Compare commits

..

No commits in common. "develop" and "v0.94.0" have entirely different histories.

695 changed files with 20704 additions and 42277 deletions

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

@ -39,7 +39,76 @@ jobs:
- uses: nrwl/nx-set-shas@v4
- name: Check affected
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
run: pnpm nx affected --verbose -t typecheck build rebuild-deps
report-electron-size:
name: Report Electron size
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'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run the build
uses: ./.github/actions/build-electron
with:
os: linux
arch: x64
shell: bash
forge_platform: linux
- name: Run the Electron size report
uses: ./.github/actions/report-size
with:
paths: 'upload/**/*'
onlyDiff: 'true'
branch: 'develop'
header: 'Electron size report'
unit: "MB"
ghToken: ${{ secrets.GITHUB_TOKEN }}
report-server-size:
name: Report server size
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 build
uses: ./.github/actions/build-server
with:
os: linux
arch: x64
- name: Run the server size report
uses: ./.github/actions/report-size
with:
paths: 'upload/**/*'
onlyDiff: 'true'
branch: 'develop'
header: 'Server size report'
unit: "MB"
ghToken: ${{ secrets.GITHUB_TOKEN }}
test_dev:
name: Test development
@ -74,14 +143,7 @@ jobs:
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
- name: Trigger build
run: pnpm nx run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6

@ -53,7 +53,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
run: pnpx playwright install --with-deps
- name: Run the TypeScript build
run: pnpm run server:build
@ -82,15 +82,7 @@ jobs:
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
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpx nx run server-e2e:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
@ -119,9 +111,6 @@ jobs:
- 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

@ -11,8 +11,7 @@ on:
pull_request:
paths:
- .github/actions/build-electron/*
- .github/workflows/nightly.yml
- forge.config.ts
- forge.config.cjs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -38,7 +37,7 @@ jobs:
shell: bash
forge_platform: darwin
- name: linux
image: ubuntu-22.04
image: ubuntu-latest
shell: bash
forge_platform: linux
- name: windows
@ -77,7 +76,7 @@ jobs:
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.2
uses: softprops/action-gh-release@v2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@ -103,7 +102,7 @@ jobs:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
@ -117,7 +116,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.2
uses: softprops/action-gh-release@v2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

@ -33,11 +33,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpx 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
- run: pnpx nx affected -t e2e

@ -73,7 +73,7 @@ jobs:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
@ -114,7 +114,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.3.2
uses: softprops/action-gh-release@v2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
@ -122,5 +122,5 @@ jobs:
files: upload/*.*
discussion_category_name: Announcements
make_latest: ${{ !contains(github.ref, 'rc') }}
prerelease: ${{ contains(github.ref, 'rc') }}
prerelease: ${{ !contains(github.ref, 'rc') }}
token: ${{ secrets.RELEASE_PAT }}

5
.gitignore vendored

@ -43,7 +43,4 @@ apps/*/out
upload
.rollup.cache
*.tsbuildinfo
/result
.svelte-kit
*.tsbuildinfo

@ -1,2 +1,7 @@
_regroup
_regroup_monorepo
_regroup_monorepo
# Asset copying respects .gitignore / .nxignore for some reason.
# See https://github.com/nrwl/nx/issues/20309
!dist
!node_modules

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

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

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e # Fail on any command error
VERSION=`jq -r ".version" package.json`
SERIES=${VERSION:0:4}-latest
sudo docker build -t triliumnext/notes:$VERSION --network host -t triliumnext/notes:$SERIES .
if [[ $VERSION != *"beta"* ]]; then
sudo docker tag triliumnext/notes:$VERSION triliumnext/notes:latest
fi

@ -25,16 +25,15 @@ 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 "|--------|----------------|----------------|"
echo "| locale |server strings |client strings |"
echo "|--------|---------------|---------------|"
echo "| en | ${s} | ${c} |"
for locale in "${locales[@]}"; do
s=$(number_of_keys "${paths[0]}/${locale}/server.json")
c=$(number_of_keys "${paths[1]}/${locale}/translation.json")
n1=$(((8 - ${#locale}) / 2))
n2=$((n1 == 1 ? n1 + 1 : n1))
echo "|$(printf "%${n1}s")${locale}$(printf "%${n2}s")| ${s} | ${c} |"
echo "|$(printf "%${n1}s")${locale}$(printf "%${n2}s")| ${s} | ${c} |"
done
}
@ -79,10 +78,7 @@ 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/"
)
paths=("${file_path}/../translations/" "${file_path}/../src/public/translations/")
locales=(cn de es fr pt_br ro tw)
if [ $# -eq 1 ]; then

@ -44,6 +44,7 @@ export default tseslint.config(
"dist/*",
"docs/*",
"demo/*",
"libraries/*",
"src/public/app-dist/*",
"src/public/app/doc_notes/*"
]

@ -38,6 +38,7 @@ export default [
"dist/*",
"docs/*",
"demo/*",
"libraries/*",
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
"packages/*",
"src/public/app-dist/*",

@ -35,13 +35,13 @@
"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",
"@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "4.4.0",
"@types/express": "5.0.1",
"@types/node": "22.15.29",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.29.0",
"@vitest/coverage-v8": "3.1.4",
"eslint": "9.27.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",

@ -0,0 +1,12 @@
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "1234"
}
> {%
client.assert(response.status === 201);
client.global.set("authToken", response.body.authToken);
%}

@ -0,0 +1,43 @@
### Test regular API metrics endpoint (requires session authentication)
### Get metrics from regular API (default Prometheus format)
GET {{triliumHost}}/api/metrics
> {%
client.test("API metrics endpoint returns Prometheus format by default", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
client.assert(response.body.includes("# HELP"), "Should contain HELP comments");
client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments");
});
%}
### Get metrics in JSON format
GET {{triliumHost}}/api/metrics?format=json
> {%
client.test("API metrics endpoint returns JSON when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json");
client.assert(response.body.version, "Version info not present");
client.assert(response.body.database, "Database info not present");
client.assert(response.body.timestamp, "Timestamp not present");
client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number");
client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number");
client.assert(response.body.noteTypes, "Note types breakdown not present");
client.assert(response.body.attachmentTypes, "Attachment types breakdown not present");
client.assert(response.body.statistics, "Statistics not present");
});
%}
### Test invalid format parameter
GET {{triliumHost}}/api/metrics?format=xml
> {%
client.test("Invalid format parameter returns error", function() {
client.assert(response.status === 500, "Response status should be 500");
client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats");
});
%}

@ -0,0 +1,7 @@
GET {{triliumHost}}/etapi/app-info
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.clipperProtocolVersion === "1.0");
%}

@ -0,0 +1,21 @@
GET {{triliumHost}}/etapi/app-info
Authorization: Basic etapi {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.clipperProtocolVersion === "1.0");
%}
###
GET {{triliumHost}}/etapi/app-info
Authorization: Basic etapi wrong
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/app-info
Authorization: Basic wrong {{authToken}}
> {% client.assert(response.status === 401); %}

@ -0,0 +1,4 @@
PUT {{triliumHost}}/etapi/backup/etapi_test
Authorization: {{authToken}}
> {% client.assert(response.status === 201); %}

@ -0,0 +1,158 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "forcedId{{$randomInt}}",
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.note.noteId.startsWith("forcedId"));
client.assert(response.body.note.title == "Hello");
client.assert(response.body.note.dateCreated == "2023-08-21 23:38:51.123+0200");
client.assert(response.body.note.utcDateCreated == "2023-08-21 23:38:51.123Z");
client.assert(response.body.branch.parentNoteId == "root");
client.log(`Created note ` + response.body.note.noteId + ` and branch ` + response.body.branch.branchId);
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.parentNoteId == "_hidden");
client.global.set("clonedBranchId", response.body.branchId);
client.log(`Created cloned branch ` + response.body.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("createdNoteId"));
client.assert(response.body.title == "Hello");
// order is not defined and may fail in the future
client.assert(response.body.parentBranchIds[0] == client.global.get("clonedBranchId"))
client.assert(response.body.parentBranchIds[1] == client.global.get("createdBranchId"));
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body == "Hi there!");
%}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.branchId == client.global.get("createdBranchId"));
client.assert(response.body.parentNoteId == "root");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.branchId == client.global.get("clonedBranchId"));
client.assert(response.body.parentNoteId == "_hidden");
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
Authorization: {{authToken}}
{
"attributeId": "forcedAttributeId{{$randomInt}}",
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {%
client.assert(response.status === 201);
client.assert(response.body.attributeId.startsWith("forcedAttributeId"));
client.global.set("createdAttributeId", response.body.attributeId);
%}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.attributeId == client.global.get("createdAttributeId"));
%}
###
POST {{triliumHost}}/etapi/attachments
Content-Type: application/json
Authorization: {{authToken}}
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "plain/text",
"title": "my attachment",
"content": "my text"
}
> {%
client.assert(response.status === 201);
client.global.set("createdAttachmentId", response.body.attachmentId);
%}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.attachmentId == client.global.get("createdAttachmentId"));
client.assert(response.body.role == "file");
client.assert(response.body.mime == "plain/text");
client.assert(response.body.title == "my attachment");
%}

@ -0,0 +1,52 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTACHMENT_NOT_FOUND");
%}

@ -0,0 +1,52 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND");
%}

@ -0,0 +1,87 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}

@ -0,0 +1,126 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "NOTE_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND");
%}

@ -0,0 +1,37 @@
GET {{triliumHost}}/etapi/notes/root/export
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=html
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=markdown
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=wrong
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "UNRECOGNIZED_EXPORT_FORMAT");
%}

@ -0,0 +1,72 @@
GET {{triliumHost}}/etapi/inbox/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "DATE_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "DATE_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/months/2022-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/months/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "MONTH_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/years/2022
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/years/202
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "YEAR_INVALID");
%}

@ -0,0 +1,116 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello parent",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("parentNoteId", response.body.note.noteId);
client.global.set("parentBranchId", response.body.branch.branchId);
%}
### Create inheritable parent attribute
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{parentNoteId}}",
"type": "label",
"name": "mylabel",
"value": "",
"isInheritable": true,
"position": 10
}
> {%
client.assert(response.status === 201);
client.global.set("parentAttributeId", response.body.attributeId);
%}
### Create child note under root
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello child",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("childNoteId", response.body.note.noteId);
client.global.set("childBranchId", response.body.branch.branchId);
%}
### Create child attribute
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{childNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": false,
"position": 10
}
> {%
client.assert(response.status === 201);
client.global.set("childAttributeId", response.body.attributeId);
%}
### Clone child to parent
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{childNoteId}}",
"parentNoteId": "{{parentNoteId}}"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.parentNoteId == client.global.get("parentNoteId"));
%}
###
GET {{triliumHost}}/etapi/notes/{{childNoteId}}
Authorization: {{authToken}}
> {%
function hasAttribute(list, attributeId) {
for (let i = 0; i < list.length; i++) {
if (list[i]["attributeId"] === attributeId) {
return true;
}
}
return false;
}
client.log(JSON.stringify(response.body.attributes));
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("childNoteId"));
client.assert(response.body.attributes.length == 2);
client.assert(hasAttribute(response.body.attributes, client.global.get("parentAttributeId")));
client.assert(hasAttribute(response.body.attributes, client.global.get("childAttributeId")));
%}

@ -0,0 +1,61 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "GetInheritedAttributes Test Note",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("parentNoteId", response.body.note.noteId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{parentNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "{{parentNoteId}}",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("createdNoteId"));
client.assert(response.body.attributes.length == 1);
client.assert(response.body.attributes[0].attributeId == client.global.get("createdAttributeId"));
%}

@ -0,0 +1,25 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body === "Hi there!");
%}

@ -0,0 +1,5 @@
{
"dev": {
"triliumHost": "http://localhost:37740"
}
}

@ -0,0 +1,12 @@
POST {{triliumHost}}/etapi/notes/root/import
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../db/demo.zip
> {%
client.assert(response.status === 201);
client.assert(response.body.note.title == "Trilium Demo");
client.assert(response.body.branch.parentNoteId == "root");
%}

@ -0,0 +1,34 @@
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "1234"
}
> {%
client.assert(response.status === 201);
client.global.set("testAuthToken", response.body.authToken);
%}
###
GET {{triliumHost}}/etapi/notes/root
Authorization: {{testAuthToken}}
> {% client.assert(response.status === 200); %}
###
POST {{triliumHost}}/etapi/auth/logout
Authorization: {{testAuthToken}}
Content-Type: application/json
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/notes/root
Authorization: {{testAuthToken}}
> {% client.assert(response.status === 401); %}

@ -0,0 +1,82 @@
### Test ETAPI metrics endpoint
# First login to get a token
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "{{password}}"
}
> {%
client.test("Login successful", function() {
client.assert(response.status === 201, "Response status is not 201");
client.assert(response.body.authToken, "Auth token not present");
client.global.set("authToken", response.body.authToken);
});
%}
### Get metrics with authentication (default Prometheus format)
GET {{triliumHost}}/etapi/metrics
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns Prometheus format by default", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
client.assert(response.body.includes("# HELP"), "Should contain HELP comments");
client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments");
});
%}
### Get metrics in JSON format
GET {{triliumHost}}/etapi/metrics?format=json
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns JSON when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json");
client.assert(response.body.version, "Version info not present");
client.assert(response.body.database, "Database info not present");
client.assert(response.body.timestamp, "Timestamp not present");
client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number");
client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number");
});
%}
### Get metrics in Prometheus format explicitly
GET {{triliumHost}}/etapi/metrics?format=prometheus
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns Prometheus format when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
});
%}
### Test invalid format parameter
GET {{triliumHost}}/etapi/metrics?format=xml
Authorization: {{authToken}}
> {%
client.test("Invalid format parameter returns error", function() {
client.assert(response.status === 400, "Response status should be 400");
client.assert(response.body.code === "INVALID_FORMAT", "Error code should be INVALID_FORMAT");
client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats");
});
%}
### Test without authentication (should fail)
GET {{triliumHost}}/etapi/metrics
> {%
client.test("Metrics endpoint requires authentication", function() {
client.assert(response.status === 401, "Response status should be 401");
});
%}

@ -0,0 +1,109 @@
GET {{triliumHost}}/etapi/notes?search=aaa
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/notes/root
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/notes/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/notes/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/branches/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/branches/root
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/branches/root
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/inbox/2022-02-22
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-02-22
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-02-22
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/months/2022-02
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/years/2022
> {% client.assert(response.status === 401); %}
###
POST {{triliumHost}}/etapi/create-note
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/app-info
> {% client.assert(response.status === 401); %}
### Fake URL will get a 404 even without token
GET {{triliumHost}}/etapi/zzzzzz
> {% client.assert(response.status === 404); %}

@ -0,0 +1,4 @@
POST {{triliumHost}}/etapi/refresh-note-ordering/root
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}

@ -0,0 +1,79 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": "CHANGED",
"position": 999
}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.body.title === "CHANGED");
client.assert(response.body.position === 999);
%}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": null
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

@ -0,0 +1,80 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"value": "CHANGED"
}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.body.value === "CHANGED");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"value": null
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

@ -0,0 +1,66 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"type": "text",
"title": "Hello",
"content": ""
}
> {% client.global.set("createdBranchId", response.body.branch.branchId); %}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"prefix": "pref",
"notePosition": 666,
"isExpanded": true
}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.prefix === 'pref');
client.assert(response.body.notePosition === 666);
client.assert(response.body.isExpanded === true);
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"prefix": 123
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

@ -0,0 +1,83 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "application/json",
"content": "{}"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Hello');
client.assert(response.body.type === 'code');
client.assert(response.body.mime === 'application/json');
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": "Wassup",
"type": "html",
"mime": "text/html",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Wassup');
client.assert(response.body.type === 'html');
client.assert(response.body.mime === 'text/html');
client.assert(response.body.dateCreated == "2023-08-21 23:38:51.123+0200");
client.assert(response.body.utcDateCreated == "2023-08-21 23:38:51.123Z");
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"isProtected": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

@ -0,0 +1,23 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "text/plain",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/notes/{{createdNoteId}}/revision
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}

@ -0,0 +1,39 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../images/icon-color.png
> {% client.assert(response.status === 204); %}

@ -0,0 +1,45 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
> {% client.assert(response.body === "Changed content"); %}

@ -0,0 +1,25 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "image",
"mime": "image/png",
"content": ""
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../images/icon-color.png
> {% client.assert(response.status === 204); %}

@ -0,0 +1,30 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "text/plain",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {% client.assert(response.body === "Changed content"); %}

@ -0,0 +1,39 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "title",
"type": "text",
"content": "{{$uuid}}"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {% client.global.set("content", response.body); %}
###
GET {{triliumHost}}/etapi/notes?search={{content}}&debug=true
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.results.length === 1);
%}
### Same but with fast search which doesn't look in the content so 0 notes should be found
GET {{triliumHost}}/etapi/notes?search={{content}}&fastSearch=true
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.results.length === 0);
%}

@ -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,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.95.0",
"version": "0.94.0",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@ -10,7 +10,7 @@
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.29.0",
"@eslint/js": "9.27.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
@ -25,9 +25,8 @@
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.7",
"bootstrap": "5.3.6",
"boxicons": "2.1.4",
"dayjs": "1.11.13",
"dayjs-plugin-utc": "0.1.2",
@ -48,10 +47,11 @@
"mark.js": "8.11.1",
"marked": "15.0.12",
"mermaid": "11.6.0",
"mind-elixir": "4.6.1",
"mind-elixir": "4.6.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.26.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"vanilla-js-wheel-zoom": "9.0.4"
@ -63,22 +63,14 @@
"@types/leaflet": "1.9.18",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"happy-dom": "17.5.6",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.0.2"
"vite-plugin-static-copy": "3.0.0"
},
"nx": {
"name": "client",
"targets": {
"serve": {
"dependsOn": [
"^build"
]
},
"circular-deps": {
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
}
}
"name": "client"
}
}

@ -0,0 +1,424 @@
/*
* Remove template code below
*/
html {
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
svg {
display: block;
vertical-align: middle;
}
svg {
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
overflow: scroll;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.rounded {
border-radius: 1.5rem;
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}
#welcome {
margin-top: 2.5rem;
}
#welcome h1 {
font-size: 3rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
}
#welcome span {
display: block;
font-size: 1.875rem;
font-weight: 300;
line-height: 2.25rem;
margin-bottom: 0.5rem;
}
#hero {
align-items: center;
background-color: hsla(214, 62%, 21%, 1);
border: none;
box-sizing: border-box;
color: rgba(55, 65, 81, 1);
display: grid;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#hero .text-container {
color: rgba(255, 255, 255, 1);
padding: 3rem 2rem;
}
#hero .text-container h2 {
font-size: 1.5rem;
line-height: 2rem;
position: relative;
}
#hero .text-container h2 svg {
color: hsla(162, 47%, 50%, 1);
height: 2rem;
left: -0.25rem;
position: absolute;
top: 0;
width: 2rem;
}
#hero .text-container h2 span {
margin-left: 2.5rem;
}
#hero .text-container a {
background-color: rgba(255, 255, 255, 1);
border-radius: 0.75rem;
color: rgba(55, 65, 81, 1);
display: inline-block;
margin-top: 1.5rem;
padding: 1rem 2rem;
text-decoration: inherit;
}
#hero .logo-container {
display: none;
justify-content: center;
padding-left: 2rem;
padding-right: 2rem;
}
#hero .logo-container svg {
color: rgba(255, 255, 255, 1);
width: 66.666667%;
}
#middle-content {
align-items: flex-start;
display: grid;
gap: 4rem;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#learning-materials {
padding: 2.5rem 2rem;
}
#learning-materials h2 {
font-weight: 500;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.list-item-link {
align-items: center;
border-radius: 0.75rem;
display: flex;
margin-top: 1rem;
padding: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 100%;
}
.list-item-link svg:first-child {
margin-right: 1rem;
height: 1.5rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1.5rem;
}
.list-item-link > span {
flex-grow: 1;
font-weight: 400;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link > span > span {
color: rgba(107, 114, 128, 1);
display: block;
flex-grow: 1;
font-size: 0.75rem;
font-weight: 300;
line-height: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link svg:last-child {
height: 1rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1rem;
}
.list-item-link:hover {
color: rgba(255, 255, 255, 1);
background-color: hsla(162, 47%, 50%, 1);
}
.list-item-link:hover > span {
}
.list-item-link:hover > span > span {
color: rgba(243, 244, 246, 1);
}
.list-item-link:hover svg:last-child {
transform: translateX(0.25rem);
}
#other-links {
}
.button-pill {
padding: 1.5rem 2rem;
transition-duration: 300ms;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
align-items: center;
display: flex;
}
.button-pill svg {
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
flex-shrink: 0;
width: 3rem;
}
.button-pill > span {
letter-spacing: -0.025em;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.button-pill span span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
.button-pill:hover svg,
.button-pill:hover {
color: rgba(255, 255, 255, 1) !important;
}
#nx-console:hover {
background-color: rgba(0, 122, 204, 1);
}
#nx-console svg {
color: rgba(0, 122, 204, 1);
}
#nx-console-jetbrains {
margin-top: 2rem;
}
#nx-console-jetbrains:hover {
background-color: rgba(255, 49, 140, 1);
}
#nx-console-jetbrains svg {
color: rgba(255, 49, 140, 1);
}
#nx-repo:hover {
background-color: rgba(24, 23, 23, 1);
}
#nx-repo svg {
color: rgba(24, 23, 23, 1);
}
#nx-cloud {
margin-bottom: 2rem;
margin-top: 2rem;
padding: 2.5rem 2rem;
}
#nx-cloud > div {
align-items: center;
display: flex;
}
#nx-cloud > div svg {
border-radius: 0.375rem;
flex-shrink: 0;
width: 3rem;
}
#nx-cloud > div h2 {
font-size: 1.125rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#nx-cloud > div h2 span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
#nx-cloud p {
font-size: 1rem;
line-height: 1.5rem;
margin-top: 1rem;
}
#nx-cloud pre {
margin-top: 1rem;
}
#nx-cloud a {
color: rgba(107, 114, 128, 1);
display: block;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 1.5rem;
text-align: right;
}
#nx-cloud a:hover {
text-decoration: underline;
}
#commands {
padding: 2.5rem 2rem;
margin-top: 3.5rem;
}
#commands h2 {
font-size: 1.25rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#commands p {
font-size: 1rem;
font-weight: 300;
line-height: 1.5rem;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
}
details {
align-items: center;
display: flex;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
}
details pre > span {
color: rgba(181, 181, 181, 1);
}
summary {
border-radius: 0.5rem;
display: flex;
font-weight: 400;
padding: 0.5rem;
cursor: pointer;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
summary:hover {
background-color: rgba(243, 244, 246, 1);
}
summary svg {
height: 1.5rem;
margin-right: 1rem;
width: 1.5rem;
}
#love {
color: rgba(107, 114, 128, 1);
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 3.5rem;
opacity: 0.6;
text-align: center;
}
#love svg {
color: rgba(252, 165, 165, 1);
width: 1.25rem;
height: 1.25rem;
display: inline;
margin-top: -0.25rem;
}
@media screen and (min-width: 768px) {
#hero {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#hero .logo-container {
display: flex;
}
#middle-content {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

@ -0,0 +1,21 @@
import { AppElement } from './app.element';
describe('AppElement', () => {
let app: AppElement;
beforeEach(() => {
app = new AppElement();
});
it('should create successfully', () => {
expect(app).toBeTruthy();
});
it('should have a greeting', () => {
app.connectedCallback();
expect(app.querySelector('h1').innerHTML).toContain(
'Welcome @triliumnext/client'
);
});
});

@ -0,0 +1,409 @@
import './app.element.css';
export class AppElement extends HTMLElement {
public static observedAttributes = [
];
connectedCallback() {
const title = '@triliumnext/client';
this.innerHTML = `
<div class="wrapper">
<div class="container">
<!-- WELCOME -->
<div id="welcome">
<h1>
<span> Hello there, </span>
Welcome ${title} 👋
</h1>
</div>
<!-- HERO -->
<div id="hero" class="rounded">
<div class="text-container">
<h2>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<span>You&apos;re up and running</span>
</h2>
<a href="#commands"> What&apos;s next? </a>
</div>
<div class="logo-container">
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z"
/>
</svg>
</div>
</div>
<!-- MIDDLE CONTENT -->
<div id="middle-content">
<div id="learning-materials" class="rounded shadow">
<h2>Learning materials</h2>
<a href="https://nx.dev/getting-started/intro?utm_source=nx-project" target="_blank" rel="noreferrer" class="list-item-link">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>
Documentation
<span> Everything is in there </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a href="https://nx.dev/blog/?utm_source=nx-project" target="_blank" rel="noreferrer" class="list-item-link">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
<span>
Blog
<span> Changelog, features & events </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a href="https://www.youtube.com/@NxDevtools/videos?utm_source=nx-project&sub_confirmation=1" target="_blank" rel="noreferrer" class="list-item-link">
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>YouTube</title>
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/>
</svg>
<span>
YouTube channel
<span> Nx Show, talks & tutorials </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a href="https://nx.dev/react-tutorial/1-code-generation?utm_source=nx-project" target="_blank" rel="noreferrer" class="list-item-link">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
<span>
Interactive tutorials
<span> Create an app, step-by-step </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a href="https://nxplaybook.com/?utm_source=nx-project" target="_blank" rel="noreferrer" class="list-item-link">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 14l9-5-9-5-9 5 9 5z" />
<path
d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222"
/>
</svg>
<span>
Video courses
<span> Nx custom courses </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
<div id="other-links">
<a id="nx-console" class="button-pill rounded shadow" href="https://marketplace.visualstudio.com/items?itemName=nrwl.angular-console&utm_source=nx-project" target="_blank" rel="noreferrer">
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Visual Studio Code</title>
<path
d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
/>
</svg>
<span>
Install Nx Console for VSCode
<span>The official VSCode extension for Nx.</span>
</span>
</a>
<a
id="nx-console-jetbrains"
class="button-pill rounded shadow"
href="https://plugins.jetbrains.com/plugin/21060-nx-console"
target="_blank"
rel="noreferrer"
>
<svg
height="48"
width="48"
viewBox="20 20 60 60"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m22.5 22.5h60v60h-60z" />
<g fill="#fff">
<path d="m29.03 71.25h22.5v3.75h-22.5z" />
<path d="m28.09 38 1.67-1.58a1.88 1.88 0 0 0 1.47.87c.64 0 1.06-.44 1.06-1.31v-5.98h2.58v6a3.48 3.48 0 0 1 -.87 2.6 3.56 3.56 0 0 1 -2.57.95 3.84 3.84 0 0 1 -3.34-1.55z" />
<path d="m36 30h7.53v2.19h-5v1.44h4.49v2h-4.42v1.49h5v2.21h-7.6z" />
<path d="m47.23 32.29h-2.8v-2.29h8.21v2.27h-2.81v7.1h-2.6z" />
<path d="m29.13 43.08h4.42a3.53 3.53 0 0 1 2.55.83 2.09 2.09 0 0 1 .6 1.53 2.16 2.16 0 0 1 -1.44 2.09 2.27 2.27 0 0 1 1.86 2.29c0 1.61-1.31 2.59-3.55 2.59h-4.44zm5 2.89c0-.52-.42-.8-1.18-.8h-1.29v1.64h1.24c.79 0 1.25-.26 1.25-.81zm-.9 2.66h-1.57v1.73h1.62c.8 0 1.24-.31 1.24-.86 0-.5-.4-.87-1.27-.87z" />
<path d="m38 43.08h4.1a4.19 4.19 0 0 1 3 1 2.93 2.93 0 0 1 .9 2.19 3 3 0 0 1 -1.93 2.89l2.24 3.27h-3l-1.88-2.84h-.87v2.84h-2.56zm4 4.5c.87 0 1.39-.43 1.39-1.11 0-.75-.54-1.12-1.4-1.12h-1.44v2.26z" />
<path d="m49.59 43h2.5l4 9.44h-2.79l-.67-1.69h-3.63l-.67 1.69h-2.71zm2.27 5.73-1-2.65-1.06 2.65z" />
<path d="m56.46 43.05h2.6v9.37h-2.6z" />
<path d="m60.06 43.05h2.42l3.37 5v-5h2.57v9.37h-2.26l-3.53-5.14v5.14h-2.57z" />
<path d="m68.86 51 1.45-1.73a4.84 4.84 0 0 0 3 1.13c.71 0 1.08-.24 1.08-.65 0-.4-.31-.6-1.59-.91-2-.46-3.53-1-3.53-2.93 0-1.74 1.37-3 3.62-3a5.89 5.89 0 0 1 3.86 1.25l-1.26 1.84a4.63 4.63 0 0 0 -2.62-.92c-.63 0-.94.25-.94.6 0 .42.32.61 1.63.91 2.14.46 3.44 1.16 3.44 2.91 0 1.91-1.51 3-3.79 3a6.58 6.58 0 0 1 -4.35-1.5z" />
</g>
</svg>
<span>
Install Nx Console for JetBrains
<span>
Available for WebStorm, Intellij IDEA Ultimate and more!
</span>
</span>
</a>
<div id="nx-cloud" class="rounded shadow">
<div>
<svg id="nx-cloud-logo" role="img" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" fill="transparent" viewBox="0 0 24 24">
<path stroke-width="2" d="M23 3.75V6.5c-3.036 0-5.5 2.464-5.5 5.5s-2.464 5.5-5.5 5.5-5.5 2.464-5.5 5.5H3.75C2.232 23 1 21.768 1 20.25V3.75C1 2.232 2.232 1 3.75 1h16.5C21.768 1 23 2.232 23 3.75Z" />
<path stroke-width="2" d="M23 6v14.1667C23 21.7307 21.7307 23 20.1667 23H6c0-3.128 2.53867-5.6667 5.6667-5.6667 3.128 0 5.6666-2.5386 5.6666-5.6666C17.3333 8.53867 19.872 6 23 6Z" />
</svg>
<h2>
Nx Cloud
<span>
Enable faster CI & better DX
</span>
</h2>
</div>
<p>
You can activate distributed tasks executions and caching by
running:
</p>
<pre>nx connect</pre>
<a href="https://nx.app/?utm_source=nx-project" target="_blank" rel="noreferrer"> What is Nx Cloud? </a>
</div>
<a id="nx-repo" class="button-pill rounded shadow" href="https://github.com/nrwl/nx?utm_source=nx-project" target="_blank" rel="noreferrer">
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
<span>
Nx is open source
<span> Love Nx? Give us a star! </span>
</span>
</a>
</div>
</div>
<!-- COMMANDS -->
<div id="commands" class="rounded shadow">
<h2>Next steps</h2>
<p>Here are some things you can do with Nx:</p>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Add UI library
</summary>
<pre><span># Generate UI lib</span>
nx g @nx/angular:lib ui
<span># Add a component</span>
nx g @nx/angular:component ui/src/lib/button</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
View interactive project graph
</summary>
<pre>nx graph</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Run affected commands
</summary>
<pre><span># see what&apos;s been affected by changes</span>
nx affected:graph
<span># run tests for current changes</span>
nx affected:test
<span># run e2e tests for current changes</span>
nx affected:e2e</pre>
</details>
</div>
<p id="love">
Carefully crafted with
<svg
fill="currentColor"
stroke="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</p>
</div>
</div>
`;
}
}
customElements.define('triliumnext-root', AppElement);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Client</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<triliumnext-root></triliumnext-root>
</body>
</html>

@ -0,0 +1 @@
import './app/app.element';

@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

@ -1,4 +1,5 @@
import froca from "../services/froca.js";
import bundleService from "../services/bundle.js";
import RootCommandExecutor from "./root_command_executor.js";
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
import options from "../services/options.js";
@ -27,7 +28,6 @@ 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;
@ -128,7 +128,6 @@ export type CommandMappings = {
openAboutDialog: CommandData;
hideFloatingButtons: {};
hideLeftPane: CommandData;
showCpuArchWarning: CommandData;
showLeftPane: CommandData;
hoistNote: CommandData & { noteId: string };
leaveProtectedSession: CommandData;
@ -280,7 +279,6 @@ export type CommandMappings = {
buildIcon(name: string): NativeImage;
};
refreshTouchBar: CommandData;
reloadTextEditor: CommandData;
};
type EventMappings = {
@ -469,21 +467,13 @@ export class AppContext extends Component {
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()
];
this.components = [this.tabManager, new RootCommandExecutor(), new Entrypoints(), new MainTreeExecutors(), new ShortcutComponent()];
if (utils.isMobile()) {
this.components.push(new MobileScreenSwitcherExecutor());

@ -12,7 +12,6 @@ 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;
@ -84,7 +83,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
await this.triggerEvent("beforeNoteSwitch", { noteContext: this });
closeActiveDialog();
utils.closeActiveDialog();
this.notePath = resolvedNotePath;
this.viewScope = opts.viewScope;
@ -270,32 +269,14 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
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 blob = await this.note.getBlob();
if (!blob) {
return false;
}
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"));
}
const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode");
// Return the cached decision, which won't change until viewScope is reset
return viewScope.isReadOnly || false;
return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

@ -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);
}
}
}

@ -8,7 +8,6 @@ import electronContextMenu from "./menus/electron_context_menu.js";
import glob from "./services/glob.js";
import { t } from "./services/i18n.js";
import options from "./services/options.js";
import server from "./services/server.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";

@ -1,6 +1,7 @@
import server from "../services/server.js";
import noteAttributeCache from "../services/note_attribute_cache.js";
import ws from "../services/ws.js";
import froca from "../services/froca.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import cssClassManager from "../services/css_class_manager.js";
import type { Froca } from "../services/froca-interface.js";
@ -409,8 +410,8 @@ class FNote {
const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({
notePath: path,
isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId),
isArchived: path.some((noteId) => this.froca.notes[noteId].isArchived),
isSearch: path.some((noteId) => this.froca.notes[noteId].type === "search"),
isArchived: path.some((noteId) => froca.notes[noteId].isArchived),
isSearch: path.some((noteId) => froca.notes[noteId].type === "search"),
isHidden: path.includes("_hidden")
}));
@ -981,7 +982,7 @@ class FNote {
continue;
}
const parentNote = this.froca.notes[parentNoteId];
const parentNote = froca.notes[parentNoteId];
if (!parentNote || parentNote.type === "search") {
continue;

@ -21,7 +21,6 @@ import ConfirmDialog from "../widgets/dialogs/confirm.js";
import RevisionsDialog from "../widgets/dialogs/revisions.js";
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@ -46,5 +45,4 @@ export function applyModals(rootContainer: RootContainer) {
.child(new InfoDialog())
.child(new ConfirmDialog())
.child(new PromptDialog())
.child(new IncorrectCpuArchDialog())
}

@ -0,0 +1,204 @@
// Source: https://github.com/codemirror/codemirror5/pull/7080/files
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: https://codemirror.net/5/LICENSE
(function (mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], mod);
else // Plain browser env
mod(CodeMirror);
})(function (CodeMirror) {
"use strict";
CodeMirror.defineMode("hcl", function (config) {
var indentUnit = config.indentUnit;
var keywords = {
"resource": true,
"variable": true,
"output": true,
"module": true,
"provider": true,
"data": true,
"locals": true,
"terraform": true,
"if": true,
"else": true,
"for": true,
"foreach": true,
"in": true,
"true": true,
"false": true,
"null": true,
};
var atoms = {
"true": true,
"false": true,
"null": true,
};
var isOperatorChar = /[+\-*&^%:=<>!|\/]/;
var curPunc;
function tokenBase(stream, state) {
var ch = stream.next();
if (ch == '"' || ch == "'" || ch == "`") {
state.tokenize = tokenString(ch);
return state.tokenize(stream, state);
}
if (/[\d\.]/.test(ch)) {
if (ch == ".") {
stream.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/);
} else {
stream.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/);
}
return "number";
}
if (/[\[\]{}\(\),;\:\.]/.test(ch)) {
curPunc = ch;
return null;
}
if (ch == "/") {
if (stream.eat("*")) {
state.tokenize = tokenComment;
return tokenComment(stream, state);
}
if (stream.eat("/")) {
stream.skipToEnd();
return "comment";
}
}
if (isOperatorChar.test(ch)) {
stream.eatWhile(isOperatorChar);
return "operator";
}
stream.eatWhile(/[\w\$_\xa1-\uffff]/);
var cur = stream.current();
if (keywords.propertyIsEnumerable(cur)) {
return "keyword";
}
if (atoms.propertyIsEnumerable(cur)) return "atom";
return "variable";
}
function tokenString(quote) {
return function (stream, state) {
var escaped = false,
next,
end = false;
while ((next = stream.next()) != null) {
if (next == quote && !escaped) {
end = true;
break;
}
escaped = !escaped && quote != "`" && next == "\\";
}
if (end || !(escaped || quote == "`"))
state.tokenize = tokenBase;
return "string";
};
}
function tokenComment(stream, state) {
var maybeEnd = false,
ch;
while (ch = stream.next()) {
if (ch == "/" && maybeEnd) {
state.tokenize = tokenBase;
break;
}
maybeEnd = (ch == "*");
}
return "comment";
}
function Context(indented, column, type, align, prev) {
this.indented = indented;
this.column = column;
this.type = type;
this.align = align;
this.prev = prev;
}
function pushContext(state, col, type) {
return state.context = new Context(state.indented, col, type, null, state.context);
}
function popContext(state) {
if (!state.context.prev) return;
var t = state.context.type;
if (t == ")" || t == "]" || t == "}")
state.indented = state.context.indented;
return state.context = state.context.prev;
}
// Interface
return {
startState: function (basecolumn) {
return {
tokenize: null,
context: new Context((basecolumn || 0) - indentUnit, 0, "top", false),
indented: 0,
startOfLine: true
};
},
token: function (stream, state) {
var ctx = state.context;
if (stream.sol()) {
if (ctx.align == null) ctx.align = false;
state.indented = stream.indentation();
state.startOfLine = true;
}
if (stream.eatSpace()) return null;
curPunc = null;
var style = (state.tokenize || tokenBase)(stream, state);
if (style == "comment") return style;
if (ctx.align == null) ctx.align = true;
if (curPunc == "{") pushContext(state, stream.column(), "}");
else if (curPunc == "[") pushContext(state, stream.column(), "]");
else if (curPunc == "(") pushContext(state, stream.column(), ")");
else if (curPunc == "}" && ctx.type == "}") popContext(state);
else if (curPunc == ctx.type) popContext(state);
state.startOfLine = false;
return style;
},
indent: function (state, textAfter) {
if (state.tokenize != tokenBase && state.tokenize != null) return CodeMirror.Pass;
var ctx = state.context, firstChar = textAfter && textAfter.charAt(0);
if (firstChar == "#" || firstChar == ";") return 0;
if (stream.sol()) {
if (ctx.type == "case" && /^(?:case|default)\b/.test(textAfter)) {
state.context.type = "}";
return ctx.indented;
}
var closing = firstChar == ctx.type;
if (ctx.align) return ctx.column + (closing ? 0 : 1);
else return ctx.indented + (closing ? 0 : indentUnit);
}
},
electricChars: "{}):",
closeBrackets: "()[]{}''\"\"``",
fold: "brace",
blockCommentStart: "/*",
blockCommentEnd: "*/",
lineComment: "//"
};
});
CodeMirror.defineMIME("text/x-hcl", "hcl");
CodeMirror.modeInfo.push({
ext: [ "hcl " ],
mime: "text/x-hcl",
mode: "hcl",
name: "Terraform (HCL)"
});
});

@ -192,17 +192,13 @@ class ContextMenu {
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
$item.on("mouseup", (e) => {
// Prevent submenu from failing to expand on mobile
if (!this.isMobile || !("items" in item && item.items)) {
})
.on("mouseup", (e) =>{
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
}
});
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");

@ -8,7 +8,7 @@ interface Entity {
export interface EntityChange {
id?: number | null;
noteId?: string;
entityName: EntityType;
entityName: EntityRowNames;
entityId: string;
entity?: Entity;
positions?: Record<string, number>;
@ -22,5 +22,3 @@ export interface EntityChange {
changeId?: string | null;
instanceId?: string | null;
}
export type EntityType = "notes" | "branches" | "attributes" | "note_reordering" | "revisions" | "options" | "attachments" | "blobs" | "etapi_tokens";

@ -1,6 +1,6 @@
import ScriptContext from "./script_context.js";
import server from "./server.js";
import toastService, { showError } from "./toast.js";
import toastService from "./toast.js";
import froca from "./froca.js";
import utils from "./utils.js";
import { t } from "./i18n.js";
@ -37,9 +37,7 @@ async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $cont
} catch (e: any) {
const note = await froca.getNote(bundle.noteId);
const message = `Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`;
showError(message);
logError(message);
toastService.showAndLogError(`Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`);
}
}

@ -4,7 +4,7 @@ import froca from "./froca.js";
import linkService from "./link.js";
import utils from "./utils.js";
import { t } from "./i18n.js";
import { throwError } from "./ws.js";
import toast from "./toast.js";
let clipboardBranchIds: string[] = [];
let clipboardMode: string | null = null;
@ -37,7 +37,7 @@ async function pasteAfter(afterBranchId: string) {
// copy will keep clipboardBranchIds and clipboardMode, so it's possible to paste into multiple places
} else {
throwError(`Unrecognized clipboard mode=${clipboardMode}`);
toastService.throwError(`Unrecognized clipboard mode=${clipboardMode}`);
}
}
@ -69,7 +69,7 @@ async function pasteInto(parentBranchId: string) {
// copy will keep clipboardBranchIds and clipboardMode, so it's possible to paste into multiple places
} else {
throwError(`Unrecognized clipboard mode=${clipboardMode}`);
toastService.throwError(`Unrecognized clipboard mode=${clipboardMode}`);
}
}
@ -109,6 +109,39 @@ function isClipboardEmpty() {
return clipboardBranchIds.length === 0;
}
export function copyText(text: string) {
if (!text) {
return;
}
let succeeded = false;
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
succeeded = true;
} else {
// Fallback method: https://stackoverflow.com/a/72239825
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
succeeded = document.execCommand('copy');
document.body.removeChild(textArea);
}
} catch (e) {
console.warn(e);
succeeded = false;
}
if (succeeded) {
toast.showMessage(t("clipboard.copy_success"));
} else {
toast.showError(t("clipboard.copy_failed"));
}
}
export default {
pasteAfter,
pasteInto,

@ -1,37 +0,0 @@
export function copyText(text: string) {
if (!text) {
return;
}
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
return true;
} else {
// Fallback method: https://stackoverflow.com/a/72239825
const textArea = document.createElement("textarea");
textArea.value = text;
try {
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return document.execCommand('copy');
} finally {
document.body.removeChild(textArea);
}
}
} catch (e) {
console.warn(e);
return false;
}
}
export async function copyTextWithToast(text: string) {
const t = (await import("./i18n.js")).t;
const toast = (await import("./toast.js")).default;
if (copyText(text)) {
toast.showMessage(t("clipboard.copy_success"));
} else {
toast.showError(t("clipboard.copy_failed"));
}
}

@ -1,41 +1,6 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
if (closeActDialog) {
closeActiveDialog();
glob.activeDialog = $dialog;
}
saveFocusedElement();
Modal.getOrCreateInstance($dialog[0]).show();
$dialog.on("hidden.bs.modal", () => {
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
if (!glob.activeDialog || glob.activeDialog === $dialog) {
focusSavedElement();
}
});
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
keyboardActionsService.updateDisplayedShortcuts($dialog);
return $dialog;
}
export function closeActiveDialog() {
if (glob.activeDialog) {
Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
glob.activeDialog = null;
}
}
async function info(message: string) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));

@ -1,29 +0,0 @@
let $lastFocusedElement: JQuery<HTMLElement> | null;
// perhaps there should be saved focused element per tab?
export function saveFocusedElement() {
$lastFocusedElement = $(":focus");
}
export function focusSavedElement() {
if (!$lastFocusedElement) {
return;
}
if ($lastFocusedElement.hasClass("ck")) {
// must handle CKEditor separately because of this bug: https://github.com/ckeditor/ckeditor5/issues/607
// the bug manifests itself in resetting the cursor position to the first character - jumping above
const editor = $lastFocusedElement.closest(".ck-editor__editable").prop("ckeditorInstance");
if (editor) {
editor.editing.view.focus();
} else {
console.log("Could not find CKEditor instance to focus last element");
}
} else {
$lastFocusedElement.focus();
}
$lastFocusedElement = null;
}

@ -245,10 +245,6 @@ class FrocaImpl implements Froca {
}
async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> {
if (noteIds.length === 0) {
return [];
}
noteIds = Array.from(new Set(noteIds)); // make unique
const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]);

@ -36,7 +36,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
} else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
// NOOP - these entities are handled at the backend level and don't require frontend processing
// NOOP
} else {
throw new Error(`Unknown entityName '${ec.entityName}'`);
}

@ -26,18 +26,12 @@ function setupGlobs() {
window.onerror = function (msg, url, lineNo, columnNo, error) {
const string = String(msg).toLowerCase();
let errorObjectString = "";
try {
errorObjectString = JSON.stringify(error);
} catch (e: any) {
errorObjectString = e.toString();
}
let message = "Uncaught error: ";
if (string.includes("script error")) {
message += "No details available";
} else {
message += [`Message: ${msg}`, `URL: ${url}`, `Line: ${lineNo}`, `Column: ${columnNo}`, `Error object: ${errorObjectString}`, `Stack: ${error && error.stack}`].join(", ");
message += [`Message: ${msg}`, `URL: ${url}`, `Line: ${lineNo}`, `Column: ${columnNo}`, `Error object: ${JSON.stringify(error)}`, `Stack: ${error && error.stack}`].join(", ");
}
ws.logError(message);

@ -1,7 +1,6 @@
import { LOCALES } from "@triliumnext/commons";
import { readFileSync } from "fs";
import { join } from "path";
import { describe, expect, it } from "vitest";
describe("i18n", () => {
it("translations are valid JSON", () => {

@ -1,5 +1,5 @@
import { t } from "./i18n.js";
import toastService, { showError } from "./toast.js";
import toastService from "./toast.js";
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
try {
@ -11,9 +11,7 @@ function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
if (success) {
toastService.showMessage(t("image.copied-to-clipboard"));
} else {
const message = t("image.cannot-copy");
showError(message);
logError(message);
toastService.showAndLogError(t("image.cannot-copy"));
}
} finally {
window.getSelection()?.removeAllRanges();

@ -115,7 +115,6 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
export default {
updateDisplayedShortcuts,
setupActionsForElement,
getAction,
getActions,
getActionsForScope
};

@ -16,29 +16,4 @@ describe("Link", () => {
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("parses notePath with spaces", () => {
const output = parseNavigationStateFromUrl(` #root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("parses notePath with extraWindow", () => {
const output = parseNavigationStateFromUrl(`127.0.0.1:8080/?extraWindow=1#root/QZGqKB7wVZF8?ntxId=0XPvXG`);
expect(output).toMatchObject({ notePath: "root/QZGqKB7wVZF8", noteId: "QZGqKB7wVZF8" });
});
it("ignores external URL with internal hash anchor", () => {
const output = parseNavigationStateFromUrl(`https://en.wikipedia.org/wiki/Bearded_Collie#Health`);
expect(output).toMatchObject({});
});
it("ignores malformed but hash-containing external URL", () => {
const output = parseNavigationStateFromUrl("https://abc.com/#drop?searchString=firefox");
expect(output).toStrictEqual({});
});
it("ignores non-hash internal path", () => {
const output = parseNavigationStateFromUrl("/root/abc123");
expect(output).toStrictEqual({});
});
});

@ -48,13 +48,6 @@ export interface ViewScope {
viewMode?: ViewMode;
attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean;
/**
* If true, it indicates that the note in the view should be opened in read-only mode (for supported note types such as text or code).
*
* The reason why we store this information here is that a note can become read-only as the user types content in it, and we wouldn't want
* to immediately enter read-only mode.
*/
isReadOnly?: boolean;
highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean;
tocTemporarilyHidden?: boolean;
@ -211,17 +204,11 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
return {};
}
url = url.trim();
const hashIdx = url.indexOf("#");
if (hashIdx === -1) {
return {};
}
// Exclude external links that contain #
if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString") && !url.includes("/?extraWindow")) {
return {};
}
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
let [notePath, paramString] = hash.split("?");
@ -285,10 +272,8 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
evt.preventDefault();
evt.stopPropagation();
if (hrefLink && hrefLink.startsWith("#") && !hrefLink.startsWith("#root/") && $link) {
if (handleAnchor(hrefLink, $link)) {
return true;
}
if (hrefLink?.startsWith("#fn") && $link) {
return handleFootnote(hrefLink, $link);
}
const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink);
@ -350,19 +335,18 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
}
/**
* Scrolls to either the footnote (if clicking on a reference such as `[1]`), or to the reference of a footnote (if clicking on the footnote `^` arrow),
* or CKEditor bookmarks.
* Scrolls to either the footnote (if clicking on a reference such as `[1]`), or to the reference of a footnote (if clicking on the footnote `^` arrow).
*
* @param hrefLink the URL of the link that was clicked (it should be in the form of `#fn` or `#fnref`).
* @param $link the element of the link that was clicked.
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), `false` otherwise.
* @returns whether the event should be consumed or not.
*/
function handleAnchor(hrefLink: string, $link: JQuery<HTMLElement>) {
function handleFootnote(hrefLink: string, $link: JQuery<HTMLElement>) {
const el = $link.closest(".ck-content").find(hrefLink)[0];
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
return !!el;
return true;
}
function linkContextMenu(e: PointerEvent) {

@ -44,7 +44,10 @@ interface OptionRow {}
interface NoteReorderingRow {}
interface ContentNoteIdToComponentIdRow {
noteId: string;
componentId: string;
}
type EntityRowMappings = {
notes: NoteRow;

@ -289,11 +289,13 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
}
if (suggestion.action === "create-note") {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
const { success, noteType, templateNoteId } = await noteCreateService.chooseNoteType();
if (!success) {
return;
}
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
const { note } = await noteCreateService.createNote(suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,

@ -116,7 +116,7 @@ async function chooseNoteType() {
}
async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) {
const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
const { success, noteType, templateNoteId } = await chooseNoteType();
if (!success) {
return;
@ -125,7 +125,7 @@ async function createNoteWithTypePrompt(parentNotePath: string, options: CreateN
options.type = noteType;
options.templateNoteId = templateNoteId;
return await createNote(notePath || parentNotePath, options);
return await createNote(parentNotePath, options);
}
/* If the first element is heading, parse it out and use it as a new heading. */

@ -73,8 +73,8 @@ async function mouseEnterHandler(this: HTMLElement) {
}
let renderPromise;
if (url && url.startsWith("#") && !url.startsWith("#root/")) {
renderPromise = renderFootnoteOrAnchor($link, url);
if (url?.startsWith("#fn")) {
renderPromise = renderFootnote($link, url);
} else {
renderPromise = renderTooltip(await froca.getNote(noteId));
}
@ -178,47 +178,33 @@ async function renderTooltip(note: FNote | null) {
return content;
}
function renderFootnoteOrAnchor($link: JQuery<HTMLElement>, url: string) {
function renderFootnote($link: JQuery<HTMLElement>, url: string) {
// A footnote text reference
const footnoteRef = url.substring(3);
let $targetContent: JQuery<HTMLElement>;
if (url.startsWith("#fn")) {
$targetContent = $link
.closest(".ck-content") // find the parent CK content
.find("> .footnote-section") // find the footnote section
.find(`a[href="#fnref${footnoteRef}"]`) // find the footnote link
.closest(".footnote-item") // find the parent container of the footnote
.find(".footnote-content"); // find the actual text content of the footnote
} else {
$targetContent = $link
.closest(".ck-content")
.find(url)
.closest("p");
}
if (!$targetContent.length) {
// If the target content is not found, return an empty string
return "";
}
const $footnoteContent = $link
.closest(".ck-content") // find the parent CK content
.find("> .footnote-section") // find the footnote section
.find(`a[href="#fnref${footnoteRef}"]`) // find the footnote link
.closest(".footnote-item") // find the parent container of the footnote
.find(".footnote-content"); // find the actual text content of the footnote
const isEditable = $link.closest(".ck-content").hasClass("note-detail-editable-text-editor");
if (isEditable) {
/* Remove widget buttons for tables, formulas, and images in editable notes. */
$targetContent.find('.ck-widget__selection-handle').remove();
$targetContent.find('.ck-widget__type-around').remove();
$targetContent.find('.ck-widget__resizer').remove();
$footnoteContent.find('.ck-widget__selection-handle').remove();
$footnoteContent.find('.ck-widget__type-around').remove();
$footnoteContent.find('.ck-widget__resizer').remove();
/* Handling in-line math formulas */
$targetContent.find('.ck-math-tex.ck-math-tex-inline.ck-widget').each(function () {
$footnoteContent.find('.ck-math-tex.ck-math-tex-inline.ck-widget').each(function () {
const $katex = $(this).find('.katex').first();
if ($katex.length) {
$(this).replaceWith($('<span class="math-tex"></span>').append($('<span></span>').append($katex.clone())));
}
});
}
let footnoteContent = $targetContent.html();
let footnoteContent = $footnoteContent.html();
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`
return footnoteContent || "";
}

@ -4,8 +4,6 @@ import { t } from "./i18n.js";
import type { MenuItem } from "../menus/context_menu.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
const SEPARATOR = { title: "----" };
async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
@ -20,59 +18,25 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
{ title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
{ title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
...await getBuiltInTemplates(command),
...await getUserTemplates(command)
];
return items;
}
async function getUserTemplates(command?: TreeCommandNames) {
const templateNoteIds = await server.get<string[]>("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds);
if (templateNotes.length === 0) {
return [];
}
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of templateNotes) {
items.push({
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
}
return items;
}
async function getBuiltInTemplates(command?: TreeCommandNames) {
const templatesRoot = await froca.getNote("_templates");
if (!templatesRoot) {
console.warn("Unable to find template root.");
return [];
}
const childNotes = await templatesRoot.getChildNotes();
if (childNotes.length === 0) {
return [];
if (templateNotes.length > 0) {
items.push({ title: "----" });
for (const templateNote of templateNotes) {
items.push({
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
}
}
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of childNotes) {
items.push({
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
}
return items;
}

@ -1,5 +1,4 @@
import server from "./server.js";
import { isShare } from "./utils.js";
type OptionValue = number | string;
@ -8,11 +7,7 @@ class Options {
private arr!: Record<string, OptionValue>;
constructor() {
if (!isShare) {
this.initializedPromise = server.get<Record<string, OptionValue>>("options").then((data) => this.load(data));
} else {
this.initializedPromise = Promise.resolve();
}
this.initializedPromise = server.get<Record<string, OptionValue>>("options").then((data) => this.load(data));
}
load(arr: Record<string, OptionValue>) {

@ -1,4 +1,4 @@
import type { Entity } from "./frontend_script_api.js";
import FrontendScriptApi, { type Entity } from "./frontend_script_api.js";
import utils from "./utils.js";
import froca from "./froca.js";
@ -14,8 +14,6 @@ async function ScriptContext(startNoteId: string, allNoteIds: string[], originEn
throw new Error(`Could not find start note ${startNoteId}.`);
}
const FrontendScriptApi = (await import("./frontend_script_api.js")).default;
return {
modules: modules,
notes: utils.toObject(allNotes, (note) => [note.noteId, note]),

@ -1,4 +1,4 @@
import utils, { isShare } from "./utils.js";
import utils from "./utils.js";
import ValidationError from "./validation_error.js";
type Headers = Record<string, string | null | undefined>;
@ -28,10 +28,6 @@ export interface StandardResponse {
}
async function getHeaders(headers?: Headers) {
if (isShare) {
return {};
}
const appContext = (await import("../components/app_context.js")).default;
const activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null;
@ -276,8 +272,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
} else {
const title = `${statusCode} ${method} ${url}`;
toastService.showErrorTitleAndMessage(title, messageStr);
const { throwError } = await import("./ws.js");
throwError(`${title} - ${message}`);
toastService.throwError(`${title} - ${message}`);
}
}

@ -2,9 +2,7 @@ import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type Auto
import mime_types from "./mime_types.js";
import options from "./options.js";
import { t } from "./i18n.js";
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
import { isShare } from "./utils.js";
import { MimeType } from "@triliumnext/commons";
import { copyText } from "./clipboard.js";
let highlightingLoaded = false;
@ -16,6 +14,9 @@ let highlightingLoaded = false;
*/
export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
const syntaxHighlightingEnabled = isSyntaxHighlightEnabled();
if (syntaxHighlightingEnabled) {
await ensureMimeTypesForHighlighting();
}
const codeBlocks = $container.find("pre code");
for (const codeBlock of codeBlocks) {
@ -36,13 +37,7 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
const $copyButton = $("<button>")
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
.attr("title", t("code_block.copy_title"))
.on("click", () => {
if (!isShare) {
copyTextWithToast($codeBlock.text());
} else {
copyText($codeBlock.text());
}
});
.on("click", () => copyText($codeBlock.text()));
$codeBlock.parent().append($copyButton);
}
@ -54,11 +49,11 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
const text = $codeBlock.text();
let highlightedText: HighlightResult | AutoHighlightResult | null = null;
if (normalizedMimeType === mime_types.MIME_TYPE_AUTO && !isShare) {
if (normalizedMimeType === mime_types.MIME_TYPE_AUTO) {
await ensureMimeTypesForHighlighting();
highlightedText = highlightAuto(text);
} else if (normalizedMimeType) {
await ensureMimeTypesForHighlighting(normalizedMimeType);
await ensureMimeTypesForHighlighting();
highlightedText = highlight(text, { language: normalizedMimeType });
}
@ -67,7 +62,7 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
}
}
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
export async function ensureMimeTypesForHighlighting() {
if (highlightingLoaded) {
return;
}
@ -77,20 +72,7 @@ export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
loadHighlightingTheme(currentThemeName);
// Load mime types.
let mimeTypes: MimeType[];
if (mimeTypeHint) {
mimeTypes = [
{
title: mimeTypeHint,
enabled: true,
mime: mimeTypeHint.replace("-", "/")
}
]
} else {
mimeTypes = mime_types.getMimeTypes();
}
const mimeTypes = mime_types.getMimeTypes();
await ensureMimeTypes(mimeTypes);
highlightingLoaded = true;
@ -114,12 +96,8 @@ export function loadHighlightingTheme(themeName: string) {
* @returns whether syntax highlighting should be enabled for code blocks.
*/
export function isSyntaxHighlightEnabled() {
if (!isShare) {
const theme = options.get("codeBlockTheme");
return !!theme && theme !== "none";
} else {
return true;
}
const theme = options.get("codeBlockTheme");
return !!theme && theme !== "none";
}
/**

@ -78,7 +78,13 @@ function showMessage(message: string, delay = 2000) {
});
}
export function showError(message: string, delay = 10000) {
function showAndLogError(message: string, delay = 10000) {
showError(message, delay);
ws.logError(message);
}
function showError(message: string, delay = 10000) {
console.log(utils.now(), "error: ", message);
toast({
@ -102,10 +108,18 @@ function showErrorTitleAndMessage(title: string, message: string, delay = 10000)
});
}
function throwError(message: string) {
ws.logError(message);
throw new Error(message);
}
export default {
showMessage,
showError,
showErrorTitleAndMessage,
showAndLogError,
throwError,
showPersistent,
closePersistent
};

@ -1,10 +1,9 @@
import dayjs from "dayjs";
import { Modal } from "bootstrap";
import type { ViewScope } from "./link.js";
const SVG_MIME = "image/svg+xml";
export const isShare = !window.glob;
function reloadFrontendApp(reason?: string) {
if (reason) {
logInfo(`Frontend app reload: ${reason}`);
@ -125,12 +124,8 @@ function formatDateISO(date: Date) {
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
}
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
if (userSuppliedFormat?.trim()) {
return dayjs(date).format(userSuppliedFormat);
} else {
return `${formatDate(date)} ${formatTime(date)}`;
}
function formatDateTime(date: Date) {
return `${formatDate(date)} ${formatTime(date)}`;
}
function localNowDateTime() {
@ -274,6 +269,71 @@ function getMimeTypeClass(mime: string) {
return `mime-${mime.toLowerCase().replace(/[\W_]+/g, "-")}`;
}
function closeActiveDialog() {
if (glob.activeDialog) {
Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
glob.activeDialog = null;
}
}
let $lastFocusedElement: JQuery<HTMLElement> | null;
// perhaps there should be saved focused element per tab?
function saveFocusedElement() {
$lastFocusedElement = $(":focus");
}
function focusSavedElement() {
if (!$lastFocusedElement) {
return;
}
if ($lastFocusedElement.hasClass("ck")) {
// must handle CKEditor separately because of this bug: https://github.com/ckeditor/ckeditor5/issues/607
// the bug manifests itself in resetting the cursor position to the first character - jumping above
const editor = $lastFocusedElement.closest(".ck-editor__editable").prop("ckeditorInstance");
if (editor) {
editor.editing.view.focus();
} else {
console.log("Could not find CKEditor instance to focus last element");
}
} else {
$lastFocusedElement.focus();
}
$lastFocusedElement = null;
}
async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
if (closeActDialog) {
closeActiveDialog();
glob.activeDialog = $dialog;
}
saveFocusedElement();
Modal.getOrCreateInstance($dialog[0]).show();
$dialog.on("hidden.bs.modal", () => {
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
if (!glob.activeDialog || glob.activeDialog === $dialog) {
focusSavedElement();
}
});
// TODO: Fix once keyboard_actions is ported.
// @ts-ignore
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
keyboardActionsService.updateDisplayedShortcuts($dialog);
return $dialog;
}
function isHtmlEmpty(html: string) {
if (!html) {
return true;
@ -759,6 +819,10 @@ export default {
setCookie,
getNoteTypeClass,
getMimeTypeClass,
closeActiveDialog,
openDialog,
saveFocusedElement,
focusSavedElement,
isHtmlEmpty,
clearBrowserCache,
copySelectionToClipboard,

@ -17,7 +17,7 @@ let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastPingTs: number;
let frontendUpdateDataQueue: EntityChange[] = [];
export function logError(message: string) {
function logError(message: string) {
console.error(utils.now(), message); // needs to be separate from .trace()
if (ws && ws.readyState === 1) {
@ -301,12 +301,6 @@ setTimeout(() => {
setInterval(sendPing, 1000);
}, 0);
export function throwError(message: string) {
logError(message);
throw new Error(message);
}
export default {
logError,
subscribeToMessages,

@ -1,33 +1,5 @@
import "normalize.css";
import "boxicons/css/boxicons.min.css";
import "@triliumnext/ckeditor5/content.css";
import "@triliumnext/share-theme/styles/index.css";
import "@triliumnext/share-theme/scripts/index.js";
async function ensureJQuery() {
const $ = (await import("jquery")).default;
(window as any).$ = $;
}
async function applyMath() {
const anyMathBlock = document.querySelector("#content .math-tex");
if (!anyMathBlock) {
return;
}
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
renderMathInElement(document.getElementById("content"));
}
async function formatCodeBlocks() {
const anyCodeBlock = document.querySelector("#content pre");
if (!anyCodeBlock) {
return;
}
await ensureJQuery();
const { formatCodeBlocks } = await import("./services/syntax_highlight.js");
await formatCodeBlocks($("#content"));
}
/**
* Fetch note with given ID from backend
@ -47,9 +19,6 @@ async function fetchNote(noteId: string | null = null) {
document.addEventListener(
"DOMContentLoaded",
() => {
formatCodeBlocks();
applyMath();
const toggleMenuButton = document.getElementById("toggleMenuButton");
const layout = document.getElementById("layout");

@ -14,6 +14,33 @@ a {
flex-direction: row-reverse;
}
#menu {
padding: 25px;
flex-basis: 0;
flex-grow: 1;
overflow: auto;
}
#menu p {
margin: 0;
}
#menu > p {
font-weight: bold;
font-size: 110%;
}
#menu ul {
padding-left: 20px;
}
#main {
flex-basis: 0;
flex-grow: 3;
overflow: auto;
padding: 10px 20px 20px 20px;
}
#parentLink {
float: right;
margin-top: 20px;
@ -53,6 +80,48 @@ iframe.pdf-view {
cursor: pointer;
}
#childLinks.grid ul {
list-style-type: none;
display: flex;
flex-wrap: wrap;
padding: 0;
}
#childLinks.grid ul li {
width: 180px;
height: 140px;
padding: 10px;
}
#childLinks.grid ul li a {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border: 1px solid #ddd;
border-radius: 5px;
justify-content: center;
align-content: center;
text-align: center;
font-size: large;
}
#childLinks.grid ul li a:hover {
background: #eee;
}
#childLinks.list ul {
list-style-type: none;
display: inline-flex;
flex-wrap: wrap;
padding: 0;
margin-top: 5px;
}
#childLinks.list ul li {
margin-right: 20px;
}
#noteClippedFrom {
padding: 10px 0 10px 0;
margin: 20px 0 20px 0;

@ -24,8 +24,7 @@
--bs-body-font-family: var(--main-font-family) !important;
--bs-body-font-weight: var(--main-font-weight) !important;
--bs-body-color: var(--main-text-color) !important;
--bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px;
--bs-body-bg: var(--main-background-color) !important;
}
.table {
@ -327,7 +326,6 @@ button kbd {
user-select: none;
-webkit-user-select: none;
--bs-dropdown-zindex: 999;
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
}
body.desktop .dropdown-menu {
@ -392,7 +390,7 @@ body.desktop .dropdown-menu {
}
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
body #context-menu-container .dropdown-item > span {
body.desktop #context-menu-container .dropdown-item > span {
display: flex;
align-items: center;
}
@ -440,11 +438,10 @@ body #context-menu-container .dropdown-item > span {
border-radius: 6px;
overflow: hidden;
margin: 4px;
font-size: var(--monospace-font-size);
}
.cm-scroller {
font-family: var(--monospace-font-family) !important;
body .cm-editor {
font-size: var(--monospace-font-size);
}
body .cm-editor .cm-gutters {
@ -1275,29 +1272,6 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
white-space: normal !important;
}
/* Slash commands */
.ck.ck-slash-command-button {
padding: 0.5em 1em !important;
}
.ck.ck-slash-command-button__text-part,
.ck.ck-template-form__text-part {
margin-left: 0.5em;
line-height: 1.2em !important;
}
.ck.ck-slash-command-button__text-part > span,
.ck.ck-template-form__text-part > span {
line-height: inherit !important;
}
.ck.ck-slash-command-button__text-part .ck.ck-slash-command-button__description,
.ck.ck-template-form__text-part .ck-template-form__description {
display: block;
opacity: 0.8;
}
.area-expander {
display: flex;
flex-direction: row;
@ -1813,9 +1787,7 @@ body.zen #left-pane,
body.zen #right-pane,
body.zen .tab-row-container,
body.zen .tab-row-widget,
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
body.zen .ribbon-container,
body.zen .note-icon-widget,
body.zen .title-row .button-widget,
body.zen .floating-buttons-children > *:not(.bx-edit-alt) {

@ -70,7 +70,6 @@
--scrollbar-border-color: #666;
--scrollbar-background-color: #333;
--selection-background-color: #3399FF70;
--tooltip-background-color: #333;
--link-color: lightskyblue;

@ -74,7 +74,6 @@ html {
--scrollbar-border-color: #ddd;
--scrollbar-background-color: #ddd;
--selection-background-color: #3399FF70;
--tooltip-background-color: #f8f8f8;
--link-color: blue;

@ -395,20 +395,4 @@ div.tn-tool-dialog {
padding-right: 12px;
font-weight: normal;
white-space: nowrap;
}
/*
* NOTE TYPE CHOOSER DIALOG
*/
.note-type-chooser-dialog div.note-type-dropdown {
/* Disable the active item highlighting since there is no use for it here */
--active-item-text-color: initial;
--active-item-background-color: initial;
font-size: unset;
}
.note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.bx {
margin-right: .25em;
}

@ -267,7 +267,7 @@ input::selection,
}
.input-group button:focus-visible,
.input-group a:focus-visible:not(.dropdown-item) {
.input-group a:focus-visible {
box-shadow: unset;
outline: transparent;
border: transparent;
@ -349,7 +349,7 @@ select:hover,
select.form-select:hover,
select.form-control:hover,
.select-button.dropdown-toggle.btn:hover {
background: var(--input-hover-background) var(--dropdown-arrow,);
background: var(--input-hover-background) var(--dropdown-arrow);
color: var(--input-hover-color);
}

@ -201,11 +201,6 @@
color: var(--menu-item-icon-color);
}
/* Slash commands */
.ck.ck-slash-command-button__text-part .ck.ck-button__label {
font-weight: bold;
}
/* Separator */
:root .ck .ck-list__separator {
margin: .5em 0;

@ -142,12 +142,6 @@ div.note-detail-empty {
border: unset;
}
/* NOTE ATTACHMENTS */
.attachment-list div.links-wrapper {
font-size: unset;
}
/*
* OPTIONS PAGES
*/

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