mirror of https://github.com/TriliumNext/Notes
Compare commits
No commits in common. "develop" and "v0.2.0" have entirely different histories.
@ -1,26 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.sh]
|
||||
end_of_line = lf
|
||||
|
||||
[{server,translation}.json]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [eliandoran]
|
||||
custom: ["https://paypal.me/eliandoran"]
|
||||
@ -1,51 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report a bug
|
||||
type: "Bug"
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the bug and any additional information.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: TriliumNext Version
|
||||
description: What version of TriliumNext are you using?
|
||||
placeholder: 0.90.0-beta
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What operating system are you using?
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Ubuntu
|
||||
- Other Linux
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What is your setup?
|
||||
description: https://triliumnext.github.io/Docs/Wiki/quick-start.html
|
||||
options:
|
||||
- Local (no sync)
|
||||
- Local + server sync
|
||||
- Server access only
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating System Version
|
||||
description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a.
|
||||
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Error logs
|
||||
description: Please provide error logs, see [wiki page](https://triliumnext.github.io/Docs/Wiki/error-logs.html) for instructions on how to submit them.
|
||||
validations:
|
||||
required: false
|
||||
@ -1,14 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Ask for a new feature to be added
|
||||
type: "Feature"
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe feature
|
||||
description: A clear and concise description of what you want to be added.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.
|
||||
@ -1,10 +0,0 @@
|
||||
name: Task
|
||||
description: Create a new Task
|
||||
type: "Task"
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe Task
|
||||
description: A clear and concise description of what the task is about.
|
||||
validations:
|
||||
required: true
|
||||
@ -1,164 +0,0 @@
|
||||
name: "Build Electron App"
|
||||
description: "Builds and packages the Electron app for different platforms"
|
||||
|
||||
inputs:
|
||||
os:
|
||||
description: "One of the supported platforms: macos, linux, windows"
|
||||
required: true
|
||||
arch:
|
||||
description: "The architecture to build for: x64, arm64"
|
||||
required: true
|
||||
shell:
|
||||
description: "Which shell to use"
|
||||
required: true
|
||||
forge_platform:
|
||||
description: "The --platform to pass to Electron Forge"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Certificate setup
|
||||
- name: Import Apple certificates
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v5
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
|
||||
keychain: build-app-${{ github.run_id }}
|
||||
keychain-password: ${{ github.run_id }}
|
||||
|
||||
- name: Install Installer certificate
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v5
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
keychain: build-installer-${{ github.run_id }}
|
||||
keychain-password: ${{ github.run_id }}
|
||||
|
||||
- name: Verify certificates
|
||||
if: inputs.os == 'macos'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo "Available signing identities in app keychain:"
|
||||
security find-identity -v -p codesigning build-app-${{ github.run_id }}.keychain
|
||||
|
||||
echo "Available signing identities in installer keychain:"
|
||||
security find-identity -v -p codesigning build-installer-${{ github.run_id }}.keychain
|
||||
|
||||
# Make the keychains searchable
|
||||
security list-keychains -d user -s build-app-${{ github.run_id }}.keychain build-installer-${{ github.run_id }}.keychain $(security list-keychains -d user | tr -d '"')
|
||||
security default-keychain -s build-app-${{ github.run_id }}.keychain
|
||||
security unlock-keychain -p ${{ github.run_id }} build-app-${{ github.run_id }}.keychain
|
||||
security unlock-keychain -p ${{ github.run_id }} build-installer-${{ github.run_id }}.keychain
|
||||
security set-keychain-settings -t 3600 -l build-app-${{ github.run_id }}.keychain
|
||||
security set-keychain-settings -t 3600 -l build-installer-${{ github.run_id }}.keychain
|
||||
|
||||
- name: Set up Python and other macOS dependencies
|
||||
if: ${{ inputs.os == 'macos' }}
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
brew install python-setuptools
|
||||
brew install create-dmg
|
||||
|
||||
- name: Install dependencies for RPM and Flatpak package building
|
||||
if: ${{ inputs.os == 'linux' }}
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
FLATPAK_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
|
||||
FLATPAK_VERSION='24.08'
|
||||
flatpak install --user --no-deps --arch $FLATPAK_ARCH --assumeyes runtime/org.freedesktop.Platform/$FLATPAK_ARCH/$FLATPAK_VERSION runtime/org.freedesktop.Sdk/$FLATPAK_ARCH/$FLATPAK_VERSION org.electronjs.Electron2.BaseApp/$FLATPAK_ARCH/$FLATPAK_VERSION
|
||||
|
||||
- name: Update build info
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run chore:update-build-info
|
||||
|
||||
# Critical debugging configuration
|
||||
- name: Run electron-forge build with enhanced logging
|
||||
shell: ${{ inputs.shell }}
|
||||
env:
|
||||
# Pass through required environment variables for signing and notarization
|
||||
APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ env.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
|
||||
TRILIUM_ARTIFACT_NAME_HINT: TriliumNextNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
|
||||
run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
||||
|
||||
# Add DMG signing step
|
||||
- name: Sign DMG
|
||||
if: inputs.os == 'macos'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo "Signing DMG file..."
|
||||
dmg_file=$(find ./apps/desktop/dist -name "*.dmg" -print -quit)
|
||||
if [ -n "$dmg_file" ]; then
|
||||
echo "Found DMG: $dmg_file"
|
||||
# Get the first valid signing identity from the keychain
|
||||
SIGNING_IDENTITY=$(security find-identity -v -p codesigning build-app-${{ github.run_id }}.keychain | grep "Developer ID Application" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ -z "$SIGNING_IDENTITY" ]; then
|
||||
echo "Error: No valid Developer ID Application certificate found in keychain"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using signing identity: $SIGNING_IDENTITY"
|
||||
# Sign the DMG
|
||||
codesign --force --sign "$SIGNING_IDENTITY" --options runtime --timestamp "$dmg_file"
|
||||
# Notarize the DMG
|
||||
xcrun notarytool submit "$dmg_file" --apple-id "$APPLE_ID" --password "$APPLE_ID_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
|
||||
# Staple the notarization ticket
|
||||
xcrun stapler staple "$dmg_file"
|
||||
else
|
||||
echo "No DMG found to sign"
|
||||
fi
|
||||
|
||||
- name: Verify code signing
|
||||
if: inputs.os == 'macos'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo "Verifying code signing for all artifacts..."
|
||||
|
||||
# First check the .app bundle
|
||||
echo "Looking for .app bundle..."
|
||||
app_bundle=$(find ./apps/desktop/dist -name "*.app" -print -quit)
|
||||
if [ -n "$app_bundle" ]; then
|
||||
echo "Found app bundle: $app_bundle"
|
||||
echo "Verifying app bundle signing..."
|
||||
codesign --verify --deep --strict --verbose=2 "$app_bundle"
|
||||
echo "Displaying app bundle signing info..."
|
||||
codesign --display --verbose=2 "$app_bundle"
|
||||
|
||||
echo "Checking entitlements..."
|
||||
codesign --display --entitlements :- "$app_bundle"
|
||||
|
||||
echo "Checking notarization status..."
|
||||
xcrun stapler validate "$app_bundle" || echo "Warning: App bundle not notarized yet"
|
||||
else
|
||||
echo "No .app bundle found to verify"
|
||||
fi
|
||||
|
||||
# Then check DMG if it exists
|
||||
echo "Looking for DMG..."
|
||||
dmg_file=$(find ./apps/desktop/dist -name "*.dmg" -print -quit)
|
||||
if [ -n "$dmg_file" ]; then
|
||||
echo "Found DMG: $dmg_file"
|
||||
echo "Verifying DMG signing..."
|
||||
codesign --verify --deep --strict --verbose=2 "$dmg_file"
|
||||
echo "Displaying DMG signing info..."
|
||||
codesign --display --verbose=2 "$dmg_file"
|
||||
|
||||
echo "Checking DMG notarization..."
|
||||
xcrun stapler validate "$dmg_file" || echo "Warning: DMG not notarized yet"
|
||||
else
|
||||
echo "No DMG found to verify"
|
||||
fi
|
||||
|
||||
# Finally check ZIP if it exists
|
||||
echo "Looking for ZIP..."
|
||||
zip_file=$(find ./apps/desktop/dist -name "*.zip" -print -quit)
|
||||
if [ -n "$zip_file" ]; then
|
||||
echo "Found ZIP: $zip_file"
|
||||
echo "Note: ZIP files are not code signed, but their contents should be"
|
||||
fi
|
||||
@ -1,33 +0,0 @@
|
||||
inputs:
|
||||
os:
|
||||
description: "One of the supported platforms: windows"
|
||||
required: true
|
||||
arch:
|
||||
description: "The architecture to build for: x64, arm64"
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run Linux server build
|
||||
env:
|
||||
MATRIX_ARCH: ${{ inputs.arch }}
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm run chore:update-build-info
|
||||
pnpm nx --project=server package
|
||||
- name: Prepare artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p upload
|
||||
file=$(find ./apps/server/out -name '*.tar.xz' -print -quit)
|
||||
name=${{ github.ref_name }}
|
||||
cp "$file" "upload/TriliumNextNotes-Server-${name//\//-}-${{ inputs.os }}-${{ inputs.arch }}.tar.xz"
|
||||
@ -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 }}
|
||||
@ -1,150 +0,0 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/notes
|
||||
TEST_TAG: ${{ github.repository_owner }}/notes:test
|
||||
|
||||
permissions:
|
||||
pull-requests: write # for PR comments
|
||||
|
||||
jobs:
|
||||
check-affected:
|
||||
name: Check affected jobs (NX)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Check affected
|
||||
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
|
||||
|
||||
test_dev:
|
||||
name: Test development
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-affected
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run the unit tests
|
||||
run: pnpm run test:all
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test_dev
|
||||
- check-affected
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
- name: Trigger client build
|
||||
run: pnpm nx run client:build
|
||||
- name: Send client bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v3
|
||||
with:
|
||||
webpackStatsFile: ./apps/client/dist/webpack-stats.json
|
||||
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
|
||||
- name: Trigger server build
|
||||
run: pnpm nx run server:build
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
test_docker:
|
||||
name: Check Docker build
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build_docker
|
||||
- check-affected
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: Dockerfile.alpine
|
||||
- dockerfile: Dockerfile
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
- name: Trigger build
|
||||
run: pnpm nx run server:build
|
||||
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
load: true
|
||||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Validate container run output
|
||||
run: |
|
||||
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --name trilium_local ${{ env.TEST_TAG }})
|
||||
echo "Container ID: $CONTAINER_ID"
|
||||
|
||||
- name: Wait for the healthchecks to pass
|
||||
uses: stringbean/docker-healthcheck-action@v3
|
||||
with:
|
||||
container: trilium_local
|
||||
wait-time: 50
|
||||
require-status: running
|
||||
require-healthy: true
|
||||
|
||||
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
|
||||
- name: Print entire log
|
||||
if: always()
|
||||
run: journalctl -u docker CONTAINER_NAME=trilium_local --no-pager
|
||||
@ -1,307 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "develop"
|
||||
- "feature/update**"
|
||||
- "feature/server_esm**"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "bin/**"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/notes
|
||||
TEST_TAG: ${{ github.repository_owner }}/notes:test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test_docker:
|
||||
name: Check Docker build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: Dockerfile.alpine
|
||||
- dockerfile: Dockerfile
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run the TypeScript build
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
load: true
|
||||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Validate container run output
|
||||
run: |
|
||||
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --network=host -e TRILIUM_PORT=8082 --volume ./apps/server/spec/db:/home/node/trilium-data --name trilium_local ${{ env.TEST_TAG }})
|
||||
echo "Container ID: $CONTAINER_ID"
|
||||
|
||||
- name: Wait for the healthchecks to pass
|
||||
uses: stringbean/docker-healthcheck-action@v3
|
||||
with:
|
||||
container: trilium_local
|
||||
wait-time: 50
|
||||
require-status: running
|
||||
require-healthy: true
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm exec nx run server-e2e:e2e
|
||||
|
||||
- name: Upload Playwright trace
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Playwright trace (${{ matrix.dockerfile }})
|
||||
path: test-output/playwright/output
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: Playwright report (${{ matrix.dockerfile }})
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
|
||||
- name: Print entire log
|
||||
if: always()
|
||||
run: |
|
||||
journalctl -u docker CONTAINER_NAME=trilium_local --no-pager
|
||||
|
||||
build:
|
||||
name: Build Docker images
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: Dockerfile.alpine
|
||||
platform: linux/amd64
|
||||
image: ubuntu-latest
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm64
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm/v7
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm/v8
|
||||
image: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.image }}
|
||||
needs:
|
||||
- test_docker
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run the TypeScript build
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Update build info
|
||||
run: pnpm run chore:update-build-info
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifest lists
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
# Extract the branch or tag name from the ref
|
||||
REF_NAME=$(echo "${GITHUB_REF}" | sed 's/refs\/heads\///' | sed 's/refs\/tags\///')
|
||||
|
||||
# Create and push the manifest list with both the branch/tag name and the commit SHA
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# If the ref is a tag, also tag the image as stable as this is part of a 'release'
|
||||
# and only go in the `if` if there is NOT a `-` in the tag's name, due to tagging of `-alpha`, `-beta`, etc...
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* && ! "${REF_NAME}" =~ - ]]; then
|
||||
# First create stable tags
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# Small delay to ensure stable tag is fully propagated
|
||||
sleep 5
|
||||
|
||||
# Now update latest tags
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
@ -1,129 +0,0 @@
|
||||
name: Nightly Release
|
||||
on:
|
||||
# This can be used to automatically publish nightlies at UTC nighttime
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # run at 2 AM UTC
|
||||
# This can be used to allow manually triggering nightlies from the web interface
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- renovate/electron-forge*
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/actions/build-electron/*
|
||||
- .github/workflows/nightly.yml
|
||||
- forge.config.ts
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_UPLOAD_URL: https://uploads.github.com/repos/TriliumNext/Notes/releases/179589950/assets{?name,label}
|
||||
GITHUB_RELEASE_ID: 179589950
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nightly-electron:
|
||||
name: Deploy nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
os:
|
||||
- name: macos
|
||||
image: macos-latest
|
||||
shell: bash
|
||||
forge_platform: darwin
|
||||
- name: linux
|
||||
image: ubuntu-22.04
|
||||
shell: bash
|
||||
forge_platform: linux
|
||||
- name: windows
|
||||
image: win-signing
|
||||
shell: cmd
|
||||
forge_platform: win32
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Update nightly version
|
||||
run: npm run chore:ci-update-nightly-version
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-electron
|
||||
with:
|
||||
os: ${{ matrix.os.name }}
|
||||
arch: ${{ matrix.arch }}
|
||||
shell: ${{ matrix.os.shell }}
|
||||
forge_platform: ${{ matrix.os.forge_platform }}
|
||||
env:
|
||||
APPLE_APP_CERTIFICATE_BASE64: ${{ secrets.APPLE_APP_CERTIFICATE_BASE64 }}
|
||||
APPLE_APP_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_CERTIFICATE_PASSWORD }}
|
||||
APPLE_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
prerelease: true
|
||||
draft: false
|
||||
fail_on_unmatched_files: true
|
||||
files: apps/desktop/upload/*.*
|
||||
tag_name: nightly
|
||||
name: Nightly Build
|
||||
|
||||
- name: Publish artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
with:
|
||||
name: TriliumNextNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
||||
path: apps/desktop/upload
|
||||
|
||||
nightly-server:
|
||||
name: Deploy server nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
include:
|
||||
- arch: x64
|
||||
runs-on: ubuntu-22.04
|
||||
- arch: arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-server
|
||||
with:
|
||||
os: linux
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
prerelease: true
|
||||
draft: false
|
||||
fail_on_unmatched_files: true
|
||||
files: upload/*.*
|
||||
tag_name: nightly
|
||||
name: Nightly Build
|
||||
@ -1,43 +0,0 @@
|
||||
name: playwright
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
filter: tree:0
|
||||
fetch-depth: 0
|
||||
|
||||
# This enables task distribution via Nx Cloud
|
||||
# Run this command as early as possible, before dependencies are installed
|
||||
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
|
||||
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
|
||||
# - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
|
||||
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
|
||||
# - run: npx nx-cloud record -- echo Hello World
|
||||
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
|
||||
# When you enable task distribution, run the e2e-ci task instead of e2e
|
||||
- run: pnpm exec nx affected -t e2e
|
||||
@ -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 }}
|
||||
@ -1,126 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
make-electron:
|
||||
name: Make Electron
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
os:
|
||||
- name: macos
|
||||
image: macos-latest
|
||||
shell: bash
|
||||
forge_platform: darwin
|
||||
- name: linux
|
||||
image: ubuntu-latest
|
||||
shell: bash
|
||||
forge_platform: linux
|
||||
- name: windows
|
||||
image: win-signing
|
||||
shell: cmd
|
||||
forge_platform: win32
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-electron
|
||||
with:
|
||||
os: ${{ matrix.os.name }}
|
||||
arch: ${{ matrix.arch }}
|
||||
shell: ${{ matrix.os.shell }}
|
||||
forge_platform: ${{ matrix.os.forge_platform }}
|
||||
env:
|
||||
APPLE_APP_CERTIFICATE_BASE64: ${{ secrets.APPLE_APP_CERTIFICATE_BASE64 }}
|
||||
APPLE_APP_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_CERTIFICATE_PASSWORD }}
|
||||
APPLE_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
||||
path: apps/desktop/upload/*.*
|
||||
|
||||
build_server:
|
||||
name: Build Linux Server
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
include:
|
||||
- arch: x64
|
||||
runs-on: ubuntu-22.04
|
||||
- arch: arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-server
|
||||
with:
|
||||
os: linux
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: upload/*.*
|
||||
|
||||
publish_release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- make-electron
|
||||
- build_server
|
||||
steps:
|
||||
- run: mkdir upload
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
fail_on_unmatched_files: true
|
||||
files: upload/*.*
|
||||
discussion_category_name: Announcements
|
||||
make_latest: ${{ !contains(github.ref, 'rc') }}
|
||||
prerelease: ${{ contains(github.ref, 'rc') }}
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
@ -1,49 +1,9 @@
|
||||
# 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
|
||||
.DS_Store
|
||||
node_modules/
|
||||
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
|
||||
*.db
|
||||
config.ini
|
||||
cert.key
|
||||
cert.crt
|
||||
@ -1,6 +0,0 @@
|
||||
# Default ignored files
|
||||
/workspace.xml
|
||||
|
||||
# Datasource local storage ignored files
|
||||
/dataSources.local.xml
|
||||
/dataSources/
|
||||
@ -1,15 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="OTHER_INDENT_OPTIONS">
|
||||
<value>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</value>
|
||||
</option>
|
||||
<codeStyleSettings language="JSON">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="document.db" uuid="2a4ac1e6-b828-4a2a-8e4a-3f59f10aff26">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/document.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
||||
</project>
|
||||
@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@ -1,11 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptLibraryMappings">
|
||||
<includedPredefinedLibrary name="Node.js Core" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JSLintConfiguration">
|
||||
<option devel="true" />
|
||||
<option es6="true" />
|
||||
<option maxerr="50" />
|
||||
<option node="true" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,8 +0,0 @@
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_16" default="true" project-jdk-name="openjdk-16" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/trilium.iml" filepath="$PROJECT_DIR$/trilium.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$" dialect="SQLite" />
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,2 +0,0 @@
|
||||
Adam Zivner <adam.zivner@gmail.com>
|
||||
Adam Zivner <zadam.apps@gmail.com>
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
# An array of strings which contain Language Ids defined by VS Code
|
||||
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
|
||||
languageIds:
|
||||
- javascript
|
||||
- typescript
|
||||
- html
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
usageMatchRegex:
|
||||
# The following example shows how to detect `t("your.i18n.keys")`
|
||||
# the `{key}` will be placed by a proper keypath matching regex,
|
||||
# you can ignore it and use your own matching rules as well
|
||||
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
|
||||
|
||||
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
|
||||
# and works like how the i18next framework identifies the namespace scope from the
|
||||
# useTranslation() hook.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
|
||||
|
||||
# An array of strings containing refactor templates.
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
refactorTemplates:
|
||||
- t("$1")
|
||||
- ${t("$1")}
|
||||
- <%= t("$1") %>
|
||||
|
||||
|
||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||
monopoly: true
|
||||
@ -1,32 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
// Place your Notes workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
|
||||
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
|
||||
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
|
||||
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
|
||||
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
|
||||
// Placeholders with the same ids are connected.
|
||||
// Example:
|
||||
// "Print to console": {
|
||||
// "scope": "javascript,typescript",
|
||||
// "prefix": "log",
|
||||
// "body": [
|
||||
// "console.log('$1');",
|
||||
// "$2"
|
||||
// ],
|
||||
// "description": "Log output to console"
|
||||
// }
|
||||
|
||||
"JQuery HTMLElement field": {
|
||||
"scope": "typescript",
|
||||
"prefix": "jqf",
|
||||
"body": ["private $${1:name}!: JQuery<HTMLElement>;"]
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
Please treat each other with respect and understanding.
|
||||
@ -1,2 +1,43 @@
|
||||
> [!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.
|
||||
# Trilium Notes
|
||||
Trilium Notes is a hierarchical note taking application. Picture tells a thousand words:
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
* Notes can be arranged into arbitrarily deep hierarchy
|
||||
* Notes can have more than 1 parents - see [cloning](https://github.com/zadam/trilium/wiki/Cloning-notes)
|
||||
* WYSIWYG (What You See Is What You Get) editing
|
||||
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation)
|
||||
* Seamless note versioning
|
||||
* Can be deployed as web application and / or desktop application with offline access (electron based)
|
||||
* [Synchronization with](https://github.com/zadam/trilium/wiki/Synchronization) self-hosted sync server
|
||||
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes)
|
||||
|
||||
## Builds
|
||||
|
||||
* If you want to install Trilium on server, follow [this page](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
|
||||
* If you want to use Trilium on the desktop, download binary release for your platfor from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Desktop (electron-based) 64-bit builds are available for Linux and Windows.
|
||||
|
||||
Requirements for web based installation are [outlined here](https://github.com/zadam/trilium/wiki/Installation-as-webapp).
|
||||
|
||||
Currently only recent Chrome and Firefox are supported (tested) browsers. Other modern browsers (not IE) might work as well.
|
||||
|
||||
## Documentation
|
||||
|
||||
List of documentation pages:
|
||||
|
||||
* [Installation as webapp](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
|
||||
* [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation)
|
||||
* [Tree manipulation](https://github.com/zadam/trilium/wiki/Tree-manipulation)
|
||||
* [Links](https://github.com/zadam/trilium/wiki/Links)
|
||||
* [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes)
|
||||
* [Protected notes](https://github.com/zadam/trilium/wiki/Protected-notes)
|
||||
* [Synchronization](https://github.com/zadam/trilium/wiki/Synchronization)
|
||||
* [Document](https://github.com/zadam/trilium/wiki/Document)
|
||||
* [Keyboard shortcuts](https://github.com/zadam/trilium/wiki/Keyboard-shortcuts)
|
||||
* [Troubleshooting](https://github.com/zadam/trilium/wiki/Troubleshooting)
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
In the (still active) 0.X phase of the project only the latest stable minor release is getting bugfixes (including security ones).
|
||||
|
||||
So e.g. if the latest stable version is 0.42.3 and the latest beta version is 0.43.0-beta, then 0.42 line will still get security fixes but older versions (like 0.41.X) won't get any fixes.
|
||||
|
||||
Description above is a general rule and may be altered on case by case basis.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
You can report low severity vulnerabilities as GitHub issues, more severe vulnerabilities should be reported to the email [contact@eliandoran.me](mailto:contact@eliandoran.me)
|
||||
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import anonymizationService from "../src/services/anonymization.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
fs.writeFileSync(path.resolve(__dirname, "tpl", "anonymize-database.sql"), anonymizationService.getFullAnonymizationScript());
|
||||
@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if ! command -v magick &> /dev/null; then
|
||||
echo "This tool requires ImageMagick to be installed in order to create the icons."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v inkscape &> /dev/null; then
|
||||
echo "This tool requires Inkscape to be render sharper SVGs than ImageMagick."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v icnsutil &> /dev/null; then
|
||||
echo "This tool requires icnsutil to be installed in order to generate macOS icons."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir=$(realpath $(dirname $0))
|
||||
cd "${script_dir}/../images/app-icons"
|
||||
inkscape -w 180 -h 180 "../icon-color.svg" -o "./ios/apple-touch-icon.png"
|
||||
|
||||
# Build PNGs
|
||||
inkscape -w 128 -h 128 "../icon-color.svg" -o "./png/128x128.png"
|
||||
inkscape -w 256 -h 256 "../icon-color.svg" -o "./png/256x256.png"
|
||||
|
||||
# Build dev icons (including tray)
|
||||
inkscape -w 16 -h 16 "../icon-purple.svg" -o "./png/16x16-dev.png"
|
||||
inkscape -w 32 -h 32 "../icon-purple.svg" -o "./png/32x32-dev.png"
|
||||
inkscape -w 256 -h 256 "../icon-purple.svg" -o "./png/256x256-dev.png"
|
||||
|
||||
# Build Mac .icns
|
||||
declare -a sizes=("16" "32" "512" "1024")
|
||||
for size in "${sizes[@]}"; do
|
||||
inkscape -w $size -h $size "../icon-color.svg" -o "./png/${size}x${size}.png"
|
||||
done
|
||||
|
||||
mkdir -p fakeapp.app
|
||||
npx iconsur set fakeapp.app -l -i "png/1024x1024.png" -o "mac/1024x1024.png" -s 0.8
|
||||
declare -a sizes=("16x16" "32x32" "128x128" "512x512")
|
||||
for size in "${sizes[@]}"; do
|
||||
magick "mac/1024x1024.png" -resize "${size}" "mac/${size}.png"
|
||||
done
|
||||
icnsutil compose -f "mac/icon.icns" ./mac/*.png
|
||||
|
||||
# Build Windows icon
|
||||
magick -background none "../icon-color.svg" -define icon:auto-resize=16,32,48,64,128,256 "./icon.ico"
|
||||
|
||||
# Build Windows setup icon
|
||||
magick -background none "../icon-installer.svg" -define icon:auto-resize=16,32,48,64,128,256 "./win/setup.ico"
|
||||
|
||||
# Build Squirrel splash image
|
||||
magick "./png/256x256.png" -background "#ffffff" -gravity center -extent 640x480 "./win/setup-banner.gif"
|
||||
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCHEMA_FILE_PATH=db/schema.sql
|
||||
|
||||
sqlite3 ./data/document.db .schema | grep -v "sqlite_sequence" > "$SCHEMA_FILE_PATH"
|
||||
|
||||
echo "DB schema exported to $SCHEMA_FILE_PATH"
|
||||
@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Usage: tsx ./generate_document.ts 1000
|
||||
* will create 1000 new notes and some clones into the current document.db
|
||||
*/
|
||||
|
||||
import sqlInit from "../src/services/sql_init.js";
|
||||
import noteService from "../src/services/notes.js";
|
||||
import attributeService from "../src/services/attributes.js";
|
||||
import cls from "../src/services/cls.js";
|
||||
import cloningService from "../src/services/cloning.js";
|
||||
import loremIpsum from "lorem-ipsum";
|
||||
import "../src/becca/entity_constructor.js";
|
||||
|
||||
const noteCount = parseInt(process.argv[2]);
|
||||
|
||||
if (!noteCount) {
|
||||
console.error(`Please enter number of notes as program parameter.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const notes = ["root"];
|
||||
|
||||
function getRandomNoteId() {
|
||||
const index = Math.floor(Math.random() * notes.length);
|
||||
|
||||
return notes[index];
|
||||
}
|
||||
|
||||
async function start() {
|
||||
for (let i = 0; i < noteCount; i++) {
|
||||
const title = loremIpsum.loremIpsum({
|
||||
count: 1,
|
||||
units: "sentences",
|
||||
sentenceLowerBound: 1,
|
||||
sentenceUpperBound: 10
|
||||
});
|
||||
|
||||
const paragraphCount = Math.floor(Math.random() * Math.random() * 100);
|
||||
const content = loremIpsum.loremIpsum({
|
||||
count: paragraphCount,
|
||||
units: "paragraphs",
|
||||
sentenceLowerBound: 1,
|
||||
sentenceUpperBound: 15,
|
||||
paragraphLowerBound: 3,
|
||||
paragraphUpperBound: 10,
|
||||
format: "html"
|
||||
});
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: getRandomNoteId(),
|
||||
title,
|
||||
content,
|
||||
type: "text"
|
||||
});
|
||||
|
||||
console.log(`Created note ${i}: ${title}`);
|
||||
|
||||
if (Math.random() < 0.04) {
|
||||
const noteIdToClone = note.noteId;
|
||||
const parentNoteId = getRandomNoteId();
|
||||
const prefix = Math.random() > 0.8 ? "prefix" : "";
|
||||
|
||||
const result = await cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
|
||||
|
||||
console.log(`Cloning ${i}:`, result.success ? "succeeded" : "FAILED");
|
||||
}
|
||||
|
||||
// does not have to be for the current note
|
||||
await attributeService.createAttribute({
|
||||
noteId: getRandomNoteId(),
|
||||
type: "label",
|
||||
name: "label",
|
||||
value: "value",
|
||||
isInheritable: Math.random() > 0.1 // 10% are inheritable
|
||||
});
|
||||
|
||||
await attributeService.createAttribute({
|
||||
noteId: getRandomNoteId(),
|
||||
type: "relation",
|
||||
name: "relation",
|
||||
value: getRandomNoteId(),
|
||||
isInheritable: Math.random() > 0.1 // 10% are inheritable
|
||||
});
|
||||
|
||||
note.saveRevision();
|
||||
|
||||
notes.push(note.noteId);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// @TriliumNextTODO sqlInit.dbReady never seems to resolve so program hangs
|
||||
// see https://github.com/TriliumNext/Notes/issues/1020
|
||||
sqlInit.dbReady.then(cls.wrap(start)).catch((err) => console.error(err));
|
||||
@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
SERIES=${VERSION:0:4}-latest
|
||||
|
||||
docker push zadam/trilium:$VERSION
|
||||
docker push zadam/trilium:$SERIES
|
||||
|
||||
if [[ $1 != *"beta"* ]]; then
|
||||
docker push zadam/trilium:latest
|
||||
fi
|
||||
@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
|
||||
then
|
||||
echo "Version ${VERSION} isn't in format X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION_DATE=$(git log -1 --format=%aI "v${VERSION}" | cut -c -10)
|
||||
VERSION_COMMIT=$(git rev-list -n 1 "v${VERSION}")
|
||||
|
||||
# expecting the directory at a specific path
|
||||
cd ~/trilium-flathub || exit
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_BRANCH=master
|
||||
|
||||
if [[ "$VERSION" == *"beta"* ]]; then
|
||||
BASE_BRANCH=beta
|
||||
fi
|
||||
|
||||
git switch "${BASE_BRANCH}"
|
||||
git pull
|
||||
|
||||
BRANCH=b${VERSION}
|
||||
|
||||
git branch "${BRANCH}"
|
||||
git switch "${BRANCH}"
|
||||
|
||||
echo "Updating files with version ${VERSION}, date ${VERSION_DATE} and commit ${VERSION_COMMIT}"
|
||||
|
||||
flatpak-node-generator npm ../trilium/package-lock.json
|
||||
|
||||
xmlstarlet ed --inplace --update "/component/releases/release/@version" --value "${VERSION}" --update "/component/releases/release/@date" --value "${VERSION_DATE}" ./com.github.zadam.trilium.metainfo.xml
|
||||
|
||||
yq --inplace "(.modules[0].sources[0].tag = \"v${VERSION}\") | (.modules[0].sources[0].commit = \"${VERSION_COMMIT}\")" ./com.github.zadam.trilium.yml
|
||||
|
||||
git add ./generated-sources.json
|
||||
git add ./com.github.zadam.trilium.metainfo.xml
|
||||
git add ./com.github.zadam.trilium.yml
|
||||
|
||||
git commit -m "release $VERSION"
|
||||
git push --set-upstream origin "${BRANCH}"
|
||||
|
||||
gh pr create --fill -B "${BASE_BRANCH}"
|
||||
gh pr merge --auto --merge --delete-branch
|
||||
@ -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,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
#
|
||||
# Create PO files to make easier the labor of translation.
|
||||
#
|
||||
# Info:
|
||||
# https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
|
||||
# https://docs.translatehouse.org/projects/translate-toolkit/en/latest/commands/json2po.html
|
||||
#
|
||||
# Dependencies:
|
||||
# jq
|
||||
# translate-toolkit
|
||||
# python-wcwidth
|
||||
#
|
||||
# Created by @hasecilu
|
||||
#
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
|
||||
number_of_keys() {
|
||||
[ -f "$1" ] && jq 'path(..) | select(length == 2) | .[1]' "$1" | wc -l || echo "0"
|
||||
}
|
||||
|
||||
stats() {
|
||||
# Print the number of existing strings on the JSON files for each locale
|
||||
s=$(number_of_keys "${paths[0]}/en/server.json")
|
||||
c=$(number_of_keys "${paths[1]}/en/translation.json")
|
||||
echo "| locale | server strings | client strings |"
|
||||
echo "|--------|----------------|----------------|"
|
||||
echo "| en | ${s} | ${c} |"
|
||||
echo "|--------|----------------|----------------|"
|
||||
for locale in "${locales[@]}"; do
|
||||
s=$(number_of_keys "${paths[0]}/${locale}/server.json")
|
||||
c=$(number_of_keys "${paths[1]}/${locale}/translation.json")
|
||||
n1=$(((8 - ${#locale}) / 2))
|
||||
n2=$((n1 == 1 ? n1 + 1 : n1))
|
||||
echo "|$(printf "%${n1}s")${locale}$(printf "%${n2}s")| ${s} | ${c} |"
|
||||
done
|
||||
}
|
||||
|
||||
update_1() {
|
||||
# Update PO files from English and localized JSON files as source
|
||||
# NOTE: if you want a new language you need to first create the JSON files
|
||||
# on their corresponding place with `{}` as content to avoid error on `json2po`
|
||||
local locales=("$@")
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
json2po -t "${path}/en" "${path}/${locale}" "${path}/po-${locale}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
update_2() {
|
||||
# Recover translation from PO files to localized JSON files
|
||||
local locales=("$@")
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
po2json -t "${path}/en" "${path}/po-${locale}" "${path}/${locale}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
help() {
|
||||
echo -e "\nDescription:"
|
||||
echo -e "\tCreate PO files to make easier the labor of translation"
|
||||
echo -e "\nUsage:"
|
||||
echo -e "\t./translation.sh [--stats] [--update1 <OPT_LOCALE>] [--update2 <OPT_LOCALE>]"
|
||||
echo -e "\nFlags:"
|
||||
echo -e " --clear\n\tClear all po-* directories"
|
||||
echo -e " --stats\n\tPrint the number of existing strings on the JSON files for each locale"
|
||||
echo -e " --update1 <LOCALE>\n\tUpdate PO files from English and localized JSON files as source"
|
||||
echo -e " --update2 <LOCALE>\n\tRecover translation from PO files to localized JSON files"
|
||||
}
|
||||
|
||||
# Main function ------------------------------------------------------------------------------------
|
||||
|
||||
# Get script directory to set file path relative to it
|
||||
file_path="$(
|
||||
cd -- "$(dirname "${0}")" >/dev/null 2>&1 || exit
|
||||
pwd -P
|
||||
)"
|
||||
paths=(
|
||||
"${file_path}/../../apps/server/src/assets/translations/"
|
||||
"${file_path}/../../apps/client/src/translations/"
|
||||
)
|
||||
locales=(cn de es fr pt_br ro tw)
|
||||
|
||||
if [ $# -eq 1 ]; then
|
||||
if [ "$1" == "--clear" ]; then
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
[ -d "${path}/po-${locale}" ] && rm -r "${path}/po-${locale}"
|
||||
done
|
||||
done
|
||||
elif [ "$1" == "--stats" ]; then
|
||||
stats
|
||||
elif [ "$1" == "--update1" ]; then
|
||||
update_1 "${locales[@]}"
|
||||
elif [ "$1" == "--update2" ]; then
|
||||
update_2 "${locales[@]}"
|
||||
else
|
||||
help
|
||||
fi
|
||||
elif [ $# -eq 2 ]; then
|
||||
if [ "$1" == "--update1" ]; then
|
||||
update_1 "$2"
|
||||
elif [ "$1" == "--update2" ]; then
|
||||
update_2 "$2"
|
||||
else
|
||||
help
|
||||
fi
|
||||
else
|
||||
help
|
||||
fi
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-bookmark"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 7v14l-6 -4l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4z" /></svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if ! command -v magick &> /dev/null; then
|
||||
echo "This tool requires ImageMagick to be installed in order to create the icons."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v inkscape &> /dev/null; then
|
||||
echo "This tool requires Inkscape to be render sharper SVGs than ImageMagick."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir=$(realpath $(dirname $0))
|
||||
images_dir="$script_dir/../../images"
|
||||
output_dir="$images_dir/app-icons/tray"
|
||||
|
||||
function generateDpiScaledIcons {
|
||||
file=$1
|
||||
suffix=$2
|
||||
name="$(basename $file .svg)$suffix"
|
||||
inkscape -w 16 -h 16 "$file" -o "$output_dir/$name.png"
|
||||
inkscape -w 20 -h 20 "$file" -o "$output_dir/$name@1.25x.png"
|
||||
inkscape -w 24 -h 24 "$file" -o "$output_dir/$name@1.5x.png"
|
||||
inkscape -w 32 -h 32 "$file" -o "$output_dir/$name@2x.png"
|
||||
}
|
||||
|
||||
generateDpiScaledIcons "$images_dir/icon-black.svg" "Template"
|
||||
generateDpiScaledIcons "$images_dir/icon-color.svg"
|
||||
generateDpiScaledIcons "$images_dir/icon-purple.svg"
|
||||
|
||||
for file in *.svg; do
|
||||
name="$(basename $file .svg)Template"
|
||||
generateDpiScaledIcons "$file" "Template"
|
||||
magick "$output_dir/$name.png" -channel RGB -negate "$output_dir/$name-inverted.png"
|
||||
magick "$output_dir/$name@1.25x.png" -channel RGB -negate "$output_dir/$name-inverted@1.25x.png"
|
||||
magick "$output_dir/$name@1.5x.png" -channel RGB -negate "$output_dir/$name-inverted@1.5x.png"
|
||||
magick "$output_dir/$name@2x.png" -channel RGB -negate "$output_dir/$name-inverted@2x.png"
|
||||
done
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-plus"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>
|
||||
|
Before Width: | Height: | Size: 357 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-history"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 8l0 4l2 2" /><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" /></svg>
|
||||
|
Before Width: | Height: | Size: 387 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-calendar-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 21h-5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3.5" /><path d="M16 3v4" /><path d="M8 3v4" /><path d="M4 11h11" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" /></svg>
|
||||
|
Before Width: | Height: | Size: 734 B |
@ -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,17 +0,0 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
|
||||
const authFile = "playwright/.auth/user.json";
|
||||
|
||||
const ROOT_URL = "http://localhost:8082";
|
||||
const LOGIN_PASSWORD = "demo1234";
|
||||
|
||||
// Reference: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto(ROOT_URL);
|
||||
await expect(page).toHaveURL(`${ROOT_URL}/login`);
|
||||
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(LOGIN_PASSWORD);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
@ -1,9 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("Can duplicate note with broken links", async ({ page }) => {
|
||||
await page.goto(`http://localhost:8082/#2VammGGdG6Ie`);
|
||||
await page.locator(".tree-wrapper .fancytree-active").getByText("Note map").click({ button: "right" });
|
||||
await page.getByText("Duplicate subtree").click();
|
||||
await expect(page.locator(".toast-body")).toBeHidden();
|
||||
await expect(page.locator(".tree-wrapper").getByText("Note map (dup)")).toBeVisible();
|
||||
});
|
||||
@ -1,18 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test("get started link", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole("link", { name: "Get started" }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible();
|
||||
});
|
||||
@ -1,21 +0,0 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
|
||||
test("Native Title Bar not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsAppearance");
|
||||
await expect(page.getByRole("heading", { name: "Theme" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("Tray settings not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsOther");
|
||||
await expect(page.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("Spellcheck settings not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsSpellcheck");
|
||||
await expect(page.getByRole("heading", { name: "Spell Check" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
|
||||
await expect(page.getByText("These options apply only for desktop builds")).toBeVisible();
|
||||
await expect(page.getByText("Enable spellcheck")).toBeHidden();
|
||||
});
|
||||
@ -1,18 +0,0 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
|
||||
test("Renders on desktop", async ({ page, context }) => {
|
||||
await page.goto("http://localhost:8082");
|
||||
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
|
||||
});
|
||||
|
||||
test("Renders on mobile", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
url: "http://localhost:8082",
|
||||
name: "trilium-device",
|
||||
value: "mobile"
|
||||
}
|
||||
]);
|
||||
await page.goto("http://localhost:8082");
|
||||
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const expectedVersion = "0.90.3";
|
||||
|
||||
test("Displays update badge when there is a version available", async ({ page }) => {
|
||||
await page.goto("http://localhost:8080");
|
||||
await page.getByRole("button", { name: "" }).click();
|
||||
await page.getByText(`Version ${expectedVersion} is available,`).click();
|
||||
|
||||
const page1 = await page.waitForEvent("popup");
|
||||
expect(page1.url()).toBe(`https://github.com/TriliumNext/Notes/releases/tag/v${expectedVersion}`);
|
||||
});
|
||||
@ -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,155 +0,0 @@
|
||||
import type child_process from "child_process";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
|
||||
let etapiAuthToken: string | undefined;
|
||||
|
||||
const getEtapiAuthorizationHeader = (): string => "Basic " + Buffer.from(`etapi:${etapiAuthToken}`).toString("base64");
|
||||
|
||||
const PORT: string = "9999";
|
||||
const HOST: string = "http://localhost:" + PORT;
|
||||
|
||||
type SpecDefinitionsFunc = () => void;
|
||||
|
||||
function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void {
|
||||
describe(description, () => {
|
||||
let appProcess: ReturnType<typeof child_process.spawn>;
|
||||
|
||||
beforeAll(async () => {});
|
||||
|
||||
afterAll(() => {});
|
||||
|
||||
specDefinitions();
|
||||
});
|
||||
}
|
||||
|
||||
async function getEtapiResponse(url: string): Promise<Response> {
|
||||
return await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getEtapi(url: string): Promise<any> {
|
||||
const response = await getEtapiResponse(url);
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function getEtapiContent(url: string): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function postEtapiContent(url: string, data: BodyInit): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function putEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function putEtapiContent(url: string, data?: BodyInit): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function patchEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function deleteEtapi(url: string): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function processEtapiResponse(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`ETAPI error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return text?.trim() ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function checkStatus(response: Response): void {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`ETAPI error ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
describeEtapi,
|
||||
getEtapi,
|
||||
getEtapiResponse,
|
||||
getEtapiContent,
|
||||
postEtapi,
|
||||
postEtapiContent,
|
||||
putEtapi,
|
||||
putEtapiContent,
|
||||
patchEtapi,
|
||||
deleteEtapi
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./build",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2023"],
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["./src/public/app/**/*"],
|
||||
"files": [
|
||||
"./src/public/app/types.d.ts",
|
||||
"./src/public/app/types-lib.d.ts",
|
||||
"./src/types.d.ts"
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
const log = require('./services/log');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const favicon = require('serve-favicon');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bodyParser = require('body-parser');
|
||||
const helmet = require('helmet');
|
||||
const session = require('express-session');
|
||||
const FileStore = require('session-file-store')(session);
|
||||
const os = require('os');
|
||||
const sessionSecret = require('./services/session_secret');
|
||||
|
||||
const app = express();
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
log.request(req);
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(bodyParser.json({limit: '50mb'}));
|
||||
app.use(bodyParser.urlencoded({extended: false}));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
const sessionParser = session({
|
||||
secret: sessionSecret,
|
||||
resave: false, // true forces the session to be saved back to the session store, even if the session was never modified during the request.
|
||||
saveUninitialized: false, // true forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified.
|
||||
cookie: {
|
||||
// path: "/",
|
||||
httpOnly: true,
|
||||
maxAge: 1800000
|
||||
},
|
||||
store: new FileStore({
|
||||
ttl: 30 * 24 * 3600,
|
||||
path: os.tmpdir() + '/trilium-sessions'
|
||||
})
|
||||
});
|
||||
app.use(sessionParser);
|
||||
|
||||
app.use(favicon(__dirname + '/public/images/app-icons/win/icon.ico'));
|
||||
|
||||
require('./routes/routes').register(app);
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use((req, res, next) => {
|
||||
const err = new Error('Router not found for request ' + req.url);
|
||||
err.status = 404;
|
||||
next(err);
|
||||
});
|
||||
|
||||
// error handler
|
||||
app.use((err, req, res, next) => {
|
||||
log.error(err.message);
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.send({
|
||||
message: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// triggers sync timer
|
||||
require('./services/sync');
|
||||
|
||||
// triggers backup timer
|
||||
require('./services/backup');
|
||||
|
||||
// trigger consistency checks timer
|
||||
require('./services/consistency_checks');
|
||||
|
||||
module.exports = {
|
||||
app,
|
||||
sessionParser
|
||||
};
|
||||
@ -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}`;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /share/
|
||||
Disallow: /
|
||||
@ -1,608 +0,0 @@
|
||||
import froca from "../services/froca.js";
|
||||
import RootCommandExecutor from "./root_command_executor.js";
|
||||
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { hasTouchBar } from "../services/utils.js";
|
||||
import zoomComponent from "./zoom.js";
|
||||
import TabManager from "./tab_manager.js";
|
||||
import Component from "./component.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
import linkService, { type ViewScope } from "../services/link.js";
|
||||
import MobileScreenSwitcherExecutor, { type Screen } from "./mobile_screen_switcher.js";
|
||||
import MainTreeExecutors from "./main_tree_executors.js";
|
||||
import toast from "../services/toast.js";
|
||||
import ShortcutComponent from "./shortcut_component.js";
|
||||
import { t, initLocale } from "../services/i18n.js";
|
||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
||||
import type LoadResults from "../services/load_results.js";
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
|
||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
||||
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
|
||||
import type { NativeImage, TouchBar } from "electron";
|
||||
import TouchBarComponent from "./touch_bar.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { StartupChecks } from "./startup_checks.js";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
}
|
||||
|
||||
interface RootWidget extends Component {
|
||||
render: () => JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
interface BeforeUploadListener extends Component {
|
||||
beforeUnloadEvent(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for the data/arguments for a given command (see {@link CommandMappings}).
|
||||
*/
|
||||
export interface CommandData {
|
||||
ntxId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a set of commands that are triggered from the context menu, providing information such as the selected note.
|
||||
*/
|
||||
export interface ContextMenuCommandData extends CommandData {
|
||||
node: Fancytree.FancytreeNode;
|
||||
notePath?: string;
|
||||
noteId?: string;
|
||||
selectedOrActiveBranchIds: string[];
|
||||
selectedOrActiveNoteIds?: string[];
|
||||
}
|
||||
|
||||
export interface NoteCommandData extends CommandData {
|
||||
notePath?: string | null;
|
||||
hoistedNoteId?: string | null;
|
||||
viewScope?: ViewScope;
|
||||
}
|
||||
|
||||
export interface ExecuteCommandData<T> extends CommandData {
|
||||
resolve: (data: T) => void;
|
||||
}
|
||||
|
||||
export interface NoteSwitchedContext {
|
||||
noteContext: NoteContext;
|
||||
notePath: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys represent the different commands that can be triggered via {@link AppContext#triggerCommand} (first argument), and the values represent the data or arguments definition of the given command. All data for commands must extend {@link CommandData}.
|
||||
*/
|
||||
export type CommandMappings = {
|
||||
"api-log-messages": CommandData;
|
||||
focusTree: CommandData;
|
||||
focusOnTitle: CommandData;
|
||||
focusOnDetail: CommandData;
|
||||
focusOnSearchDefinition: Required<CommandData>;
|
||||
searchNotes: CommandData & {
|
||||
searchString?: string;
|
||||
ancestorNoteId?: string | null;
|
||||
};
|
||||
closeTocCommand: CommandData;
|
||||
closeHlt: CommandData;
|
||||
showLaunchBarSubtree: CommandData;
|
||||
showRevisions: CommandData;
|
||||
showLlmChat: CommandData;
|
||||
createAiChat: CommandData;
|
||||
showOptions: CommandData & {
|
||||
section: string;
|
||||
};
|
||||
showExportDialog: CommandData & {
|
||||
notePath: string;
|
||||
defaultType: "single" | "subtree";
|
||||
};
|
||||
showDeleteNotesDialog: CommandData & {
|
||||
branchIdsToDelete: string[];
|
||||
callback: (value: ResolveOptions) => void;
|
||||
forceDeleteAllClones: boolean;
|
||||
};
|
||||
showConfirmDeleteNoteBoxWithNoteDialog: ConfirmWithTitleOptions;
|
||||
openedFileUpdated: CommandData & {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastModifiedMs: number;
|
||||
filePath: string;
|
||||
};
|
||||
focusAndSelectTitle: CommandData & {
|
||||
isNewNote?: boolean;
|
||||
};
|
||||
showPromptDialog: PromptDialogOptions;
|
||||
showInfoDialog: ConfirmWithMessageOptions;
|
||||
showConfirmDialog: ConfirmWithMessageOptions;
|
||||
showRecentChanges: CommandData & { ancestorNoteId: string };
|
||||
showImportDialog: CommandData & { noteId: string };
|
||||
openNewNoteSplit: NoteCommandData;
|
||||
openInWindow: NoteCommandData;
|
||||
openNoteInNewTab: CommandData;
|
||||
openNoteInNewSplit: CommandData;
|
||||
openNoteInNewWindow: CommandData;
|
||||
openAboutDialog: CommandData;
|
||||
hideFloatingButtons: {};
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
noteContextReorder: CommandData & {
|
||||
ntxIdsInOrder: string[];
|
||||
oldMainNtxId?: string | null;
|
||||
newMainNtxId?: string | null;
|
||||
};
|
||||
openInTab: ContextMenuCommandData;
|
||||
openNoteInSplit: ContextMenuCommandData;
|
||||
toggleNoteHoisting: ContextMenuCommandData;
|
||||
insertNoteAfter: ContextMenuCommandData;
|
||||
insertChildNote: ContextMenuCommandData;
|
||||
delete: ContextMenuCommandData;
|
||||
editNoteTitle: {};
|
||||
protectSubtree: ContextMenuCommandData;
|
||||
unprotectSubtree: ContextMenuCommandData;
|
||||
openBulkActionsDialog:
|
||||
| ContextMenuCommandData
|
||||
| {
|
||||
selectedOrActiveNoteIds?: string[];
|
||||
};
|
||||
editBranchPrefix: ContextMenuCommandData;
|
||||
convertNoteToAttachment: ContextMenuCommandData;
|
||||
duplicateSubtree: ContextMenuCommandData;
|
||||
expandSubtree: ContextMenuCommandData;
|
||||
collapseSubtree: ContextMenuCommandData;
|
||||
sortChildNotes: ContextMenuCommandData;
|
||||
copyNotePathToClipboard: ContextMenuCommandData;
|
||||
recentChangesInSubtree: ContextMenuCommandData;
|
||||
cutNotesToClipboard: ContextMenuCommandData;
|
||||
copyNotesToClipboard: ContextMenuCommandData;
|
||||
pasteNotesFromClipboard: ContextMenuCommandData;
|
||||
pasteNotesAfterFromClipboard: ContextMenuCommandData;
|
||||
moveNotesTo: ContextMenuCommandData;
|
||||
cloneNotesTo: ContextMenuCommandData;
|
||||
deleteNotes: ContextMenuCommandData;
|
||||
importIntoNote: ContextMenuCommandData;
|
||||
exportNote: ContextMenuCommandData;
|
||||
searchInSubtree: ContextMenuCommandData;
|
||||
moveNoteUp: ContextMenuCommandData;
|
||||
moveNoteDown: ContextMenuCommandData;
|
||||
moveNoteUpInHierarchy: ContextMenuCommandData;
|
||||
moveNoteDownInHierarchy: ContextMenuCommandData;
|
||||
selectAllNotesInParent: ContextMenuCommandData;
|
||||
|
||||
createNoteIntoInbox: CommandData;
|
||||
|
||||
addNoteLauncher: ContextMenuCommandData;
|
||||
addScriptLauncher: ContextMenuCommandData;
|
||||
addWidgetLauncher: ContextMenuCommandData;
|
||||
addSpacerLauncher: ContextMenuCommandData;
|
||||
moveLauncherToVisible: ContextMenuCommandData;
|
||||
moveLauncherToAvailable: ContextMenuCommandData;
|
||||
resetLauncher: ContextMenuCommandData;
|
||||
|
||||
executeInActiveNoteDetailWidget: CommandData & {
|
||||
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
|
||||
};
|
||||
executeWithTextEditor: CommandData &
|
||||
ExecuteCommandData<CKTextEditor> & {
|
||||
callback?: GetTextEditorCallback;
|
||||
};
|
||||
executeWithCodeEditor: CommandData & ExecuteCommandData<CodeMirror>;
|
||||
/**
|
||||
* Called upon when attempting to retrieve the content element of a {@link NoteContext}.
|
||||
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
|
||||
*/
|
||||
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
|
||||
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
|
||||
addTextToActiveEditor: CommandData & {
|
||||
text: string;
|
||||
};
|
||||
/** Works only in the electron context menu. */
|
||||
replaceMisspelling: CommandData;
|
||||
|
||||
importMarkdownInline: CommandData;
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
updateAttributesList: {
|
||||
attributes: Attribute[];
|
||||
};
|
||||
|
||||
addNewLabel: CommandData;
|
||||
addNewRelation: CommandData;
|
||||
addNewLabelDefinition: CommandData;
|
||||
addNewRelationDefinition: CommandData;
|
||||
|
||||
cloneNoteIdsTo: CommandData & {
|
||||
noteIds: string[];
|
||||
};
|
||||
moveBranchIdsTo: CommandData & {
|
||||
branchIds: string[];
|
||||
};
|
||||
/** Sets the active {@link Screen} (e.g. to toggle the tree sidebar). It triggers the {@link EventMappings.activeScreenChanged} event, but only if the provided <em>screen</em> is different than the current one. */
|
||||
setActiveScreen: CommandData & {
|
||||
screen: Screen;
|
||||
};
|
||||
closeTab: CommandData;
|
||||
closeToc: CommandData;
|
||||
closeOtherTabs: CommandData;
|
||||
closeRightTabs: CommandData;
|
||||
closeAllTabs: CommandData;
|
||||
reopenLastTab: CommandData;
|
||||
moveTabToNewWindow: CommandData;
|
||||
copyTabToNewWindow: CommandData;
|
||||
closeActiveTab: CommandData & {
|
||||
$el: JQuery<HTMLElement>;
|
||||
};
|
||||
setZoomFactorAndSave: {
|
||||
zoomFactor: string;
|
||||
};
|
||||
|
||||
reEvaluateRightPaneVisibility: CommandData;
|
||||
runActiveNote: CommandData;
|
||||
scrollContainerToCommand: CommandData & {
|
||||
position: number;
|
||||
};
|
||||
scrollToEnd: CommandData;
|
||||
closeThisNoteSplit: CommandData;
|
||||
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
|
||||
jumpToNote: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent };
|
||||
|
||||
toggleZenMode: CommandData;
|
||||
|
||||
updateAttributeList: CommandData & { attributes: Attribute[] };
|
||||
saveAttributes: CommandData;
|
||||
reloadAttributes: CommandData;
|
||||
refreshNoteList: CommandData & { noteId: string };
|
||||
|
||||
refreshResults: {};
|
||||
refreshSearchDefinition: {};
|
||||
|
||||
geoMapCreateChildNote: CommandData;
|
||||
|
||||
buildTouchBar: CommandData & {
|
||||
TouchBar: typeof TouchBar;
|
||||
buildIcon(name: string): NativeImage;
|
||||
};
|
||||
refreshTouchBar: CommandData;
|
||||
reloadTextEditor: CommandData;
|
||||
};
|
||||
|
||||
type EventMappings = {
|
||||
initialRenderComplete: {};
|
||||
frocaReloaded: {};
|
||||
setLeftPaneVisibility: {
|
||||
leftPaneVisible: boolean | null;
|
||||
}
|
||||
protectedSessionStarted: {};
|
||||
notesReloaded: {
|
||||
noteIds: string[];
|
||||
};
|
||||
refreshIncludedNote: {
|
||||
noteId: string;
|
||||
};
|
||||
apiLogMessages: {
|
||||
noteId: string;
|
||||
messages: string[];
|
||||
};
|
||||
entitiesReloaded: {
|
||||
loadResults: LoadResults;
|
||||
};
|
||||
addNewLabel: CommandData;
|
||||
addNewRelation: CommandData;
|
||||
sqlQueryResults: CommandData & {
|
||||
results: SqlExecuteResults;
|
||||
};
|
||||
readOnlyTemporarilyDisabled: {
|
||||
noteContext: NoteContext;
|
||||
};
|
||||
/** Triggered when the {@link CommandMappings.setActiveScreen} command is invoked. */
|
||||
activeScreenChanged: {
|
||||
activeScreen: Screen;
|
||||
};
|
||||
activeContextChanged: {
|
||||
noteContext: NoteContext;
|
||||
};
|
||||
beforeNoteSwitch: {
|
||||
noteContext: NoteContext;
|
||||
};
|
||||
beforeNoteContextRemove: {
|
||||
ntxIds: string[];
|
||||
};
|
||||
noteSwitched: NoteSwitchedContext;
|
||||
noteSwitchedAndActivated: NoteSwitchedContext;
|
||||
setNoteContext: {
|
||||
noteContext: NoteContext;
|
||||
};
|
||||
reEvaluateHighlightsListWidgetVisibility: {
|
||||
noteId: string | undefined;
|
||||
};
|
||||
reEvaluateTocWidgetVisibility: {
|
||||
noteId: string | undefined;
|
||||
};
|
||||
showHighlightsListWidget: {
|
||||
noteId: string;
|
||||
};
|
||||
showTocWidget: {
|
||||
noteId: string;
|
||||
};
|
||||
showSearchError: {
|
||||
error: string;
|
||||
};
|
||||
searchRefreshed: { ntxId?: string | null };
|
||||
hoistedNoteChanged: {
|
||||
noteId: string;
|
||||
ntxId: string | null;
|
||||
};
|
||||
contextsReopened: {
|
||||
ntxId?: string;
|
||||
mainNtxId: string | null;
|
||||
tabPosition: number;
|
||||
afterNtxId?: string;
|
||||
};
|
||||
noteDetailRefreshed: {
|
||||
ntxId?: string | null;
|
||||
};
|
||||
noteContextReorder: {
|
||||
oldMainNtxId: string;
|
||||
newMainNtxId: string;
|
||||
ntxIdsInOrder: string[];
|
||||
};
|
||||
newNoteContextCreated: {
|
||||
noteContext: NoteContext;
|
||||
};
|
||||
noteContextRemoved: {
|
||||
ntxIds: string[];
|
||||
};
|
||||
exportSvg: { ntxId: string | null | undefined; };
|
||||
exportPng: { ntxId: string | null | undefined; };
|
||||
geoMapCreateChildNote: {
|
||||
ntxId: string | null | undefined; // TODO: deduplicate ntxId
|
||||
};
|
||||
tabReorder: {
|
||||
ntxIdsInOrder: string[];
|
||||
};
|
||||
refreshNoteList: {
|
||||
noteId: string;
|
||||
};
|
||||
noteTypeMimeChanged: { noteId: string };
|
||||
zenModeChanged: { isEnabled: boolean };
|
||||
relationMapCreateChildNote: { ntxId: string | null | undefined };
|
||||
relationMapResetPanZoom: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
||||
activeNoteChanged: {};
|
||||
showAddLinkDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
text: string;
|
||||
};
|
||||
showIncludeDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
};
|
||||
openBulkActionsDialog: {
|
||||
selectedOrActiveNoteIds: string[];
|
||||
};
|
||||
cloneNoteIdsTo: {
|
||||
noteIds: string[];
|
||||
};
|
||||
refreshData: { ntxId: string | null | undefined };
|
||||
};
|
||||
|
||||
export type EventListener<T extends EventNames> = {
|
||||
[key in T as `${key}Event`]: (data: EventData<T>) => void;
|
||||
};
|
||||
|
||||
export type CommandListener<T extends CommandNames> = {
|
||||
[key in T as `${key}Command`]: (data: CommandListenerData<T>) => void;
|
||||
};
|
||||
|
||||
export type CommandListenerData<T extends CommandNames> = CommandMappings[T];
|
||||
|
||||
type CommandAndEventMappings = CommandMappings & EventMappings;
|
||||
type EventOnlyNames = keyof EventMappings;
|
||||
export type EventNames = CommandNames | EventOnlyNames;
|
||||
export type EventData<T extends EventNames> = CommandAndEventMappings[T];
|
||||
|
||||
/**
|
||||
* This type is a discriminated union which contains all the possible commands that can be triggered via {@link AppContext.triggerCommand}.
|
||||
*/
|
||||
export type CommandNames = keyof CommandMappings;
|
||||
|
||||
type FilterByValueType<T, ValueType> = { [K in keyof T]: T[K] extends ValueType ? K : never }[keyof T];
|
||||
|
||||
/**
|
||||
* Generic which filters {@link CommandNames} to provide only those commands that take in as data the desired implementation of {@link CommandData}. Mostly useful for contextual menu, to enforce consistency in the commands.
|
||||
*/
|
||||
export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMappings, FilterByValueType<CommandMappings, T>>;
|
||||
|
||||
export class AppContext extends Component {
|
||||
isMainWindow: boolean;
|
||||
components: Component[];
|
||||
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
|
||||
tabManager!: TabManager;
|
||||
layout?: Layout;
|
||||
noteTreeWidget?: NoteTreeWidget;
|
||||
|
||||
lastSearchString?: string;
|
||||
|
||||
constructor(isMainWindow: boolean) {
|
||||
super();
|
||||
|
||||
this.isMainWindow = isMainWindow;
|
||||
// non-widget/layout components needed for the application
|
||||
this.components = [];
|
||||
this.beforeUnloadListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called as soon as possible, before the creation of any components since this method is in charge of initializing the locale. Any attempts to read translation before this method is called will result in `undefined`.
|
||||
*/
|
||||
async earlyInit() {
|
||||
await options.initializedPromise;
|
||||
await initLocale();
|
||||
}
|
||||
|
||||
setLayout(layout: Layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.initComponents();
|
||||
this.renderWidgets();
|
||||
|
||||
await froca.initializedPromise;
|
||||
|
||||
this.tabManager.loadTabs();
|
||||
|
||||
const bundleService = (await import("../services/bundle.js")).default;
|
||||
setTimeout(() => bundleService.executeStartupBundles(), 2000);
|
||||
}
|
||||
|
||||
initComponents() {
|
||||
this.tabManager = new TabManager();
|
||||
|
||||
this.components = [
|
||||
this.tabManager,
|
||||
new RootCommandExecutor(),
|
||||
new Entrypoints(),
|
||||
new MainTreeExecutors(),
|
||||
new ShortcutComponent(),
|
||||
new StartupChecks()
|
||||
];
|
||||
|
||||
if (utils.isMobile()) {
|
||||
this.components.push(new MobileScreenSwitcherExecutor());
|
||||
}
|
||||
|
||||
for (const component of this.components) {
|
||||
this.child(component);
|
||||
}
|
||||
|
||||
if (utils.isElectron()) {
|
||||
this.child(zoomComponent);
|
||||
}
|
||||
|
||||
if (hasTouchBar) {
|
||||
this.child(new TouchBarComponent());
|
||||
}
|
||||
}
|
||||
|
||||
renderWidgets() {
|
||||
if (!this.layout) {
|
||||
throw new Error("Missing layout.");
|
||||
}
|
||||
|
||||
const rootWidget = this.layout.getRootWidget(this);
|
||||
const $renderedWidget = rootWidget.render();
|
||||
|
||||
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
|
||||
|
||||
$("body").append($renderedWidget);
|
||||
|
||||
$renderedWidget.on("click", "[data-trigger-command]", function () {
|
||||
if ($(this).hasClass("disabled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandName = $(this).attr("data-trigger-command");
|
||||
const $component = $(this).closest(".component");
|
||||
const component = $component.prop("component");
|
||||
|
||||
component.triggerCommand(commandName, { $el: $(this) });
|
||||
});
|
||||
|
||||
this.child(rootWidget);
|
||||
|
||||
this.triggerEvent("initialRenderComplete", {});
|
||||
}
|
||||
|
||||
triggerEvent<K extends EventNames>(name: K, data: EventData<K>) {
|
||||
return this.handleEvent(name, data);
|
||||
}
|
||||
|
||||
triggerCommand<K extends CommandNames>(name: K, _data?: CommandMappings[K]) {
|
||||
const data = _data || {};
|
||||
for (const executor of this.components) {
|
||||
const fun = (executor as any)[`${name}Command`];
|
||||
|
||||
if (fun) {
|
||||
return executor.callMethod(fun, data);
|
||||
}
|
||||
}
|
||||
|
||||
// this might hint at error, but sometimes this is used by components which are at different places
|
||||
// in the component tree to communicate with each other
|
||||
console.debug(`Unhandled command ${name}, converting to event.`);
|
||||
|
||||
return this.triggerEvent(name, data as CommandAndEventMappings[K]);
|
||||
}
|
||||
|
||||
getComponentByEl(el: HTMLElement) {
|
||||
return $(el).closest(".component").prop("component");
|
||||
}
|
||||
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener) {
|
||||
if (typeof WeakRef !== "function") {
|
||||
// older browsers don't support WeakRef
|
||||
return;
|
||||
}
|
||||
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
}
|
||||
}
|
||||
|
||||
const appContext = new AppContext(window.glob.isMainWindow);
|
||||
|
||||
// we should save all outstanding changes before the page/app is closed
|
||||
$(window).on("beforeunload", () => {
|
||||
let allSaved = true;
|
||||
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref());
|
||||
|
||||
for (const weakRef of appContext.beforeUnloadListeners) {
|
||||
const component = weakRef.deref();
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
|
||||
allSaved = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSaved) {
|
||||
return "some string";
|
||||
}
|
||||
});
|
||||
|
||||
$(window).on("hashchange", function () {
|
||||
const { notePath, ntxId, viewScope, searchString } = linkService.parseNavigationStateFromUrl(window.location.href);
|
||||
|
||||
if (notePath || ntxId) {
|
||||
appContext.tabManager.switchToNoteContext(ntxId, notePath, viewScope);
|
||||
} else if (searchString) {
|
||||
appContext.triggerCommand("searchNotes", { searchString });
|
||||
}
|
||||
});
|
||||
|
||||
export default appContext;
|
||||
@ -1,129 +0,0 @@
|
||||
import utils from "../services/utils.js";
|
||||
import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js";
|
||||
|
||||
/**
|
||||
* Abstract class for all components in the Trilium's frontend.
|
||||
*
|
||||
* Contains also event implementation with following properties:
|
||||
* - event / command distribution is synchronous which among others mean that events are well-ordered - event
|
||||
* which was sent out first will also be processed first by the component
|
||||
* - execution of the event / command is asynchronous - each component executes the event on its own without regard for
|
||||
* other components.
|
||||
* - although the execution is async, we are collecting all the promises, and therefore it is possible to wait until the
|
||||
* event / command is executed in all components - by simply awaiting the `triggerEvent()`.
|
||||
*/
|
||||
export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
$widget!: JQuery<HTMLElement>;
|
||||
componentId: string;
|
||||
children: ChildT[];
|
||||
initialized: Promise<void> | null;
|
||||
parent?: TypedComponent<any>;
|
||||
_position!: number;
|
||||
|
||||
constructor() {
|
||||
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
|
||||
this.children = [];
|
||||
this.initialized = null;
|
||||
}
|
||||
|
||||
get sanitizedClassName() {
|
||||
// webpack mangles names and sometimes uses unsafe characters
|
||||
return this.constructor.name.replace(/[^A-Z0-9]/gi, "_");
|
||||
}
|
||||
|
||||
get position() {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
set position(newPosition: number) {
|
||||
this._position = newPosition;
|
||||
}
|
||||
|
||||
setParent(parent: TypedComponent<any>) {
|
||||
this.parent = parent;
|
||||
return this;
|
||||
}
|
||||
|
||||
child(...components: ChildT[]) {
|
||||
for (const component of components) {
|
||||
component.setParent(this);
|
||||
|
||||
this.children.push(component);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
handleEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null | undefined {
|
||||
try {
|
||||
const callMethodPromise = this.initialized ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) : this.callMethod((this as any)[`${name}Event`], data);
|
||||
|
||||
const childrenPromise = this.handleEventInChildren(name, data);
|
||||
|
||||
// don't create promises if not needed (optimization)
|
||||
return callMethodPromise && childrenPromise ? Promise.all([callMethodPromise, childrenPromise]) : callMethodPromise || childrenPromise;
|
||||
} catch (e: any) {
|
||||
console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
triggerEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown> | undefined | null {
|
||||
return this.parent?.triggerEvent(name, data);
|
||||
}
|
||||
|
||||
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
for (const child of this.children) {
|
||||
const ret = child.handleEvent(name, data) as Promise<void>;
|
||||
|
||||
if (ret) {
|
||||
promises.push(ret);
|
||||
}
|
||||
}
|
||||
|
||||
// don't create promises if not needed (optimization)
|
||||
return promises.length > 0 ? Promise.all(promises) : null;
|
||||
}
|
||||
|
||||
triggerCommand<K extends CommandNames>(name: K, data?: CommandMappings[K]): Promise<unknown> | undefined | null {
|
||||
const fun = (this as any)[`${name}Command`];
|
||||
|
||||
if (fun) {
|
||||
return this.callMethod(fun, data);
|
||||
} else {
|
||||
if (!this.parent) {
|
||||
throw new Error(`Component "${this.componentId}" does not have a parent attached to propagate a command.`);
|
||||
}
|
||||
|
||||
return this.parent.triggerCommand(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
callMethod(fun: (arg: unknown) => Promise<unknown>, data: unknown) {
|
||||
if (typeof fun !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const promise = fun.call(this, data);
|
||||
|
||||
const took = Date.now() - startTime;
|
||||
|
||||
if (glob.isDev && took > 20) {
|
||||
// measuring only sync handlers
|
||||
console.log(`Call to ${fun.name} in ${this.componentId} took ${took}ms`);
|
||||
}
|
||||
|
||||
if (glob.isDev && promise) {
|
||||
return utils.timeLimit(promise, 20000, `Time limit failed on ${this.constructor.name} with ${fun.name}`);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
export default class Component extends TypedComponent<Component> {}
|
||||
@ -1,233 +0,0 @@
|
||||
import utils from "../services/utils.js";
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import server from "../services/server.js";
|
||||
import appContext, { type NoteCommandData } from "./app_context.js";
|
||||
import Component from "./component.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import ws from "../services/ws.js";
|
||||
import bundleService from "../services/bundle.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
// TODO: Move somewhere else nicer.
|
||||
export type SqlExecuteResults = string[][][];
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface SqlExecuteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
results: SqlExecuteResults;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface CreateChildrenResponse {
|
||||
note: FNote;
|
||||
}
|
||||
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (jQuery.hotkeys) {
|
||||
// hot keys are active also inside inputs and content editables
|
||||
jQuery.hotkeys.options.filterInputAcceptingElements = false;
|
||||
jQuery.hotkeys.options.filterContentEditable = false;
|
||||
jQuery.hotkeys.options.filterTextInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
openDevToolsCommand() {
|
||||
if (utils.isElectron()) {
|
||||
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
async createNoteIntoInboxCommand() {
|
||||
const inboxNote = await dateNoteService.getInboxNote();
|
||||
if (!inboxNote) {
|
||||
console.warn("Missing inbox note.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${inboxNote.noteId}/children?target=into`, {
|
||||
content: "",
|
||||
type: "text",
|
||||
isProtected: inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(note.noteId, { activate: true });
|
||||
|
||||
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true });
|
||||
}
|
||||
|
||||
async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) {
|
||||
const activeNoteContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
if (!activeNoteContext || !noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteToHoist = await froca.getNote(noteId);
|
||||
|
||||
if (noteToHoist?.noteId === activeNoteContext.hoistedNoteId) {
|
||||
await activeNoteContext.unhoist();
|
||||
} else if (noteToHoist?.type !== "search") {
|
||||
await activeNoteContext.setHoistedNoteId(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
async hoistNoteCommand({ noteId }: { noteId: string }) {
|
||||
const noteContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
if (!noteContext) {
|
||||
logError("hoistNoteCommand: noteContext is null");
|
||||
return;
|
||||
}
|
||||
|
||||
if (noteContext.hoistedNoteId !== noteId) {
|
||||
await noteContext.setHoistedNoteId(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
async unhoistCommand() {
|
||||
const activeNoteContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
if (activeNoteContext) {
|
||||
activeNoteContext.unhoist();
|
||||
}
|
||||
}
|
||||
|
||||
copyWithoutFormattingCommand() {
|
||||
utils.copySelectionToClipboard();
|
||||
}
|
||||
|
||||
toggleFullscreenCommand() {
|
||||
if (utils.isElectron()) {
|
||||
const win = utils.dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
|
||||
if (win.isFullScreenable()) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
}
|
||||
} // outside of electron this is handled by the browser
|
||||
}
|
||||
|
||||
reloadFrontendAppCommand() {
|
||||
utils.reloadFrontendApp();
|
||||
}
|
||||
|
||||
async logoutCommand() {
|
||||
await server.post("../logout");
|
||||
window.location.replace(`/login`);
|
||||
}
|
||||
|
||||
backInNoteHistoryCommand() {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
|
||||
webContents.goToIndex(activeIndex - 1);
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
forwardInNoteHistoryCommand() {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
|
||||
webContents.goToIndex(activeIndex + 1);
|
||||
} else {
|
||||
window.history.forward();
|
||||
}
|
||||
}
|
||||
|
||||
async switchToDesktopVersionCommand() {
|
||||
utils.setCookie("trilium-device", "desktop");
|
||||
|
||||
utils.reloadFrontendApp("Switching to desktop version");
|
||||
}
|
||||
|
||||
async switchToMobileVersionCommand() {
|
||||
utils.setCookie("trilium-device", "mobile");
|
||||
|
||||
utils.reloadFrontendApp("Switching to mobile version");
|
||||
}
|
||||
|
||||
async openInWindowCommand({ notePath, hoistedNoteId, viewScope }: NoteCommandData) {
|
||||
const extraWindowHash = linkService.calculateHash({ notePath, hoistedNoteId, viewScope });
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
|
||||
ipcRenderer.send("create-extra-window", { extraWindowHash });
|
||||
} else {
|
||||
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1${extraWindowHash}`;
|
||||
|
||||
window.open(url, "", "width=1000,height=800");
|
||||
}
|
||||
}
|
||||
|
||||
async openNewWindowCommand() {
|
||||
this.openInWindowCommand({ notePath: "", hoistedNoteId: "root" });
|
||||
}
|
||||
|
||||
async runActiveNoteCommand() {
|
||||
const noteContext = appContext.tabManager.getActiveContext();
|
||||
if (!noteContext) {
|
||||
return;
|
||||
}
|
||||
const { ntxId, note } = noteContext;
|
||||
|
||||
// ctrl+enter is also used elsewhere, so make sure we're running only when appropriate
|
||||
if (!note || note.type !== "code") {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: use note.executeScript()
|
||||
if (note.mime.endsWith("env=frontend")) {
|
||||
await bundleService.getAndExecuteBundle(note.noteId);
|
||||
} else if (note.mime.endsWith("env=backend")) {
|
||||
await server.post(`script/run/${note.noteId}`);
|
||||
} else if (note.mime === "text/x-sqlite;schema=trilium") {
|
||||
const resp = await server.post<SqlExecuteResponse>(`sql/execute/${note.noteId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(t("entrypoints.sql-error", { message: resp.error }));
|
||||
}
|
||||
|
||||
await appContext.triggerEvent("sqlQueryResults", { ntxId: ntxId, results: resp.results });
|
||||
}
|
||||
|
||||
toastService.showMessage(t("entrypoints.note-executed"));
|
||||
}
|
||||
|
||||
hideAllPopups() {
|
||||
if (utils.isDesktop()) {
|
||||
$(".aa-input").autocomplete("close");
|
||||
}
|
||||
}
|
||||
|
||||
noteSwitchedEvent() {
|
||||
this.hideAllPopups();
|
||||
}
|
||||
|
||||
activeContextChangedEvent() {
|
||||
this.hideAllPopups();
|
||||
}
|
||||
|
||||
async forceSaveRevisionCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
|
||||
await server.post(`notes/${noteId}/revision`);
|
||||
|
||||
toastService.showMessage(t("entrypoints.note-revision-created"));
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import type { MenuCommandItem } from "../menus/context_menu.js";
|
||||
import type { CommandNames } from "./app_context.js";
|
||||
|
||||
type ListenerReturnType = void | Promise<void>;
|
||||
|
||||
export interface SelectMenuItemEventListener<T extends CommandNames> {
|
||||
selectMenuItemHandler(item: MenuCommandItem<T>): ListenerReturnType;
|
||||
}
|
||||
@ -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,15 +0,0 @@
|
||||
import Component from "./component.js";
|
||||
import type { CommandListener, CommandListenerData } from "./app_context.js";
|
||||
|
||||
export type Screen = "detail" | "tree";
|
||||
|
||||
export default class MobileScreenSwitcherExecutor extends Component implements CommandListener<"setActiveScreen"> {
|
||||
private activeScreen?: Screen;
|
||||
|
||||
setActiveScreenCommand({ screen }: CommandListenerData<"setActiveScreen">) {
|
||||
if (screen !== this.activeScreen) {
|
||||
this.activeScreen = screen;
|
||||
this.triggerEvent("activeScreenChanged", { activeScreen: screen });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,416 +0,0 @@
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import appContext, { type EventData, type EventListener } from "./app_context.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import Component from "./component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import hoistedNoteService from "../services/hoisted_note.js";
|
||||
import options from "../services/options.js";
|
||||
import type { ViewScope } from "../services/link.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { closeActiveDialog } from "../services/dialog.js";
|
||||
|
||||
export interface SetNoteOpts {
|
||||
triggerSwitchEvent?: unknown;
|
||||
viewScope?: ViewScope;
|
||||
}
|
||||
|
||||
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
|
||||
|
||||
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
|
||||
ntxId: string | null;
|
||||
hoistedNoteId: string;
|
||||
mainNtxId: string | null;
|
||||
|
||||
notePath?: string | null;
|
||||
noteId?: string | null;
|
||||
parentNoteId?: string | null;
|
||||
viewScope?: ViewScope;
|
||||
|
||||
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
|
||||
super();
|
||||
|
||||
this.ntxId = ntxId || NoteContext.generateNtxId();
|
||||
this.hoistedNoteId = hoistedNoteId;
|
||||
this.mainNtxId = mainNtxId;
|
||||
|
||||
this.resetViewScope();
|
||||
}
|
||||
|
||||
static generateNtxId() {
|
||||
return utils.randomString(6);
|
||||
}
|
||||
|
||||
setEmpty() {
|
||||
this.notePath = null;
|
||||
this.noteId = null;
|
||||
this.parentNoteId = null;
|
||||
// hoisted note is kept intentionally
|
||||
|
||||
this.triggerEvent("noteSwitched", {
|
||||
noteContext: this,
|
||||
notePath: this.notePath
|
||||
});
|
||||
|
||||
this.resetViewScope();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !this.noteId;
|
||||
}
|
||||
|
||||
async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) {
|
||||
opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true;
|
||||
opts.viewScope = opts.viewScope || {};
|
||||
opts.viewScope.viewMode = opts.viewScope.viewMode || "default";
|
||||
|
||||
if (!inputNotePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedNotePath = await this.getResolvedNotePath(inputNotePath);
|
||||
|
||||
if (!resolvedNotePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.triggerEvent("beforeNoteSwitch", { noteContext: this });
|
||||
|
||||
closeActiveDialog();
|
||||
|
||||
this.notePath = resolvedNotePath;
|
||||
this.viewScope = opts.viewScope;
|
||||
({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));
|
||||
|
||||
this.saveToRecentNotes(resolvedNotePath);
|
||||
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
|
||||
|
||||
if (opts.triggerSwitchEvent) {
|
||||
await this.triggerEvent("noteSwitched", {
|
||||
noteContext: this,
|
||||
notePath: this.notePath
|
||||
});
|
||||
}
|
||||
|
||||
await this.setHoistedNoteIfNeeded();
|
||||
|
||||
if (utils.isMobile()) {
|
||||
this.triggerCommand("setActiveScreen", { screen: "detail" });
|
||||
}
|
||||
}
|
||||
|
||||
async setHoistedNoteIfNeeded() {
|
||||
if (this.hoistedNoteId === "root" && this.notePath?.startsWith("root/_hidden") && !this.note?.isLabelTruthy("keepCurrentHoisting")) {
|
||||
// hidden subtree displays only when hoisted, so it doesn't make sense to keep root as hoisted note
|
||||
|
||||
let hoistedNoteId = "_hidden";
|
||||
|
||||
if (this.note?.isLaunchBarConfig()) {
|
||||
hoistedNoteId = "_lbRoot";
|
||||
} else if (this.note?.isOptions()) {
|
||||
hoistedNoteId = "_options";
|
||||
}
|
||||
|
||||
await this.setHoistedNoteId(hoistedNoteId);
|
||||
}
|
||||
}
|
||||
|
||||
getSubContexts() {
|
||||
return appContext.tabManager.noteContexts.filter((nc) => nc.ntxId === this.ntxId || nc.mainNtxId === this.ntxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A main context represents a tab and also the first split. Further splits are the children contexts of the main context.
|
||||
* Imagine you have a tab with 3 splits, each showing notes A, B, C (in this order).
|
||||
* In such a scenario, A context is the main context (also representing the tab as a whole), and B, C are the children
|
||||
* of context A.
|
||||
*
|
||||
* @returns {boolean} true if the context is main (= tab)
|
||||
*/
|
||||
isMainContext() {
|
||||
// if null, then this is a main context
|
||||
return !this.mainNtxId;
|
||||
}
|
||||
|
||||
/**
|
||||
* See docs for isMainContext() for better explanation.
|
||||
*
|
||||
* @returns {NoteContext}
|
||||
*/
|
||||
getMainContext() {
|
||||
if (this.mainNtxId) {
|
||||
try {
|
||||
return appContext.tabManager.getNoteContextById(this.mainNtxId);
|
||||
} catch (e) {
|
||||
this.mainNtxId = null;
|
||||
return this;
|
||||
}
|
||||
} else {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
saveToRecentNotes(resolvedNotePath: string) {
|
||||
if (options.is("databaseReadonly")) {
|
||||
return;
|
||||
}
|
||||
setTimeout(async () => {
|
||||
// we include the note in the recent list only if the user stayed on the note at least 5 seconds
|
||||
if (resolvedNotePath && resolvedNotePath === this.notePath) {
|
||||
await server.post("recent-notes", {
|
||||
noteId: this.note?.noteId,
|
||||
notePath: this.notePath
|
||||
});
|
||||
utils.reloadTray();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async getResolvedNotePath(inputNotePath: string) {
|
||||
const resolvedNotePath = await treeService.resolveNotePath(inputNotePath, this.hoistedNoteId);
|
||||
|
||||
if (!resolvedNotePath) {
|
||||
logError(`Cannot resolve note path ${inputNotePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await hoistedNoteService.checkNoteAccess(resolvedNotePath, this)) === false) {
|
||||
return; // note is outside of hoisted subtree and user chose not to unhoist
|
||||
}
|
||||
|
||||
return resolvedNotePath;
|
||||
}
|
||||
|
||||
get note(): FNote | null {
|
||||
if (!this.noteId || !(this.noteId in froca.notes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return froca.notes[this.noteId];
|
||||
}
|
||||
|
||||
/** @returns {string[]} */
|
||||
get notePathArray() {
|
||||
return this.notePath ? this.notePath.split("/") : [];
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return appContext.tabManager.activeNtxId === this.ntxId;
|
||||
}
|
||||
|
||||
getPojoState() {
|
||||
if (this.hoistedNoteId !== "root") {
|
||||
// keeping empty hoisted tab is esp. important for mobile (e.g. opened launcher config)
|
||||
|
||||
if (!this.notePath && this.getSubContexts().length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ntxId: this.ntxId,
|
||||
mainNtxId: this.mainNtxId,
|
||||
notePath: this.notePath,
|
||||
hoistedNoteId: this.hoistedNoteId,
|
||||
active: this.isActive(),
|
||||
viewScope: this.viewScope
|
||||
};
|
||||
}
|
||||
|
||||
async unhoist() {
|
||||
await this.setHoistedNoteId("root");
|
||||
}
|
||||
|
||||
async setHoistedNoteId(noteIdToHoist: string) {
|
||||
if (this.hoistedNoteId === noteIdToHoist) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hoistedNoteId = noteIdToHoist;
|
||||
|
||||
if (!this.notePathArray?.includes(noteIdToHoist)) {
|
||||
await this.setNote(noteIdToHoist);
|
||||
}
|
||||
|
||||
await this.triggerEvent("hoistedNoteChanged", {
|
||||
noteId: noteIdToHoist,
|
||||
ntxId: this.ntxId
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
async isReadOnly() {
|
||||
if (this?.viewScope?.readOnlyTemporarilyDisabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// "readOnly" is a state valid only for text/code notes
|
||||
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.is("databaseReadonly")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.note.isLabelTruthy("readOnly")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.viewScope?.viewMode === "source") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store the initial decision about read-only status in the viewScope
|
||||
// This will be "remembered" until the viewScope is refreshed
|
||||
if (!this.viewScope) {
|
||||
this.resetViewScope();
|
||||
}
|
||||
|
||||
const viewScope = this.viewScope!;
|
||||
|
||||
if (viewScope.isReadOnly === undefined) {
|
||||
const blob = await this.note.getBlob();
|
||||
if (!blob) {
|
||||
viewScope.isReadOnly = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const sizeLimit = this.note.type === "text"
|
||||
? options.getInt("autoReadonlySizeText")
|
||||
: options.getInt("autoReadonlySizeCode");
|
||||
|
||||
viewScope.isReadOnly = Boolean(sizeLimit &&
|
||||
blob.contentLength > sizeLimit &&
|
||||
!this.note.isLabelTruthy("autoReadOnlyDisabled"));
|
||||
}
|
||||
|
||||
// Return the cached decision, which won't change until viewScope is reset
|
||||
return viewScope.isReadOnly || false;
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.noteId && loadResults.isNoteReloaded(this.noteId)) {
|
||||
const noteRow = loadResults.getEntityRow("notes", this.noteId);
|
||||
|
||||
if (noteRow.isDeleted) {
|
||||
this.noteId = null;
|
||||
this.notePath = null;
|
||||
|
||||
this.triggerEvent("noteSwitched", {
|
||||
noteContext: this,
|
||||
notePath: this.notePath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasNoteList() {
|
||||
return (
|
||||
this.note &&
|
||||
["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") &&
|
||||
(this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") &&
|
||||
["book", "text", "code"].includes(this.note.type) &&
|
||||
this.note.mime !== "text/x-sqlite;schema=trilium" &&
|
||||
!this.note.isLabelTruthy("hideChildrenOverview")
|
||||
);
|
||||
}
|
||||
|
||||
async getTextEditor(callback?: GetTextEditorCallback) {
|
||||
return this.timeout<CKTextEditor>(
|
||||
new Promise((resolve) =>
|
||||
appContext.triggerCommand("executeWithTextEditor", {
|
||||
callback,
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getCodeEditor() {
|
||||
return this.timeout(
|
||||
new Promise<CodeMirror>((resolve) =>
|
||||
appContext.triggerCommand("executeWithCodeEditor", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise which will retrieve the JQuery element of the content of this note context.
|
||||
*
|
||||
* Do note that retrieving the content element needs to be handled by the type widget, which is the one which
|
||||
* provides the content element by listening to the `executeWithContentElement` event. Not all note types support
|
||||
* this.
|
||||
*
|
||||
* If no content could be determined `null` is returned instead.
|
||||
*/
|
||||
async getContentElement() {
|
||||
return this.timeout<JQuery<HTMLElement>>(
|
||||
new Promise((resolve) =>
|
||||
appContext.triggerCommand("executeWithContentElement", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getTypeWidget() {
|
||||
return this.timeout(
|
||||
new Promise<TypeWidget | null>((resolve) =>
|
||||
appContext.triggerCommand("executeWithTypeWidget", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
timeout<T>(promise: Promise<T | null>) {
|
||||
return Promise.race([promise, new Promise((res) => setTimeout(() => res(null), 200))]) as Promise<T>;
|
||||
}
|
||||
|
||||
resetViewScope() {
|
||||
// view scope contains data specific to one note context and one "view".
|
||||
// it is used to e.g., make read-only note temporarily editable or to hide TOC
|
||||
// this is reset after navigating to a different note
|
||||
this.viewScope = {};
|
||||
}
|
||||
|
||||
async getNavigationTitle() {
|
||||
if (!this.note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { note, viewScope } = this;
|
||||
|
||||
const isNormalView = (viewScope?.viewMode === "default" || viewScope?.viewMode === "contextual-help");
|
||||
let title = (isNormalView ? note.title : `${note.title}: ${viewScope?.viewMode}`);
|
||||
|
||||
if (viewScope?.attachmentId) {
|
||||
// assuming the attachment has been already loaded
|
||||
const attachment = await note.getAttachmentById(viewScope.attachmentId);
|
||||
|
||||
if (attachment) {
|
||||
title += `: ${attachment.title}`;
|
||||
}
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteContext;
|
||||
@ -1,263 +0,0 @@
|
||||
import Component from "./component.js";
|
||||
import appContext, { type CommandData, type CommandListenerData } from "./app_context.js";
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import openService from "../services/open.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import options from "../services/options.js";
|
||||
import froca from "../services/froca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import LlmChatPanel from "../widgets/llm_chat_panel.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
|
||||
export default class RootCommandExecutor extends Component {
|
||||
editReadOnlyNoteCommand() {
|
||||
const noteContext = appContext.tabManager.getActiveContext();
|
||||
if (noteContext?.viewScope) {
|
||||
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
|
||||
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
|
||||
}
|
||||
}
|
||||
|
||||
async showSQLConsoleCommand() {
|
||||
const sqlConsoleNote = await dateNoteService.createSqlConsole();
|
||||
if (!sqlConsoleNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(sqlConsoleNote.noteId, { activate: true });
|
||||
|
||||
appContext.triggerEvent("focusOnDetail", { ntxId: noteContext.ntxId });
|
||||
}
|
||||
|
||||
async searchNotesCommand({ searchString, ancestorNoteId }: CommandListenerData<"searchNotes">) {
|
||||
const searchNote = await dateNoteService.createSearchNote({ searchString, ancestorNoteId });
|
||||
if (!searchNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
// force immediate search
|
||||
await froca.loadSearchNote(searchNote.noteId);
|
||||
|
||||
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(searchNote.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
appContext.triggerCommand("focusOnSearchDefinition", { ntxId: noteContext.ntxId });
|
||||
}
|
||||
|
||||
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
|
||||
const noteId = treeService.getNoteIdFromUrl(notePath);
|
||||
|
||||
this.searchNotesCommand({ ancestorNoteId: noteId });
|
||||
}
|
||||
|
||||
openNoteExternallyCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
const mime = appContext.tabManager.getActiveContextNoteMime();
|
||||
if (noteId) {
|
||||
openService.openNoteExternally(noteId, mime || "");
|
||||
}
|
||||
}
|
||||
|
||||
openNoteCustomCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
const mime = appContext.tabManager.getActiveContextNoteMime();
|
||||
if (noteId) {
|
||||
openService.openNoteCustom(noteId, mime || "");
|
||||
}
|
||||
}
|
||||
|
||||
enterProtectedSessionCommand() {
|
||||
protectedSessionService.enterProtectedSession();
|
||||
}
|
||||
|
||||
leaveProtectedSessionCommand() {
|
||||
protectedSessionService.leaveProtectedSession();
|
||||
}
|
||||
|
||||
hideLeftPaneCommand() {
|
||||
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: false });
|
||||
}
|
||||
|
||||
showLeftPaneCommand() {
|
||||
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: true });
|
||||
}
|
||||
|
||||
toggleLeftPaneCommand() {
|
||||
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: null });
|
||||
}
|
||||
|
||||
async showBackendLogCommand() {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting("_backendLog", { activate: true });
|
||||
}
|
||||
|
||||
async showHelpCommand() {
|
||||
await this.showAndHoistSubtree("_help");
|
||||
}
|
||||
|
||||
async showLaunchBarSubtreeCommand() {
|
||||
const rootNote = utils.isMobile() ? "_lbMobileRoot" : "_lbRoot";
|
||||
await this.showAndHoistSubtree(rootNote);
|
||||
this.showLeftPaneCommand();
|
||||
}
|
||||
|
||||
async showShareSubtreeCommand() {
|
||||
await this.showAndHoistSubtree("_share");
|
||||
}
|
||||
|
||||
async showHiddenSubtreeCommand() {
|
||||
await this.showAndHoistSubtree("_hidden");
|
||||
}
|
||||
|
||||
async showOptionsCommand({ section }: CommandListenerData<"showOptions">) {
|
||||
await appContext.tabManager.openContextWithNote(section || "_options", {
|
||||
activate: true,
|
||||
hoistedNoteId: "_options"
|
||||
});
|
||||
}
|
||||
|
||||
async showSQLConsoleHistoryCommand() {
|
||||
await this.showAndHoistSubtree("_sqlConsole");
|
||||
}
|
||||
|
||||
async showSearchHistoryCommand() {
|
||||
await this.showAndHoistSubtree("_search");
|
||||
}
|
||||
|
||||
async showAndHoistSubtree(subtreeNoteId: string) {
|
||||
await appContext.tabManager.openContextWithNote(subtreeNoteId, {
|
||||
activate: true,
|
||||
hoistedNoteId: subtreeNoteId
|
||||
});
|
||||
}
|
||||
|
||||
async showNoteSourceCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "source"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentsCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "attachments"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentDetailCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "attachments"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleTrayCommand() {
|
||||
if (!utils.isElectron()) return;
|
||||
const { BrowserWindow } = utils.dynamicRequire("@electron/remote");
|
||||
const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[];
|
||||
const isVisible = windows.every((w) => w.isVisible());
|
||||
const action = isVisible ? "hide" : "show";
|
||||
for (const window of windows) window[action]();
|
||||
}
|
||||
|
||||
toggleZenModeCommand() {
|
||||
const $body = $("body");
|
||||
$body.toggleClass("zen");
|
||||
const isEnabled = $body.hasClass("zen");
|
||||
appContext.triggerEvent("zenModeChanged", { isEnabled });
|
||||
}
|
||||
|
||||
firstTabCommand() {
|
||||
this.#goToTab(1);
|
||||
}
|
||||
secondTabCommand() {
|
||||
this.#goToTab(2);
|
||||
}
|
||||
thirdTabCommand() {
|
||||
this.#goToTab(3);
|
||||
}
|
||||
fourthTabCommand() {
|
||||
this.#goToTab(4);
|
||||
}
|
||||
fifthTabCommand() {
|
||||
this.#goToTab(5);
|
||||
}
|
||||
sixthTabCommand() {
|
||||
this.#goToTab(6);
|
||||
}
|
||||
seventhTabCommand() {
|
||||
this.#goToTab(7);
|
||||
}
|
||||
eigthTabCommand() {
|
||||
this.#goToTab(8);
|
||||
}
|
||||
ninthTabCommand() {
|
||||
this.#goToTab(9);
|
||||
}
|
||||
lastTabCommand() {
|
||||
this.#goToTab(Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
#goToTab(tabNumber: number) {
|
||||
const mainNoteContexts = appContext.tabManager.getMainNoteContexts();
|
||||
|
||||
const index = tabNumber === Number.POSITIVE_INFINITY ? mainNoteContexts.length - 1 : tabNumber - 1;
|
||||
const tab = mainNoteContexts[index];
|
||||
|
||||
if (tab) {
|
||||
appContext.tabManager.activateNoteContext(tab.ntxId);
|
||||
}
|
||||
}
|
||||
|
||||
async createAiChatCommand() {
|
||||
try {
|
||||
// Create a new AI Chat note at the root level
|
||||
const rootNoteId = "root";
|
||||
|
||||
const result = await noteCreateService.createNote(rootNoteId, {
|
||||
title: "New AI Chat",
|
||||
type: "aiChat",
|
||||
content: JSON.stringify({
|
||||
messages: [],
|
||||
title: "New AI Chat"
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.note) {
|
||||
toastService.showError("Failed to create AI Chat note");
|
||||
return;
|
||||
}
|
||||
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(result.note.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
toastService.showMessage("Created new AI Chat note");
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Error creating AI Chat note:", e);
|
||||
toastService.showError("Failed to create AI Chat note: " + (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import appContext, { type EventData, type EventListener } from "./app_context.js";
|
||||
import shortcutService from "../services/shortcuts.js";
|
||||
import server from "../services/server.js";
|
||||
import Component from "./component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import type { AttributeRow } from "../services/load_results.js";
|
||||
|
||||
export default class ShortcutComponent extends Component implements EventListener<"entitiesReloaded"> {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
server.get<AttributeRow[]>("keyboard-shortcuts-for-notes").then((shortcutAttributes) => {
|
||||
for (const attr of shortcutAttributes) {
|
||||
this.bindNoteShortcutHandler(attr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindNoteShortcutHandler(labelOrRow: AttributeRow) {
|
||||
const handler = () => appContext.tabManager.getActiveContext()?.setNote(labelOrRow.noteId);
|
||||
const namespace = labelOrRow.attributeId;
|
||||
|
||||
if (labelOrRow.isDeleted) {
|
||||
// only applicable if row
|
||||
if (namespace) {
|
||||
shortcutService.removeGlobalShortcut(namespace);
|
||||
}
|
||||
} else if (labelOrRow.value) {
|
||||
shortcutService.bindGlobalShortcut(labelOrRow.value, handler, namespace);
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "label" && attr.name === "keyboardShortcut" && attr.noteId) {
|
||||
const note = await froca.getNote(attr.noteId);
|
||||
// launcher shortcuts are handled specifically
|
||||
if (note && attr && note.type !== "launcher") {
|
||||
this.bindNoteShortcutHandler(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,68 +0,0 @@
|
||||
import options from "../services/options.js";
|
||||
import Component from "./component.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 2.0;
|
||||
|
||||
class ZoomComponent extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (utils.isElectron()) {
|
||||
options.initializedPromise.then(() => {
|
||||
const zoomFactor = options.getFloat("zoomFactor");
|
||||
if (zoomFactor) {
|
||||
this.setZoomFactor(zoomFactor);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("wheel", (event) => {
|
||||
if (event.ctrlKey) {
|
||||
this.setZoomFactorAndSave(this.getCurrentZoom() - event.deltaY * 0.001);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setZoomFactor(zoomFactor: string | number) {
|
||||
const parsedZoomFactor = typeof zoomFactor !== "number" ? parseFloat(zoomFactor) : zoomFactor;
|
||||
const webFrame = utils.dynamicRequire("electron").webFrame;
|
||||
webFrame.setZoomFactor(parsedZoomFactor);
|
||||
}
|
||||
|
||||
async setZoomFactorAndSave(zoomFactor: number) {
|
||||
if (zoomFactor >= MIN_ZOOM && zoomFactor <= MAX_ZOOM) {
|
||||
zoomFactor = Math.round(zoomFactor * 10) / 10;
|
||||
|
||||
this.setZoomFactor(zoomFactor);
|
||||
|
||||
await options.save("zoomFactor", zoomFactor);
|
||||
} else {
|
||||
console.log(`Zoom factor ${zoomFactor} outside of the range, ignored.`);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentZoom() {
|
||||
return utils.dynamicRequire("electron").webFrame.getZoomFactor();
|
||||
}
|
||||
|
||||
zoomOutEvent() {
|
||||
this.setZoomFactorAndSave(this.getCurrentZoom() - 0.1);
|
||||
}
|
||||
|
||||
zoomInEvent() {
|
||||
this.setZoomFactorAndSave(this.getCurrentZoom() + 0.1);
|
||||
}
|
||||
zoomResetEvent() {
|
||||
this.setZoomFactorAndSave(1);
|
||||
}
|
||||
|
||||
setZoomFactorAndSaveEvent({ zoomFactor }: { zoomFactor: number }) {
|
||||
this.setZoomFactorAndSave(zoomFactor);
|
||||
}
|
||||
}
|
||||
|
||||
const zoomService = new ZoomComponent();
|
||||
|
||||
export default zoomService;
|
||||
@ -1,65 +0,0 @@
|
||||
import type { Froca } from "../services/froca-interface.js";
|
||||
|
||||
export interface FAttachmentRow {
|
||||
attachmentId: string;
|
||||
ownerId: string;
|
||||
role: string;
|
||||
mime: string;
|
||||
title: string;
|
||||
dateModified: string;
|
||||
utcDateModified: string;
|
||||
utcDateScheduledForErasureSince: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment is a file directly tied into a note without
|
||||
* being a hidden child.
|
||||
*/
|
||||
class FAttachment {
|
||||
private froca: Froca;
|
||||
attachmentId!: string;
|
||||
ownerId!: string;
|
||||
role!: string;
|
||||
mime!: string;
|
||||
title!: string;
|
||||
isProtected!: boolean; // TODO: Is this used?
|
||||
private dateModified!: string;
|
||||
utcDateModified!: string;
|
||||
utcDateScheduledForErasureSince!: string;
|
||||
/**
|
||||
* optionally added to the entity
|
||||
*/
|
||||
contentLength!: number;
|
||||
|
||||
constructor(froca: Froca, row: FAttachmentRow) {
|
||||
/** @type {Froca} */
|
||||
this.froca = froca;
|
||||
|
||||
this.update(row);
|
||||
}
|
||||
|
||||
update(row: FAttachmentRow) {
|
||||
this.attachmentId = row.attachmentId;
|
||||
this.ownerId = row.ownerId;
|
||||
this.role = row.role;
|
||||
this.mime = row.mime;
|
||||
this.title = row.title;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
|
||||
this.contentLength = row.contentLength;
|
||||
|
||||
this.froca.attachments[this.attachmentId] = this;
|
||||
}
|
||||
|
||||
getNote() {
|
||||
return this.froca.notes[this.ownerId];
|
||||
}
|
||||
|
||||
async getBlob() {
|
||||
return await this.froca.getBlob("attachments", this.attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
export default FAttachment;
|
||||
@ -1,96 +0,0 @@
|
||||
import type { Froca } from "../services/froca-interface.js";
|
||||
import promotedAttributeDefinitionParser from "../services/promoted_attribute_definition_parser.js";
|
||||
|
||||
/**
|
||||
* There are currently only two types of attributes, labels or relations.
|
||||
*/
|
||||
export type AttributeType = "label" | "relation";
|
||||
|
||||
export interface FAttributeRow {
|
||||
attributeId: string;
|
||||
noteId: string;
|
||||
type: AttributeType;
|
||||
name: string;
|
||||
value: string;
|
||||
position: number;
|
||||
isInheritable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute is an abstract concept which has two real uses - label (key - value pair)
|
||||
* and relation (representing named relationship between source and target note)
|
||||
*/
|
||||
class FAttribute {
|
||||
private froca: Froca;
|
||||
attributeId!: string;
|
||||
noteId!: string;
|
||||
type!: AttributeType;
|
||||
name!: string;
|
||||
value!: string;
|
||||
position!: number;
|
||||
isInheritable!: boolean;
|
||||
|
||||
constructor(froca: Froca, row: FAttributeRow) {
|
||||
this.froca = froca;
|
||||
|
||||
this.update(row);
|
||||
}
|
||||
|
||||
update(row: FAttributeRow) {
|
||||
this.attributeId = row.attributeId;
|
||||
this.noteId = row.noteId;
|
||||
this.type = row.type;
|
||||
this.name = row.name;
|
||||
this.value = row.value;
|
||||
this.position = row.position;
|
||||
this.isInheritable = !!row.isInheritable;
|
||||
}
|
||||
|
||||
getNote() {
|
||||
return this.froca.notes[this.noteId];
|
||||
}
|
||||
|
||||
async getTargetNote() {
|
||||
const targetNoteId = this.targetNoteId;
|
||||
|
||||
return await this.froca.getNote(targetNoteId, true);
|
||||
}
|
||||
|
||||
get targetNoteId() {
|
||||
// alias
|
||||
if (this.type !== "relation") {
|
||||
throw new Error(`Attribute ${this.attributeId} is not a relation`);
|
||||
}
|
||||
|
||||
return this.value;
|
||||
}
|
||||
|
||||
get isAutoLink() {
|
||||
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
|
||||
}
|
||||
|
||||
get toString() {
|
||||
return `FAttribute(attributeId=${this.attributeId}, type=${this.type}, name=${this.name}, value=${this.value})`;
|
||||
}
|
||||
|
||||
isDefinition() {
|
||||
return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
|
||||
}
|
||||
|
||||
getDefinition() {
|
||||
return promotedAttributeDefinitionParser.parse(this.value);
|
||||
}
|
||||
|
||||
isDefinitionFor(attr: FAttribute) {
|
||||
return this.type === "label" && this.name === `${attr.type}:${attr.name}`;
|
||||
}
|
||||
|
||||
get dto(): Omit<FAttribute, "froca"> {
|
||||
const dto: any = Object.assign({}, this);
|
||||
delete dto.froca;
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
export default FAttribute;
|
||||
@ -1,45 +0,0 @@
|
||||
export interface FBlobRow {
|
||||
blobId: string;
|
||||
content: string;
|
||||
contentLength: number;
|
||||
dateModified: string;
|
||||
utcDateModified: string;
|
||||
}
|
||||
|
||||
export default class FBlob {
|
||||
blobId: string;
|
||||
/**
|
||||
* can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
|
||||
*/
|
||||
content: string;
|
||||
contentLength: number;
|
||||
dateModified: string;
|
||||
utcDateModified: string;
|
||||
|
||||
constructor(row: FBlobRow) {
|
||||
this.blobId = row.blobId;
|
||||
this.content = row.content;
|
||||
this.contentLength = row.contentLength;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error in case of invalid JSON
|
||||
*/
|
||||
getJsonContent<T>(): T | null {
|
||||
if (!this.content || !this.content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(this.content);
|
||||
}
|
||||
|
||||
getJsonContentSafely(): unknown | null {
|
||||
try {
|
||||
return this.getJsonContent();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue