mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 10:55:55 +01:00
Compare commits
396 Commits
v0.90.7-be
...
v0.90.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb36b33694 | ||
|
|
e982696ef4 | ||
|
|
c294469f12 | ||
|
|
2d8fb4eff5 | ||
|
|
79b31bda76 | ||
|
|
c30a4373d9 | ||
|
|
12065902d2 | ||
|
|
aa01161a40 | ||
|
|
3cfc2ac768 | ||
|
|
79a906e695 | ||
|
|
2ffd0de736 | ||
|
|
ea8e98b8ef | ||
|
|
84b555de3c | ||
|
|
a037f95ff1 | ||
|
|
50d9f382a1 | ||
|
|
ed90e0f7a9 | ||
|
|
8c7cba4f33 | ||
|
|
128c4d45df | ||
|
|
8658f9e6d3 | ||
|
|
312c3ed6ad | ||
|
|
bd2bcb7c97 | ||
|
|
ae85bffd08 | ||
|
|
84f63d5cf7 | ||
|
|
0f75319677 | ||
|
|
050eb08b1a | ||
|
|
cdf8490651 | ||
|
|
8f05b24694 | ||
|
|
95f80efaeb | ||
|
|
281b81ee60 | ||
|
|
85b507938b | ||
|
|
0c02a3bae9 | ||
|
|
ac9f344130 | ||
|
|
2b432dd4f7 | ||
|
|
3d27a60897 | ||
|
|
3792761ffc | ||
|
|
322d261df7 | ||
|
|
1fb58f3e87 | ||
|
|
c51adbc449 | ||
|
|
4179f9c155 | ||
|
|
35faba2c2f | ||
|
|
dc893a438e | ||
|
|
a83e68fbb6 | ||
|
|
a677f4381d | ||
|
|
98dfeee188 | ||
|
|
dc7bb6d7eb | ||
|
|
855f936dbf | ||
|
|
48e7bab81b | ||
|
|
694f896623 | ||
|
|
5df287db23 | ||
|
|
8868a4eae1 | ||
|
|
0da1bee02c | ||
|
|
779218849a | ||
|
|
4999809e3a | ||
|
|
fd5412b715 | ||
|
|
4bcca01ff3 | ||
|
|
9b5526c99f | ||
|
|
80ce2f5dbd | ||
|
|
f629d48028 | ||
|
|
da95e15b01 | ||
|
|
70be4cd1c2 | ||
|
|
349b1c1d78 | ||
|
|
e94942d665 | ||
|
|
4418ad986e | ||
|
|
c962a94e29 | ||
|
|
7f3d5f1e70 | ||
|
|
32a4a9c072 | ||
|
|
22b768e5e8 | ||
|
|
970c3bd7ad | ||
|
|
75941de449 | ||
|
|
5d6a42b987 | ||
|
|
d8e50a2ab8 | ||
|
|
5b050410cb | ||
|
|
f7b1c3fee3 | ||
|
|
0ba883ce2f | ||
|
|
2b0d68368c | ||
|
|
180993ead9 | ||
|
|
f4ed98ebda | ||
|
|
a4c0ae06db | ||
|
|
7a8d7f074c | ||
|
|
b4072ec8a5 | ||
|
|
a50e3935b5 | ||
|
|
3034ca217d | ||
|
|
002839176e | ||
|
|
e091ef64dd | ||
|
|
46823d28e8 | ||
|
|
616d7117db | ||
|
|
4e10071649 | ||
|
|
3ff75b14e9 | ||
|
|
82e7814569 | ||
|
|
66e8cc40eb | ||
|
|
2260dcefe5 | ||
|
|
40c9ef69e7 | ||
|
|
a8b87a1507 | ||
|
|
1df1637257 | ||
|
|
616cb87d4e | ||
|
|
7f0d675ab8 | ||
|
|
c907b288bd | ||
|
|
d9ab5d71aa | ||
|
|
8731b8a65b | ||
|
|
de4f06d9be | ||
|
|
9485067749 | ||
|
|
cd35706147 | ||
|
|
3b94aee7b7 | ||
|
|
06e30674fe | ||
|
|
f7b1e87bc4 | ||
|
|
2c252a9984 | ||
|
|
db79f231a0 | ||
|
|
693bcfb587 | ||
|
|
38d32813d2 | ||
|
|
a0c6d695b0 | ||
|
|
d63baa1503 | ||
|
|
6734d765c9 | ||
|
|
470594b1c7 | ||
|
|
782d34566d | ||
|
|
1b2a772612 | ||
|
|
497c24ee1e | ||
|
|
8893e9d4d5 | ||
|
|
2d9376a05c | ||
|
|
ce40c74e83 | ||
|
|
0aef04cea1 | ||
|
|
12b71961ae | ||
|
|
46218d6ab4 | ||
|
|
1d2366fa06 | ||
|
|
0acba0eac4 | ||
|
|
48d53e276e | ||
|
|
47baa02bca | ||
|
|
bc35c3c641 | ||
|
|
790b87f23f | ||
|
|
48ba15ad88 | ||
|
|
cda28cfd65 | ||
|
|
7ffe145481 | ||
|
|
ac2bca790b | ||
|
|
774966e640 | ||
|
|
81310d33b0 | ||
|
|
34e6430977 | ||
|
|
15b4eacdca | ||
|
|
7c342aed9e | ||
|
|
8c69d47aed | ||
|
|
f88d3220b5 | ||
|
|
70a98a3d33 | ||
|
|
6e0a10cf2c | ||
|
|
c421e75f55 | ||
|
|
89420eafa3 | ||
|
|
7a70fc14b3 | ||
|
|
d2008e7e5f | ||
|
|
745c9846a6 | ||
|
|
3972bb2ecf | ||
|
|
06262adf91 | ||
|
|
5771060b57 | ||
|
|
6a11f9c073 | ||
|
|
85ee7def84 | ||
|
|
b88f0e0109 | ||
|
|
787aa6f5a6 | ||
|
|
4f39188198 | ||
|
|
dd6e762dab | ||
|
|
48bc9204ac | ||
|
|
918f425e1f | ||
|
|
821af8dc11 | ||
|
|
44734435ea | ||
|
|
01c53b6d9f | ||
|
|
9a5de0d4c8 | ||
|
|
5116bddc5f | ||
|
|
92aa671ec7 | ||
|
|
1f4d09f6f0 | ||
|
|
29e83b97e6 | ||
|
|
18de0857b3 | ||
|
|
2048a30aa5 | ||
|
|
78017e4d36 | ||
|
|
35fe5845a3 | ||
|
|
1261bdbb29 | ||
|
|
91fa1a6cb1 | ||
|
|
1816fcd3ac | ||
|
|
d13044b972 | ||
|
|
f5205fdd30 | ||
|
|
930b8e0ce2 | ||
|
|
b5988ba7c2 | ||
|
|
75e2ceed5d | ||
|
|
d2ee3738a2 | ||
|
|
8a548f6589 | ||
|
|
0859a955b1 | ||
|
|
a02146df17 | ||
|
|
a6385557b5 | ||
|
|
00aebfcdf0 | ||
|
|
c6b3ace807 | ||
|
|
6799544950 | ||
|
|
da1cf4d6ed | ||
|
|
21cfb64f83 | ||
|
|
dd7c2084fa | ||
|
|
4f5d874028 | ||
|
|
80e6276d31 | ||
|
|
0192060ad2 | ||
|
|
e41ff54c0d | ||
|
|
bdece7216f | ||
|
|
611fb90a52 | ||
|
|
75e554d86b | ||
|
|
0db1a63cef | ||
|
|
4ffc6f716c | ||
|
|
fa3200ba8f | ||
|
|
bff9bedc44 | ||
|
|
f8777b0de1 | ||
|
|
48e6c1a33d | ||
|
|
4c43ac5bdd | ||
|
|
45ccc7562e | ||
|
|
e72eb5f27c | ||
|
|
d1404492a7 | ||
|
|
238c9c6f0d | ||
|
|
443f02a78e | ||
|
|
5fbd052138 | ||
|
|
bc84a71929 | ||
|
|
a514a51fff | ||
|
|
24022834e2 | ||
|
|
9fdc84d91f | ||
|
|
9c27672794 | ||
|
|
f37fa3723b | ||
|
|
b14065d442 | ||
|
|
1554e25283 | ||
|
|
4e945583a1 | ||
|
|
92c588dc98 | ||
|
|
5c66e3fd04 | ||
|
|
e508313f21 | ||
|
|
df3f51d1f3 | ||
|
|
0a6815e448 | ||
|
|
293db6962e | ||
|
|
eb05c5b919 | ||
|
|
bbaed45f6b | ||
|
|
aa7d7b3afd | ||
|
|
fc4797d04f | ||
|
|
c2baa4b752 | ||
|
|
faeefc75ba | ||
|
|
d0904c1051 | ||
|
|
11a82e62f1 | ||
|
|
7b24f7e332 | ||
|
|
7f17f93767 | ||
|
|
cdd5a17fce | ||
|
|
dbca50d9b0 | ||
|
|
57a86c75d8 | ||
|
|
9e3c1b46cd | ||
|
|
00209ec77a | ||
|
|
dfa4f3cd84 | ||
|
|
3af29a78dc | ||
|
|
4d783f1879 | ||
|
|
c3e10b2b76 | ||
|
|
f57ab4b9f0 | ||
|
|
a690155d7e | ||
|
|
cc0b3db424 | ||
|
|
ae60f8c842 | ||
|
|
90dffdc6ed | ||
|
|
ac13291744 | ||
|
|
bbc038f254 | ||
|
|
f8df3a6933 | ||
|
|
b10e2d9ec4 | ||
|
|
2387bbd17f | ||
|
|
f13d88c3c0 | ||
|
|
2459bbf341 | ||
|
|
60426ea487 | ||
|
|
b112cb609f | ||
|
|
b9ebc66122 | ||
|
|
2f4ed92346 | ||
|
|
d3d001d8ea | ||
|
|
70cee7dbf6 | ||
|
|
36fde2b03d | ||
|
|
88d8f57697 | ||
|
|
b7e254975f | ||
|
|
97b2ba2da1 | ||
|
|
bda8173932 | ||
|
|
48f9f072b4 | ||
|
|
9c55203ea0 | ||
|
|
dbb5e0e971 | ||
|
|
b8eb09b46b | ||
|
|
5682b2d819 | ||
|
|
5109c07e9c | ||
|
|
b8569ea243 | ||
|
|
52bc28def7 | ||
|
|
e65d4cdfbf | ||
|
|
96b9042559 | ||
|
|
e68d070320 | ||
|
|
ef5f2c680b | ||
|
|
6717b1b4ae | ||
|
|
41e3163595 | ||
|
|
514653fb50 | ||
|
|
e843f1adc1 | ||
|
|
83f5b47c99 | ||
|
|
2fdff29067 | ||
|
|
0d270cbeb6 | ||
|
|
f947a039b9 | ||
|
|
d2235a185b | ||
|
|
87bc142552 | ||
|
|
1a25f60264 | ||
|
|
fe4dbae079 | ||
|
|
e1ae014b74 | ||
|
|
7952a5a81e | ||
|
|
60b6f7df89 | ||
|
|
7354fb5b4a | ||
|
|
1fb0b74f76 | ||
|
|
9e3b915612 | ||
|
|
7505db220e | ||
|
|
a3932376f3 | ||
|
|
3a609d54ab | ||
|
|
c4bd4eb440 | ||
|
|
e931df721d | ||
|
|
1e9324c303 | ||
|
|
6c4513fb2e | ||
|
|
c7e1362105 | ||
|
|
acf37f9327 | ||
|
|
f80cf0aa02 | ||
|
|
6078620bf1 | ||
|
|
579b3f4ca0 | ||
|
|
bf28005f46 | ||
|
|
c81b847b61 | ||
|
|
88cd2ac25c | ||
|
|
e3e6f56a88 | ||
|
|
0768a2a0a3 | ||
|
|
84d216da54 | ||
|
|
391f518c01 | ||
|
|
2324c9a13b | ||
|
|
6799c44e22 | ||
|
|
f0052d56b7 | ||
|
|
53822fd47f | ||
|
|
b02c4b54e5 | ||
|
|
27f07ee604 | ||
|
|
03a23d15f9 | ||
|
|
70d55097ee | ||
|
|
560467bdba | ||
|
|
cb4fe4481f | ||
|
|
eee088316d | ||
|
|
81ca0a3776 | ||
|
|
48b0af1bba | ||
|
|
43ef452d44 | ||
|
|
70ebf1a08f | ||
|
|
9f6f0f5d60 | ||
|
|
af67362ad6 | ||
|
|
77550f3087 | ||
|
|
5813282248 | ||
|
|
e77b223508 | ||
|
|
7aafdce629 | ||
|
|
a2f0cb394a | ||
|
|
e8d1518965 | ||
|
|
8b333b32af | ||
|
|
cda369ed4d | ||
|
|
b5bc93d794 | ||
|
|
b96047e962 | ||
|
|
31ccbb0d23 | ||
|
|
cb9403535d | ||
|
|
b5ee90a1d2 | ||
|
|
9ed7eb977e | ||
|
|
8cc487da7c | ||
|
|
ae593ea363 | ||
|
|
26e4decaec | ||
|
|
28f6712a4f | ||
|
|
93efce4023 | ||
|
|
689b3a3079 | ||
|
|
4ad725842e | ||
|
|
d4956ad3a2 | ||
|
|
c7b7c68a05 | ||
|
|
cab1d7d353 | ||
|
|
7957c6d34e | ||
|
|
c18c972a57 | ||
|
|
29a700f731 | ||
|
|
815eab26f6 | ||
|
|
103da23b5a | ||
|
|
ba1d82bc0a | ||
|
|
21f8a29761 | ||
|
|
f38870b27d | ||
|
|
56a6d27240 | ||
|
|
38e5ef2c7d | ||
|
|
e29d600517 | ||
|
|
42605fbbad | ||
|
|
11ca427a28 | ||
|
|
28d8088763 | ||
|
|
664c4789c0 | ||
|
|
7c5667b457 | ||
|
|
0afd22e196 | ||
|
|
b3abee71b7 | ||
|
|
9bd5596b2a | ||
|
|
e0e3c15e6e | ||
|
|
31396264fa | ||
|
|
b1aada22b5 | ||
|
|
d7eaf72a6d | ||
|
|
59df442676 | ||
|
|
9770db7f3c | ||
|
|
8c36cea71b | ||
|
|
b03f40f1f9 | ||
|
|
00dba7bef4 | ||
|
|
4186f3d136 | ||
|
|
529502524d | ||
|
|
7c518e9512 | ||
|
|
5e2d1bc124 | ||
|
|
7dfe6f276e | ||
|
|
858db68d66 | ||
|
|
b72f46f108 | ||
|
|
83dbe0539e | ||
|
|
87e0cf55f1 | ||
|
|
8315d5c778 | ||
|
|
61bd7dca18 | ||
|
|
7f338044b0 | ||
|
|
ea3f47b8fa |
12
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
12
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -3,6 +3,12 @@ description: Report a bug
|
||||
title: "(Bug report) "
|
||||
labels: "Type: Bug"
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the bug and any additional information.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: TriliumNext Version
|
||||
@@ -38,12 +44,6 @@ body:
|
||||
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the bug and any additional information.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Error logs
|
||||
|
||||
70
.github/workflows/dev.yml
vendored
70
.github/workflows/dev.yml
vendored
@@ -9,6 +9,12 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/notes
|
||||
TEST_TAG: ${{ github.repository_owner }}/notes:test
|
||||
|
||||
jobs:
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -30,4 +36,66 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
test_docker:
|
||||
name: Check Docker build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: Dockerfile.alpine
|
||||
- dockerfile: Dockerfile
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set IMAGE_NAME to lowercase
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Run the TypeScript build
|
||||
run: npx tsc
|
||||
|
||||
- name: Create server-package.json
|
||||
run: cat package.json | grep -v electron > server-package.json
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
load: true
|
||||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Validate container run output
|
||||
run: |
|
||||
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --name trilium_local ${{ env.TEST_TAG }})
|
||||
echo "Container ID: $CONTAINER_ID"
|
||||
|
||||
- name: Wait for the healthchecks to pass
|
||||
uses: stringbean/docker-healthcheck-action@v1
|
||||
with:
|
||||
container: trilium_local
|
||||
wait-time: 50
|
||||
require-status: running
|
||||
require-healthy: true
|
||||
|
||||
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
|
||||
- name: Print entire log
|
||||
if: always()
|
||||
run: |
|
||||
journalctl -u docker CONTAINER_NAME=trilium_local --no-pager
|
||||
|
||||
32
.github/workflows/main-docker.yml
vendored
32
.github/workflows/main-docker.yml
vendored
@@ -129,6 +129,8 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -213,7 +215,9 @@ jobs:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -242,6 +246,32 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# If the ref is a tag, also tag the image as stable as this is part of a 'release'
|
||||
# and only go in the `if` if there is NOT a `-` in the tag's name, due to tagging of `-alpha`, `-beta`, etc...
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* && ! "${REF_NAME}" =~ - ]]; then
|
||||
# First create stable tags
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
|
||||
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
# Small delay to ensure stable tag is fully propagated
|
||||
sleep 5
|
||||
|
||||
# Now update latest tags
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
|
||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Update build info
|
||||
run: npm run update-build-info
|
||||
run: npm run update-build-info
|
||||
- name: Run electron-forge
|
||||
run: npm run make-electron -- --arch=${{ matrix.arch }}
|
||||
- name: Prepare artifacts (Unix)
|
||||
|
||||
3
.github/workflows/nightly.yml
vendored
3
.github/workflows/nightly.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Update build info
|
||||
run: npm run update-build-info
|
||||
- name: Update nightly version
|
||||
run: npm run ci-update-nightly-version
|
||||
- name: Run electron-forge
|
||||
run: npm run make-electron -- --arch=${{ matrix.arch }}
|
||||
- name: Prepare artifacts (Unix)
|
||||
@@ -103,6 +105,7 @@ jobs:
|
||||
- name: Run Linux server build (x86_64)
|
||||
run: |
|
||||
npm run update-build-info
|
||||
npm run ci-update-nightly-version
|
||||
./bin/build-server.sh
|
||||
- name: Prepare artifacts
|
||||
if: runner.os != 'windows'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ build/
|
||||
src/public/app-dist/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
po-*/
|
||||
|
||||
*.db
|
||||
!integration-tests/db/document.db
|
||||
|
||||
13
.vscode/i18n-ally-reviews.yml
vendored
13
.vscode/i18n-ally-reviews.yml
vendored
@@ -5,3 +5,16 @@ reviews:
|
||||
description: >-
|
||||
Describes the shortcut which triggers a search within the current
|
||||
page/note only
|
||||
add_label.to_value:
|
||||
locales:
|
||||
fr:
|
||||
comments:
|
||||
- user:
|
||||
name: Potjoe-97
|
||||
email: giann@LAPTOPT490-GF
|
||||
id: QXec0JUoxfGmMlpch-B1S
|
||||
comment: ''
|
||||
suggestion: vers la valeur
|
||||
type: request_change
|
||||
time: '2024-10-15T16:57:06.188Z'
|
||||
resolved: true
|
||||
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# !!! Don't try to build this Dockerfile directly, run it through bin/build-docker.sh script !!!
|
||||
FROM node:20.15.1-bullseye-slim
|
||||
# Build stage
|
||||
FROM node:20.15.1-bullseye-slim AS builder
|
||||
|
||||
# Configure system dependencies
|
||||
# Configure build dependencies in a single layer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
autoconf \
|
||||
automake \
|
||||
@@ -12,49 +12,52 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nasm \
|
||||
libpng-dev \
|
||||
python3 \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Bundle app source
|
||||
# Copy only necessary files for build
|
||||
COPY . .
|
||||
COPY server-package.json package.json
|
||||
|
||||
# Copy TypeScript build artifacts into the original directory structure.
|
||||
# Copy the healthcheck
|
||||
# Build and cleanup in a single layer
|
||||
RUN cp -R build/src/* src/. && \
|
||||
cp build/docker_healthcheck.js . && \
|
||||
rm -r build && \
|
||||
rm docker_healthcheck.ts
|
||||
|
||||
# Install app dependencies
|
||||
RUN apt-get purge -y --auto-remove \
|
||||
autoconf \
|
||||
automake \
|
||||
g++ \
|
||||
gcc \
|
||||
libtool \
|
||||
make \
|
||||
nasm \
|
||||
libpng-dev \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install && \
|
||||
rm docker_healthcheck.ts && \
|
||||
npm install && \
|
||||
npm run webpack && \
|
||||
npm prune --omit=dev
|
||||
RUN cp src/public/app/share.js src/public/app-dist/. && \
|
||||
npm prune --omit=dev && \
|
||||
npm cache clean --force && \
|
||||
cp src/public/app/share.js src/public/app-dist/. && \
|
||||
cp -r src/public/app/doc_notes src/public/app-dist/. && \
|
||||
rm -rf src/public/app && rm src/services/asset_path.ts
|
||||
rm -rf src/public/app && \
|
||||
rm src/services/asset_path.ts
|
||||
|
||||
# Some setup tools need to be kept
|
||||
# Runtime stage
|
||||
FROM node:20.15.1-bullseye-slim
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /var/cache/apt/*
|
||||
|
||||
# Start the application
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files from builder
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/src ./src
|
||||
COPY --from=builder /usr/src/app/db ./db
|
||||
COPY --from=builder /usr/src/app/docker_healthcheck.js .
|
||||
COPY --from=builder /usr/src/app/start-docker.sh .
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/config-sample.ini .
|
||||
COPY --from=builder /usr/src/app/images ./images
|
||||
COPY --from=builder /usr/src/app/translations ./translations
|
||||
COPY --from=builder /usr/src/app/libraries ./libraries
|
||||
|
||||
# Configure container
|
||||
EXPOSE 8080
|
||||
CMD [ "./start-docker.sh" ]
|
||||
|
||||
HEALTHCHECK --start-period=10s CMD exec gosu node node docker_healthcheck.js
|
||||
@@ -1,7 +1,7 @@
|
||||
# !!! Don't try to build this Dockerfile directly, run it through bin/build-docker.sh script !!!
|
||||
FROM node:20.15.1-alpine
|
||||
# Build stage
|
||||
FROM node:20.15.1-alpine AS builder
|
||||
|
||||
# Configure system dependencies
|
||||
# Configure build dependencies
|
||||
RUN apk add --no-cache --virtual .build-dependencies \
|
||||
autoconf \
|
||||
automake \
|
||||
@@ -11,43 +11,52 @@ RUN apk add --no-cache --virtual .build-dependencies \
|
||||
make \
|
||||
nasm \
|
||||
libpng-dev \
|
||||
python3
|
||||
python3
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Bundle app source
|
||||
# Copy only necessary files for build
|
||||
COPY . .
|
||||
|
||||
COPY server-package.json package.json
|
||||
|
||||
# Copy TypeScript build artifacts into the original directory structure.
|
||||
# Copy the healthcheck
|
||||
# Build and cleanup in a single layer
|
||||
RUN cp -R build/src/* src/. && \
|
||||
cp build/docker_healthcheck.js . && \
|
||||
rm -r build && \
|
||||
rm docker_healthcheck.ts
|
||||
|
||||
# Install app dependencies
|
||||
RUN set -x && \
|
||||
rm docker_healthcheck.ts && \
|
||||
npm install && \
|
||||
apk del .build-dependencies && \
|
||||
npm run webpack && \
|
||||
npm prune --omit=dev && \
|
||||
npm cache clean --force && \
|
||||
cp src/public/app/share.js src/public/app-dist/. && \
|
||||
cp -r src/public/app/doc_notes src/public/app-dist/. && \
|
||||
rm -rf src/public/app && \
|
||||
rm src/services/asset_path.ts
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20.15.1-alpine
|
||||
|
||||
# Some setup tools need to be kept
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
# Add application user and setup proper volume permissions
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files from builder
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/src ./src
|
||||
COPY --from=builder /usr/src/app/db ./db
|
||||
COPY --from=builder /usr/src/app/docker_healthcheck.js .
|
||||
COPY --from=builder /usr/src/app/start-docker.sh .
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/config-sample.ini .
|
||||
COPY --from=builder /usr/src/app/images ./images
|
||||
COPY --from=builder /usr/src/app/translations ./translations
|
||||
COPY --from=builder /usr/src/app/libraries ./libraries
|
||||
|
||||
# Add application user
|
||||
RUN adduser -s /bin/false node; exit 0
|
||||
|
||||
# Start the application
|
||||
# Configure container
|
||||
EXPOSE 8080
|
||||
CMD [ "./start-docker.sh" ]
|
||||
|
||||
HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js
|
||||
HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js
|
||||
14
README.md
14
README.md
@@ -1,5 +1,7 @@
|
||||
# TriliumNext Notes
|
||||
|
||||
 
|
||||
|
||||
[English](./README.md) | [Chinese](./README-ZH_CN.md) | [Russian](./README.ru.md) | [Japanese](./README.ja.md) | [Italian](./README.it.md) | [Spanish](./README.es.md)
|
||||
|
||||
TriliumNext Notes is an open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
@@ -16,6 +18,8 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Just upgrade your Trilium instance to the latest version and [install TriliumNext/Notes as usual](#-installation)
|
||||
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented.
|
||||
|
||||
## 💬 Discuss with us
|
||||
|
||||
Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
|
||||
@@ -63,6 +67,16 @@ To use TriliumNext on your desktop machine (Linux, MacOS, and Windows) you have
|
||||
* Currently only the latest versions of Chrome & Firefox are supported (and tested).
|
||||
* (Coming Soon) TriliumNext will also be provided as a Flatpak
|
||||
|
||||
#### MacOS
|
||||
Currently when running TriliumNext/Notes on MacOS, you may get the following error:
|
||||
> Apple could not verify "TriliumNext Notes" is free of malware and may harm your Mac or compromise your privacy.
|
||||
|
||||
You will need to run the command on your shell to resolve the error (documented [here](https://github.com/TriliumNext/Notes/issues/329#issuecomment-2287164137)):
|
||||
|
||||
```bash
|
||||
xattr -c "/path/to/Trilium Next.app"
|
||||
```
|
||||
|
||||
### Mobile
|
||||
|
||||
To use TriliumNext on a mobile device:
|
||||
|
||||
@@ -47,6 +47,15 @@ const copy = async () => {
|
||||
await fs.copy(dir, path.join(DEST_DIR_SRC, path.basename(dir)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories to be copied relative to the project root into <resource_dir>/src/public/app-dist.
|
||||
*/
|
||||
const publicDirsToCopy = [ "./src/public/app/doc_notes" ];
|
||||
const PUBLIC_DIR = path.join(DEST_DIR, "src", "public", "app-dist");
|
||||
for (const dir of publicDirsToCopy) {
|
||||
await fs.copy(dir, path.join(PUBLIC_DIR, path.basename(dir)));
|
||||
}
|
||||
|
||||
const nodeModulesFile = [
|
||||
"node_modules/react/umd/react.production.min.js",
|
||||
"node_modules/react/umd/react.development.js",
|
||||
@@ -55,6 +64,7 @@ const copy = async () => {
|
||||
"node_modules/katex/dist/katex.min.js",
|
||||
"node_modules/katex/dist/contrib/mhchem.min.js",
|
||||
"node_modules/katex/dist/contrib/auto-render.min.js",
|
||||
"node_modules/@highlightjs/cdn-assets/highlight.min.js"
|
||||
];
|
||||
|
||||
for (const file of nodeModulesFile) {
|
||||
@@ -89,7 +99,9 @@ const copy = async () => {
|
||||
"node_modules/codemirror/addon/",
|
||||
"node_modules/codemirror/mode/",
|
||||
"node_modules/codemirror/keymap/",
|
||||
"node_modules/mind-elixir/dist/"
|
||||
"node_modules/mind-elixir/dist/",
|
||||
"node_modules/@highlightjs/cdn-assets/languages",
|
||||
"node_modules/@highlightjs/cdn-assets/styles"
|
||||
];
|
||||
|
||||
for (const folder of nodeModulesFolder) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"src": "dist/trilium-linux-x64",
|
||||
"dest": "dist/",
|
||||
"compression": "xz",
|
||||
"name": "trilium",
|
||||
"productName": "Trilium Notes",
|
||||
"genericName": "Note taker",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"sections": "misc",
|
||||
"maintainer": "zadam.apps@gmail.com",
|
||||
"homepage": "https://github.com/zadam/trilium",
|
||||
"bin": "trilium",
|
||||
"icon": "dist/trilium-linux-x64/icon.png",
|
||||
"categories": [ "Office" ]
|
||||
}
|
||||
12
bin/electron-forge/desktop.ejs
Normal file
12
bin/electron-forge/desktop.ejs
Normal file
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
<% if (productName) { %>Name=<%= productName %>
|
||||
<% } %><% if (description) { %>Comment=<%= description %>
|
||||
<% } %><% if (genericName) { %>GenericName=<%= genericName %>
|
||||
<% } %><% if (name) { %>Exec=<%= name %> %U
|
||||
Icon=<%= name %>
|
||||
<% } %>Type=Application
|
||||
StartupNotify=true
|
||||
<% if (productName) { %>StartupWMClass=<%= productName %>
|
||||
<% } if (categories && categories.length) { %>Categories=<%= categories.join(';') %>;
|
||||
<% } %><% if (mimeType && mimeType.length) { %>MimeType=<%= mimeType.join(';') %>;
|
||||
<% } %>
|
||||
110
bin/translation.sh
Executable file
110
bin/translation.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
#
|
||||
# Create PO files to make easier the labor of translation.
|
||||
#
|
||||
# Info:
|
||||
# https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
|
||||
# https://docs.translatehouse.org/projects/translate-toolkit/en/latest/commands/json2po.html
|
||||
#
|
||||
# Dependencies:
|
||||
# jq
|
||||
# translate-toolkit
|
||||
# python-wcwidth
|
||||
#
|
||||
# Created by @hasecilu
|
||||
#
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
|
||||
number_of_keys() {
|
||||
[ -f "$1" ] && jq 'path(..) | select(length == 2) | .[1]' "$1" | wc -l || echo "0"
|
||||
}
|
||||
|
||||
stats() {
|
||||
# Print the number of existing strings on the JSON files for each locale
|
||||
s=$(number_of_keys "${paths[0]}/en/server.json")
|
||||
c=$(number_of_keys "${paths[1]}/en/translation.json")
|
||||
echo "| locale |server strings |client strings |"
|
||||
echo "|--------|---------------|---------------|"
|
||||
echo "| en | ${s} | ${c} |"
|
||||
for locale in "${locales[@]}"; do
|
||||
s=$(number_of_keys "${paths[0]}/${locale}/server.json")
|
||||
c=$(number_of_keys "${paths[1]}/${locale}/translation.json")
|
||||
n1=$(((8 - ${#locale}) / 2))
|
||||
n2=$((n1 == 1 ? n1 + 1 : n1))
|
||||
echo "|$(printf "%${n1}s")${locale}$(printf "%${n2}s")| ${s} | ${c} |"
|
||||
done
|
||||
}
|
||||
|
||||
update_1() {
|
||||
# Update PO files from English and localized JSON files as source
|
||||
# NOTE: if you want a new language you need to first create the JSON files
|
||||
# on their corresponding place with `{}` as content to avoid error on `json2po`
|
||||
local locales=("$@")
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
json2po -t "${path}/en" "${path}/${locale}" "${path}/po-${locale}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
update_2() {
|
||||
# Recover translation from PO files to localized JSON files
|
||||
local locales=("$@")
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
po2json -t "${path}/en" "${path}/po-${locale}" "${path}/${locale}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
help() {
|
||||
echo -e "\nDescription:"
|
||||
echo -e "\tCreate PO files to make easier the labor of translation"
|
||||
echo -e "\nUsage:"
|
||||
echo -e "\t./translation.sh [--stats] [--update1 <OPT_LOCALE>] [--update2 <OPT_LOCALE>]"
|
||||
echo -e "\nFlags:"
|
||||
echo -e " --clear\n\tClear all po-* directories"
|
||||
echo -e " --stats\n\tPrint the number of existing strings on the JSON files for each locale"
|
||||
echo -e " --update1 <LOCALE>\n\tUpdate PO files from English and localized JSON files as source"
|
||||
echo -e " --update2 <LOCALE>\n\tRecover translation from PO files to localized JSON files"
|
||||
}
|
||||
|
||||
# Main function ------------------------------------------------------------------------------------
|
||||
|
||||
# Get script directory to set file path relative to it
|
||||
file_path="$(
|
||||
cd -- "$(dirname "${0}")" >/dev/null 2>&1 || exit
|
||||
pwd -P
|
||||
)"
|
||||
paths=("${file_path}/../translations/" "${file_path}/../src/public/translations/")
|
||||
locales=(cn de es fr pt_br ro tw)
|
||||
|
||||
if [ $# -eq 1 ]; then
|
||||
if [ "$1" == "--clear" ]; then
|
||||
for path in "${paths[@]}"; do
|
||||
for locale in "${locales[@]}"; do
|
||||
[ -d "${path}/po-${locale}" ] && rm -r "${path}/po-${locale}"
|
||||
done
|
||||
done
|
||||
elif [ "$1" == "--stats" ]; then
|
||||
stats
|
||||
elif [ "$1" == "--update1" ]; then
|
||||
update_1 "${locales[@]}"
|
||||
elif [ "$1" == "--update2" ]; then
|
||||
update_2 "${locales[@]}"
|
||||
else
|
||||
help
|
||||
fi
|
||||
elif [ $# -eq 2 ]; then
|
||||
if [ "$1" == "--update1" ]; then
|
||||
update_1 "$2"
|
||||
elif [ "$1" == "--update2" ]; then
|
||||
update_2 "$2"
|
||||
else
|
||||
help
|
||||
fi
|
||||
else
|
||||
help
|
||||
fi
|
||||
50
bin/update-nightly-version.ts
Normal file
50
bin/update-nightly-version.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @module
|
||||
*
|
||||
* The nightly version works uses the version described in `package.json`, just like any release.
|
||||
* The problem with this approach is that production builds have a very aggressive cache, and
|
||||
* usually running the nightly with this cached version of the application will mean that the
|
||||
* user might run into module not found errors or styling errors caused by an old cache.
|
||||
*
|
||||
* This script is supposed to be run in the CI, which will update locally the version field of
|
||||
* `package.json` to contain the date. For example, `0.90.9-beta` will become `0.90.9-test-YYMMDD-HHMMSS`.
|
||||
*
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import fs from "fs";
|
||||
|
||||
function processVersion(version) {
|
||||
// Remove the beta suffix if any.
|
||||
version = version.replace("-beta", "");
|
||||
|
||||
// Add the nightly suffix, plus the date.
|
||||
const referenceDate = new Date()
|
||||
.toISOString()
|
||||
.substring(2, 19)
|
||||
.replace(/[-:]*/g, "")
|
||||
.replace("T", "-");
|
||||
version = `${version}-test-${referenceDate}`;
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = join(scriptDir, "..", "package.json");
|
||||
|
||||
// Read the version from package.json and process it.
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||||
const currentVersion = packageJson.version;
|
||||
const adjustedVersion = processVersion(currentVersion);
|
||||
console.log("Current version is", currentVersion);
|
||||
console.log("Adjusted version is", adjustedVersion);
|
||||
|
||||
// Write the adjusted version back in.
|
||||
packageJson.version = adjustedVersion;
|
||||
const formattedJson = JSON.stringify(packageJson, null, 4);
|
||||
fs.writeFileSync(packageJsonPath, formattedJson);
|
||||
}
|
||||
|
||||
main();
|
||||
BIN
db/demo.zip
BIN
db/demo.zip
Binary file not shown.
@@ -15,18 +15,26 @@ module.exports = {
|
||||
...getExtraResourcesForPlatform(),
|
||||
|
||||
// Moved to resources (TriliumNext Notes.app/Contents/Resources on macOS)
|
||||
"translations/"
|
||||
"translations/",
|
||||
"node_modules/@highlightjs/cdn-assets/styles"
|
||||
],
|
||||
afterComplete: [(buildPath, _electronVersion, platform, _arch, callback) => {
|
||||
const extraResources = getExtraResourcesForPlatform();
|
||||
for (const resource of extraResources) {
|
||||
const baseName = path.basename(resource);
|
||||
let sourcePath;
|
||||
if (platform === 'darwin') {
|
||||
sourcePath = path.join(buildPath, `${APP_NAME}.app`, 'Contents', 'Resources', path.basename(resource));
|
||||
sourcePath = path.join(buildPath, `${APP_NAME}.app`, 'Contents', 'Resources', baseName);
|
||||
} else {
|
||||
sourcePath = path.join(buildPath, 'resources', path.basename(resource));
|
||||
sourcePath = path.join(buildPath, 'resources', baseName);
|
||||
}
|
||||
let destPath;
|
||||
|
||||
if (baseName !== "256x256.png") {
|
||||
destPath = path.join(buildPath, baseName);
|
||||
} else {
|
||||
destPath = path.join(buildPath, "icon.png");
|
||||
}
|
||||
const destPath = path.join(buildPath, path.basename(resource));
|
||||
|
||||
// Copy files from resources folder to root
|
||||
fs.move(sourcePath, destPath)
|
||||
@@ -44,6 +52,7 @@ module.exports = {
|
||||
config: {
|
||||
options: {
|
||||
icon: "./images/app-icons/png/128x128.png",
|
||||
desktopTemplate: path.resolve("./bin/electron-forge/desktop.ejs")
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -95,6 +104,7 @@ function getExtraResourcesForPlatform() {
|
||||
case 'darwin':
|
||||
break;
|
||||
case 'linux':
|
||||
resources.push("images/app-icons/png/256x256.png")
|
||||
for (const script of scripts) {
|
||||
resources.push(`./bin/tpl/${script}.sh`)
|
||||
}
|
||||
|
||||
@@ -526,16 +526,19 @@
|
||||
/* @ckeditor/ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre {
|
||||
padding: 1em;
|
||||
color: hsl(0, 0%, 20.8%);
|
||||
background: hsla(0, 0%, 78%, 0.3);
|
||||
border: 1px solid hsl(0, 0%, 77%);
|
||||
border-radius: 2px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
tab-size: 4;
|
||||
white-space: pre-wrap;
|
||||
font-style: normal;
|
||||
min-width: 200px;
|
||||
border: 0px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.ck-content pre:not(.hljs) {
|
||||
color: hsl(0, 0%, 20.8%);
|
||||
background: hsla(0, 0%, 78%, 0.3);
|
||||
}
|
||||
/* @ckeditor/ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre code {
|
||||
|
||||
49
libraries/ckeditor/ckeditor.d.ts
vendored
Normal file
49
libraries/ckeditor/ckeditor.d.ts
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
|
||||
*/
|
||||
import { DecoupledEditor as DecoupledEditorBase } from '@ckeditor/ckeditor5-editor-decoupled';
|
||||
import { Essentials } from '@ckeditor/ckeditor5-essentials';
|
||||
import { Alignment } from '@ckeditor/ckeditor5-alignment';
|
||||
import { FontSize, FontFamily, FontColor, FontBackgroundColor } from '@ckeditor/ckeditor5-font';
|
||||
import { CKFinderUploadAdapter } from '@ckeditor/ckeditor5-adapter-ckfinder';
|
||||
import { Autoformat } from '@ckeditor/ckeditor5-autoformat';
|
||||
import { Bold, Italic, Strikethrough, Underline } from '@ckeditor/ckeditor5-basic-styles';
|
||||
import { BlockQuote } from '@ckeditor/ckeditor5-block-quote';
|
||||
import { CKBox } from '@ckeditor/ckeditor5-ckbox';
|
||||
import { CKFinder } from '@ckeditor/ckeditor5-ckfinder';
|
||||
import { EasyImage } from '@ckeditor/ckeditor5-easy-image';
|
||||
import { Heading } from '@ckeditor/ckeditor5-heading';
|
||||
import { Image, ImageCaption, ImageResize, ImageStyle, ImageToolbar, ImageUpload, PictureEditing } from '@ckeditor/ckeditor5-image';
|
||||
import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent';
|
||||
import { Link } from '@ckeditor/ckeditor5-link';
|
||||
import { List, ListProperties } from '@ckeditor/ckeditor5-list';
|
||||
import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed';
|
||||
import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
|
||||
import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office';
|
||||
import { Table, TableToolbar } from '@ckeditor/ckeditor5-table';
|
||||
import { TextTransformation } from '@ckeditor/ckeditor5-typing';
|
||||
import { CloudServices } from '@ckeditor/ckeditor5-cloud-services';
|
||||
export default class DecoupledEditor extends DecoupledEditorBase {
|
||||
static builtinPlugins: (typeof TextTransformation | typeof Essentials | typeof Alignment | typeof FontBackgroundColor | typeof FontColor | typeof FontFamily | typeof FontSize | typeof CKFinderUploadAdapter | typeof Paragraph | typeof Heading | typeof Autoformat | typeof Bold | typeof Italic | typeof Strikethrough | typeof Underline | typeof BlockQuote | typeof Image | typeof ImageCaption | typeof ImageResize | typeof ImageStyle | typeof ImageToolbar | typeof ImageUpload | typeof CloudServices | typeof CKBox | typeof CKFinder | typeof EasyImage | typeof List | typeof ListProperties | typeof Indent | typeof IndentBlock | typeof Link | typeof MediaEmbed | typeof PasteFromOffice | typeof Table | typeof TableToolbar | typeof PictureEditing)[];
|
||||
static defaultConfig: {
|
||||
toolbar: {
|
||||
items: string[];
|
||||
};
|
||||
image: {
|
||||
resizeUnit: "px";
|
||||
toolbar: string[];
|
||||
};
|
||||
table: {
|
||||
contentToolbar: string[];
|
||||
};
|
||||
list: {
|
||||
properties: {
|
||||
styles: boolean;
|
||||
startIndex: boolean;
|
||||
reversed: boolean;
|
||||
};
|
||||
};
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
2
libraries/ckeditor/ckeditor.js
vendored
2
libraries/ckeditor/ckeditor.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1084
package-lock.json
generated
1084
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "trilium",
|
||||
"productName": "TriliumNext Notes",
|
||||
"description": "Build your personal knowledge base with TriliumNext Notes",
|
||||
"version": "0.90.7-beta",
|
||||
"version": "0.90.12",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "./dist/electron-main.js",
|
||||
"author": {
|
||||
@@ -46,12 +46,14 @@
|
||||
"integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"integration-mem-db": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"generate-document": "cross-env nodemon src/tools/generate_document.ts 1000"
|
||||
"generate-document": "cross-env nodemon src/tools/generate_document.ts 1000",
|
||||
"ci-update-nightly-version": "tsx ./bin/update-nightly-version.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "7.1.0",
|
||||
"@electron/remote": "2.1.2",
|
||||
"@excalidraw/excalidraw": "0.17.6",
|
||||
"@highlightjs/cdn-assets": "11.10.0",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
@@ -63,30 +65,30 @@
|
||||
"cls-hooked": "4.2.2",
|
||||
"codemirror": "5.65.18",
|
||||
"compression": "1.7.4",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cookie-parser": "1.4.7",
|
||||
"csurf": "1.11.0",
|
||||
"dayjs": "1.11.13",
|
||||
"dayjs-plugin-utc": "0.1.2",
|
||||
"debounce": "2.1.1",
|
||||
"debounce": "2.2.0",
|
||||
"ejs": "3.1.10",
|
||||
"electron-debug": "4.0.1",
|
||||
"electron-dl": "4.0.0",
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"eslint": "9.10.0",
|
||||
"express": "4.21.0",
|
||||
"eslint": "9.14.0",
|
||||
"express": "4.21.1",
|
||||
"express-partial-content": "1.0.2",
|
||||
"express-rate-limit": "7.4.0",
|
||||
"express-session": "1.18.0",
|
||||
"force-graph": "1.43.5",
|
||||
"express-rate-limit": "7.4.1",
|
||||
"express-session": "1.18.1",
|
||||
"force-graph": "1.46.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"helmet": "7.1.0",
|
||||
"html": "1.0.0",
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.5",
|
||||
"i18next": "23.15.2",
|
||||
"i18next": "23.16.4",
|
||||
"i18next-fs-backend": "2.3.2",
|
||||
"i18next-http-backend": "2.6.2",
|
||||
"image-type": "4.1.0",
|
||||
@@ -103,10 +105,10 @@
|
||||
"katex": "0.16.11",
|
||||
"knockout": "3.5.1",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "14.1.2",
|
||||
"mermaid": "11.3.0",
|
||||
"marked": "14.1.3",
|
||||
"mermaid": "11.4.0",
|
||||
"mime-types": "2.1.35",
|
||||
"mind-elixir": "4.1.5",
|
||||
"mind-elixir": "4.3.1",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"node-abi": "3.67.0",
|
||||
"normalize-strings": "1.1.1",
|
||||
@@ -119,7 +121,7 @@
|
||||
"request": "2.88.2",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"sax": "1.4.1",
|
||||
"semver": "7.6.3",
|
||||
"serve-favicon": "2.5.0",
|
||||
@@ -132,7 +134,7 @@
|
||||
"tree-kill": "1.2.2",
|
||||
"turndown": "7.2.0",
|
||||
"unescape": "1.0.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.2",
|
||||
"vanilla-js-wheel-zoom": "9.0.4",
|
||||
"ws": "8.18.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.1.3"
|
||||
@@ -144,7 +146,7 @@
|
||||
"@electron-forge/maker-squirrel": "7.5.0",
|
||||
"@electron-forge/maker-zip": "7.5.0",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.5.0",
|
||||
"@playwright/test": "1.47.1",
|
||||
"@playwright/test": "1.48.2",
|
||||
"@types/archiver": "6.0.2",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"@types/cls-hooked": "4.3.8",
|
||||
@@ -163,7 +165,7 @@
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/multer": "1.4.12",
|
||||
"@types/node": "22.5.4",
|
||||
"@types/node": "22.7.8",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/sax": "1.2.7",
|
||||
@@ -182,17 +184,17 @@
|
||||
"electron-rebuild": "3.2.9",
|
||||
"esm": "3.2.25",
|
||||
"iconsur": "1.7.0",
|
||||
"jasmine": "5.3.1",
|
||||
"jasmine": "5.4.0",
|
||||
"jsdoc": "4.0.3",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"nodemon": "3.1.7",
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"ts-node": "10.9.2",
|
||||
"tslib": "2.7.0",
|
||||
"tsx": "4.19.1",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "5.6.3",
|
||||
"webpack": "5.95.0",
|
||||
"webpack": "5.96.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
10
renovate.json
Normal file
10
renovate.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"repositories": ["TriliumNext/Notes"],
|
||||
"schedule": ["before 3am"],
|
||||
"labels": ["dependencies", "renovate"],
|
||||
"prHourlyLimit": 0,
|
||||
"prConcurrentLimit": 0,
|
||||
"branchConcurrentLimit": 0
|
||||
}
|
||||
@@ -38,9 +38,18 @@ export interface RecentNoteRow {
|
||||
utcDateCreated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database representation of an option.
|
||||
*
|
||||
* Options are key-value pairs that are used to store information such as user preferences (for example
|
||||
* the current theme, sync server information), but also information about the state of the application).
|
||||
*/
|
||||
export interface OptionRow {
|
||||
/** The name of the option. */
|
||||
name: string;
|
||||
/** The value of the option. */
|
||||
value: string;
|
||||
/** `true` if the value should be synced across multiple instances (e.g. locale) or `false` if it should be local-only (e.g. theme). */
|
||||
isSynced: boolean;
|
||||
utcDateModified?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import MobileScreenSwitcherExecutor from "./mobile_screen_switcher.js";
|
||||
import MainTreeExecutors from "./main_tree_executors.js";
|
||||
import toast from "../services/toast.js";
|
||||
import ShortcutComponent from "./shortcut_component.js";
|
||||
import { initLocale } from "../services/i18n.js";
|
||||
import { t, initLocale } from "../services/i18n.js";
|
||||
|
||||
class AppContext extends Component {
|
||||
constructor(isMainWindow) {
|
||||
@@ -33,11 +33,11 @@ class AppContext extends Component {
|
||||
await initLocale();
|
||||
}
|
||||
|
||||
setLayout(layout) {
|
||||
setLayout(layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
async start() {
|
||||
async start() {
|
||||
this.initComponents();
|
||||
this.renderWidgets();
|
||||
|
||||
@@ -151,7 +151,7 @@ $(window).on('beforeunload', () => {
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
|
||||
toast.showMessage("Please wait for a couple of seconds for the save to finish, then you can try again.", 10000);
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
|
||||
allSaved = false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ws from "../services/ws.js";
|
||||
import bundleService from "../services/bundle.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
@@ -172,13 +173,13 @@ export default class Entrypoints extends Component {
|
||||
const resp = await server.post(`sql/execute/${note.noteId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(`Error occurred while executing SQL query: ${resp.error}`);
|
||||
toastService.showError(t("entrypoints.sql-error", { message: resp.error }));
|
||||
}
|
||||
|
||||
await appContext.triggerEvent('sqlQueryResults', {ntxId: ntxId, results: resp.results});
|
||||
}
|
||||
|
||||
toastService.showMessage("Note executed");
|
||||
toastService.showMessage(t("entrypoints.note-executed"));
|
||||
}
|
||||
|
||||
hideAllPopups() {
|
||||
@@ -200,6 +201,6 @@ export default class Entrypoints extends Component {
|
||||
|
||||
await server.post(`notes/${noteId}/revision`);
|
||||
|
||||
toastService.showMessage("Note revision has been created.");
|
||||
toastService.showMessage(t("entrypoints.note-revision-created"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ export default class TabManager extends Component {
|
||||
await this.removeNoteContext(ntxIdToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async closeOtherTabsCommand({ntxId}) {
|
||||
for (const ntxIdToRemove of this.mainNoteContexts.map(nc => nc.ntxId)) {
|
||||
if (ntxIdToRemove !== ntxId) {
|
||||
@@ -560,6 +560,18 @@ export default class TabManager extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async closeRightTabsCommand({ntxId}) {
|
||||
const ntxIds = this.mainNoteContexts.map(nc => nc.ntxId);
|
||||
const index = ntxIds.indexOf(ntxId);
|
||||
|
||||
if (index !== -1) {
|
||||
const idsToRemove = ntxIds.slice(index + 1);
|
||||
for (const ntxIdToRemove of idsToRemove) {
|
||||
await this.removeNoteContext(ntxIdToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async closeTabCommand({ntxId}) {
|
||||
await this.removeNoteContext(ntxId);
|
||||
}
|
||||
@@ -574,6 +586,11 @@ export default class TabManager extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async copyTabToNewWindowCommand({ntxId}) {
|
||||
const {notePath, hoistedNoteId} = this.getNoteContextById(ntxId);
|
||||
this.triggerCommand('openInWindow', {notePath, hoistedNoteId});
|
||||
}
|
||||
|
||||
async reopenLastTabCommand() {
|
||||
let closeLastEmptyTab = null;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class ZoomComponent extends Component {
|
||||
|
||||
window.addEventListener("wheel", event => {
|
||||
if (event.ctrlKey) {
|
||||
this.setZoomFactorAndSave(this.getCurrentZoom() + event.deltaY * 0.001);
|
||||
this.setZoomFactorAndSave(this.getCurrentZoom() - event.deltaY * 0.001);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class ZoomComponent extends Component {
|
||||
zoomResetEvent() {
|
||||
this.setZoomFactorAndSave(1);
|
||||
}
|
||||
|
||||
|
||||
setZoomFactorAndSaveEvent({zoomFactor}) {
|
||||
this.setZoomFactorAndSave(zoomFactor);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
bundleService.getWidgetBundlesByParent().then(async widgetBundles => {
|
||||
await appContext.earlyInit();
|
||||
|
||||
// A dynamic import is required for layouts since they initialize components which require translations.
|
||||
const DesktopLayout = (await import("./layouts/desktop_layout.js")).default;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
|
||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
|
||||
export default class DesktopLayout {
|
||||
constructor(customWidgets) {
|
||||
@@ -140,6 +141,7 @@ export default class DesktopLayout {
|
||||
// the order of the widgets matter. Some of these want to "activate" themselves
|
||||
// when visible. When this happens to multiple of them, the first one "wins".
|
||||
// promoted attributes should always win.
|
||||
.ribbon(new ClassicEditorToolbar())
|
||||
.ribbon(new PromotedAttributesWidget())
|
||||
.ribbon(new ScriptExecutorWidget())
|
||||
.ribbon(new SearchDefinitionWidget())
|
||||
|
||||
@@ -23,6 +23,7 @@ import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -167,6 +168,7 @@ export default class MobileLayout {
|
||||
.child(new NoteListWidget())
|
||||
.child(new FilePropertiesWidget().css('font-size','smaller'))
|
||||
)
|
||||
.child(new ClassicEditorToolbar())
|
||||
)
|
||||
.child(new ProtectedSessionPasswordDialog())
|
||||
.child(new ConfirmDialog())
|
||||
|
||||
@@ -3,6 +3,7 @@ import keyboardActionService from '../services/keyboard_actions.js';
|
||||
class ContextMenu {
|
||||
constructor() {
|
||||
this.$widget = $("#context-menu-container");
|
||||
this.$widget.addClass("dropend");
|
||||
this.dateContextMenuOpenedMs = 0;
|
||||
|
||||
$(document).on('click', () => this.hide());
|
||||
@@ -11,6 +12,12 @@ class ContextMenu {
|
||||
async show(options) {
|
||||
this.options = options;
|
||||
|
||||
if (this.$widget.hasClass("show")) {
|
||||
// The menu is already visible. Hide the menu then open it again
|
||||
// at the new location to re-trigger the opening animation.
|
||||
await this.hide();
|
||||
}
|
||||
|
||||
this.$widget.empty();
|
||||
|
||||
this.addItems(this.$widget, options.items);
|
||||
@@ -96,6 +103,10 @@ class ContextMenu {
|
||||
.append(" ") // some space between icon and text
|
||||
.append(item.title);
|
||||
|
||||
if (item.shortcut) {
|
||||
$link.append($("<kbd>").text(item.shortcut));
|
||||
}
|
||||
|
||||
const $item = $("<li>")
|
||||
.addClass("dropdown-item")
|
||||
.append($link)
|
||||
@@ -142,18 +153,26 @@ class ContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
async hide() {
|
||||
// this date checking comes from change in FF66 - https://github.com/zadam/trilium/issues/468
|
||||
// "contextmenu" event also triggers "click" event which depending on the timing can close the just opened context menu
|
||||
// we might filter out right clicks, but then it's better if even right clicks close the context menu
|
||||
if (Date.now() - this.dateContextMenuOpenedMs > 300) {
|
||||
// seems like if we hide the menu immediately, some clicks can get propagated to the underlying component
|
||||
// see https://github.com/zadam/trilium/pull/3805 for details
|
||||
setTimeout(() => this.$widget.hide(), 100);
|
||||
await timeout(100);
|
||||
this.$widget.removeClass("show");
|
||||
this.$widget.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function timeout(ms) {
|
||||
return new Promise((accept, reject) => {
|
||||
setTimeout(accept, ms);
|
||||
});
|
||||
}
|
||||
|
||||
const contextMenu = new ContextMenu();
|
||||
|
||||
export default contextMenu;
|
||||
|
||||
@@ -2,6 +2,7 @@ import utils from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import zoomService from "../components/zoom.js";
|
||||
import contextMenu from "./context_menu.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
|
||||
function setupContextMenu() {
|
||||
const electron = utils.dynamicRequire('electron');
|
||||
@@ -28,7 +29,7 @@ function setupContextMenu() {
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: `Add "${params.misspelledWord}" to dictionary`,
|
||||
title: t("electron_context_menu.add-term-to-dictionary", { term: params.misspelledWord }),
|
||||
uiIcon: "bx bx-plus",
|
||||
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
});
|
||||
@@ -39,7 +40,8 @@ function setupContextMenu() {
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canCut && hasText,
|
||||
title: `Cut <kbd>${platformModifier}+X`,
|
||||
title: t("electron_context_menu.cut"),
|
||||
shortcut: `${platformModifier}+X`,
|
||||
uiIcon: "bx bx-cut",
|
||||
handler: () => webContents.cut()
|
||||
});
|
||||
@@ -48,7 +50,8 @@ function setupContextMenu() {
|
||||
if (params.isEditable || hasText) {
|
||||
items.push({
|
||||
enabled: editFlags.canCopy && hasText,
|
||||
title: `Copy <kbd>${platformModifier}+C`,
|
||||
title: t("electron_context_menu.copy"),
|
||||
shortcut: `${platformModifier}+C`,
|
||||
uiIcon: "bx bx-copy",
|
||||
handler: () => webContents.copy()
|
||||
});
|
||||
@@ -56,7 +59,7 @@ function setupContextMenu() {
|
||||
|
||||
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === 'none') {
|
||||
items.push({
|
||||
title: `Copy link`,
|
||||
title: t("electron_context_menu.copy-link"),
|
||||
uiIcon: "bx bx-copy",
|
||||
handler: () => {
|
||||
electron.clipboard.write({
|
||||
@@ -70,7 +73,8 @@ function setupContextMenu() {
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canPaste,
|
||||
title: `Paste <kbd>${platformModifier}+V`,
|
||||
title: t("electron_context_menu.paste"),
|
||||
shortcut: `${platformModifier}+V`,
|
||||
uiIcon: "bx bx-paste",
|
||||
handler: () => webContents.paste()
|
||||
});
|
||||
@@ -79,7 +83,8 @@ function setupContextMenu() {
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canPaste,
|
||||
title: `Paste as plain text <kbd>${platformModifier}+Shift+V`,
|
||||
title: t("electron_context_menu.paste-as-plain-text"),
|
||||
shortcut: `${platformModifier}+Shift+V`,
|
||||
uiIcon: "bx bx-paste",
|
||||
handler: () => webContents.pasteAndMatchStyle()
|
||||
});
|
||||
@@ -106,9 +111,11 @@ function setupContextMenu() {
|
||||
// Replace the placeholder with the real search keyword.
|
||||
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||
|
||||
items.push({title: "----"});
|
||||
|
||||
items.push({
|
||||
enabled: editFlags.canPaste,
|
||||
title: `Search for "${shortenedSelection}" with ${searchEngineName}`,
|
||||
title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }),
|
||||
uiIcon: "bx bx-search-alt",
|
||||
handler: () => electron.shell.openExternal(searchUrl)
|
||||
});
|
||||
|
||||
@@ -20,9 +20,13 @@ function setupContextMenu($image) {
|
||||
{
|
||||
title: "Copy reference to clipboard",
|
||||
command: "copyImageReferenceToClipboard",
|
||||
uiIcon: "bx bx-empty"
|
||||
uiIcon: "bx bx-directions"
|
||||
},
|
||||
{
|
||||
title: "Copy image to clipboard",
|
||||
command: "copyImageToClipboard",
|
||||
uiIcon: "bx bx-copy"
|
||||
},
|
||||
{ title: "Copy image to clipboard", command: "copyImageToClipboard", uiIcon: "bx bx-empty" },
|
||||
],
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === 'copyImageReferenceToClipboard') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import froca from "../services/froca.js";
|
||||
import contextMenu from "./context_menu.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import server from "../services/server.js";
|
||||
import { t } from '../services/i18n.js';
|
||||
|
||||
export default class LauncherContextMenu {
|
||||
/**
|
||||
@@ -33,29 +34,31 @@ export default class LauncherContextMenu {
|
||||
const isAvailableItem = parentNoteId === '_lbAvailableLaunchers';
|
||||
const isItem = isVisibleItem || isAvailableItem;
|
||||
const canBeDeleted = !note.noteId.startsWith("_"); // fixed notes can't be deleted
|
||||
const canBeReset = !canBeDeleted && note.isLaunchBarConfig();;
|
||||
const canBeReset = !canBeDeleted && note.isLaunchBarConfig();
|
||||
|
||||
return [
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: 'Add a note launcher', command: 'addNoteLauncher', uiIcon: "bx bx-plus" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: 'Add a script launcher', command: 'addScriptLauncher', uiIcon: "bx bx-plus" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: 'Add a custom widget', command: 'addWidgetLauncher', uiIcon: "bx bx-plus" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: 'Add spacer', command: 'addSpacerLauncher', uiIcon: "bx bx-plus" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-note-launcher"), command: 'addNoteLauncher', uiIcon: "bx bx-note" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-script-launcher"), command: 'addScriptLauncher', uiIcon: "bx bx-code-curly" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-custom-widget"), command: 'addWidgetLauncher', uiIcon: "bx bx-customize" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: t("launcher_context_menu.add-spacer"), command: 'addSpacerLauncher', uiIcon: "bx bx-dots-horizontal" } : null,
|
||||
(isVisibleRoot || isAvailableRoot) ? { title: "----" } : null,
|
||||
{ title: 'Delete <kbd data-command="deleteNotes"></kbd>', command: "deleteNotes", uiIcon: "bx bx-trash", enabled: canBeDeleted },
|
||||
{ title: 'Reset', command: "resetLauncher", uiIcon: "bx bx-empty", enabled: canBeReset},
|
||||
|
||||
isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
|
||||
isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
|
||||
(isVisibleItem || isAvailableItem) ? { title: "----" } : null,
|
||||
|
||||
{ title: `${t("launcher_context_menu.duplicate-launcher")} <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem },
|
||||
{ title: `${t("launcher_context_menu.delete")} <kbd data-command="deleteNotes"></kbd>`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted },
|
||||
|
||||
{ title: "----" },
|
||||
isAvailableItem ? { title: 'Move to visible launchers', command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
|
||||
isVisibleItem ? { title: 'Move to available launchers', command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
|
||||
{ title: `Duplicate launcher <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "bx bx-empty",
|
||||
enabled: isItem }
|
||||
|
||||
{ title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset}
|
||||
].filter(row => row !== null);
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({command}) {
|
||||
if (command === 'resetLauncher') {
|
||||
const confirmed = await dialogService.confirm(`Do you really want to reset "${this.node.title}"?
|
||||
All data / settings in this note (and its children) will be lost
|
||||
and the launcher will be returned to its original location.`);
|
||||
const confirmed = await dialogService.confirm(t("launcher_context_menu.reset_launcher_confirm", { title: this.node.title }));
|
||||
|
||||
if (confirmed) {
|
||||
await server.post(`special-notes/launchers/${this.node.data.noteId}/reset`);
|
||||
|
||||
@@ -6,7 +6,7 @@ function openContextMenu(notePath, e, viewScope = {}, hoistedNoteId = null) {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{title: "Open note in a new tab", command: "openNoteInNewTab", uiIcon: "bx bx-empty"},
|
||||
{title: "Open note in a new tab", command: "openNoteInNewTab", uiIcon: "bx bx-link-external"},
|
||||
{title: "Open note in a new split", command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right"},
|
||||
{title: "Open note in a new window", command: "openNoteInNewWindow", uiIcon: "bx bx-window-open"}
|
||||
],
|
||||
|
||||
@@ -49,56 +49,98 @@ export default class TreeContextMenu {
|
||||
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
|
||||
|
||||
return [
|
||||
{ title: `${t("tree-context-menu.open-in-a-new-tab")} <kbd>Ctrl+Click</kbd>`, command: "openInTab", uiIcon: "bx bx-empty", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.open-in-a-new-tab")} <kbd>Ctrl+Click</kbd>`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.insert-note-after")} <kbd data-command="createNoteAfter"></kbd>`, command: "insertNoteAfter", uiIcon: "bx bx-plus",
|
||||
|
||||
isHoisted ? null : { title: `${t("tree-context-menu.hoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bxs-chevrons-up", enabled: noSelectedNotes && notSearch },
|
||||
!isHoisted || !isNotRoot ? null : { title: `${t("tree-context-menu.unhoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
|
||||
{ title: `${t("tree-context-menu.insert-note-after")}<kbd data-command="createNoteAfter"></kbd>`, command: "insertNoteAfter", uiIcon: "bx bx-plus",
|
||||
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
|
||||
enabled: insertNoteAfterEnabled && noSelectedNotes && notOptions },
|
||||
{ title: `${t("tree-context-menu.insert-child-note")} <kbd data-command="createNoteInto"></kbd>`, command: "insertChildNote", uiIcon: "bx bx-plus",
|
||||
|
||||
{ title: `${t("tree-context-menu.insert-child-note")}<kbd data-command="createNoteInto"></kbd>`, command: "insertChildNote", uiIcon: "bx bx-plus",
|
||||
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
|
||||
enabled: notSearch && noSelectedNotes && notOptions },
|
||||
{ title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`, command: "deleteNotes", uiIcon: "bx bx-trash",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptions },
|
||||
{ title: "----" },
|
||||
{ title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`, command: "searchInSubtree", uiIcon: "bx bx-search",
|
||||
enabled: notSearch && noSelectedNotes },
|
||||
isHoisted ? null : { title: 'Hoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "bx bx-empty", enabled: noSelectedNotes && notSearch },
|
||||
!isHoisted || !isNotRoot ? null : { title: 'Unhoist note <kbd data-command="toggleNoteHoisting"></kbd>', command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
{ title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`, command: "editBranchPrefix", uiIcon: "bx bx-empty",
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptions },
|
||||
{ title: t("tree-context-menu.advanced"), uiIcon: "bx bx-empty", enabled: true, items: [
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`, command: "sortChildNotes", uiIcon: "bx bx-empty", enabled: noSelectedNotes && notSearch },
|
||||
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptions },
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-empty", enabled: isNotRoot && !isHoisted && notOptions },
|
||||
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-empty", enabled: true }
|
||||
] },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
|
||||
{ title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
{ title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy",
|
||||
enabled: isNotRoot && !isHoisted },
|
||||
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-empty",
|
||||
enabled: isNotRoot && !isHoisted },
|
||||
|
||||
|
||||
{ title: t("tree-context-menu.advanced"), uiIcon: "bx bxs-wrench", enabled: true, items: [
|
||||
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`, command: "editBranchPrefix", uiIcon: "bx bx-rename", enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptions },
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptions },
|
||||
{ title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: parentNotSearch && isNotRoot && !isHoisted && notOptions },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`, command: "sortChildNotes", uiIcon: "bx bx-sort-down", enabled: noSelectedNotes && notSearch },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
|
||||
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptions },
|
||||
] },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
|
||||
{ title: `${t("tree-context-menu.cut")} <kbd data-command="cutNotesToClipboard"></kbd>`, command: "cutNotesToClipboard", uiIcon: "bx bx-cut",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch },
|
||||
{ title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`, command: "moveNotesTo", uiIcon: "bx bx-empty",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch },
|
||||
|
||||
{ title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy",
|
||||
enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{ title: `${t("tree-context-menu.paste-into")} <kbd data-command="pasteNotesFromClipboard"></kbd>`, command: "pasteNotesFromClipboard", uiIcon: "bx bx-paste",
|
||||
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.paste-after"), command: "pasteNotesAfterFromClipboard", uiIcon: "bx bx-paste",
|
||||
enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`, command: "duplicateSubtree", uiIcon: "bx bx-empty",
|
||||
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptions },
|
||||
|
||||
{ title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`, command: "moveNotesTo", uiIcon: "bx bx-transfer",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch },
|
||||
|
||||
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate",
|
||||
enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{ title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptions },
|
||||
|
||||
{ title: "----" },
|
||||
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-empty",
|
||||
|
||||
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import",
|
||||
enabled: notSearch && noSelectedNotes && notOptions },
|
||||
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-empty",
|
||||
|
||||
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export",
|
||||
enabled: notSearch && noSelectedNotes && notOptions },
|
||||
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus",
|
||||
enabled: true }
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
|
||||
{ title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`, command: "searchInSubtree", uiIcon: "bx bx-search",
|
||||
enabled: notSearch && noSelectedNotes },
|
||||
|
||||
].filter(row => row !== null);
|
||||
}
|
||||
|
||||
@@ -136,7 +178,7 @@ export default class TreeContextMenu {
|
||||
this.treeWidget.triggerCommand("openNewNoteSplit", {ntxId, notePath});
|
||||
}
|
||||
else if (command === 'convertNoteToAttachment') {
|
||||
if (!await dialogService.confirm(`Are you sure you want to convert note selected notes into attachments of their parent notes?`)) {
|
||||
if (!await dialogService.confirm(t("tree-context-menu.convert-to-attachment-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +196,7 @@ export default class TreeContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
toastService.showMessage(`${converted} notes have been converted to attachments.`);
|
||||
toastService.showMessage(t("tree-context-menu.converted-to-attachments", { count: converted }));
|
||||
}
|
||||
else if (command === 'copyNotePathToClipboard') {
|
||||
navigator.clipboard.writeText('#' + notePath);
|
||||
|
||||
@@ -5,6 +5,7 @@ import froca from "./froca.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from './i18n.js';
|
||||
|
||||
async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
|
||||
branchIdsToMove = filterRootNote(branchIdsToMove);
|
||||
@@ -13,7 +14,7 @@ async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
|
||||
const beforeBranch = froca.getBranch(beforeBranchId);
|
||||
|
||||
if (['root', '_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(beforeBranch.noteId)) {
|
||||
toastService.showError('Cannot move notes here.');
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ async function moveAfterBranch(branchIdsToMove, afterBranchId) {
|
||||
];
|
||||
|
||||
if (forbiddenNoteIds.includes(afterNote.noteId)) {
|
||||
toastService.showError('Cannot move notes here.');
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ async function moveToParentNote(branchIdsToMove, newParentBranchId) {
|
||||
const newParentBranch = froca.getBranch(newParentBranchId);
|
||||
|
||||
if (newParentBranch.noteId === '_lbRoot') {
|
||||
toastService.showError('Cannot move notes here.');
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,7 +193,7 @@ function filterRootNote(branchIds) {
|
||||
function makeToast(id, message) {
|
||||
return {
|
||||
id: id,
|
||||
title: "Delete status",
|
||||
title: t("branches.delete-status"),
|
||||
message: message,
|
||||
icon: "trash"
|
||||
};
|
||||
@@ -207,9 +208,9 @@ ws.subscribeToMessages(async message => {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === 'taskProgressCount') {
|
||||
toastService.showPersistent(makeToast(message.taskId, `Delete notes in progress: ${message.progressCount}`));
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === 'taskSucceeded') {
|
||||
const toast = makeToast(message.taskId, "Delete finished successfully.");
|
||||
const toast = makeToast(message.taskId, t("branches.delete-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
@@ -225,9 +226,9 @@ ws.subscribeToMessages(async message => {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === 'taskProgressCount') {
|
||||
toastService.showPersistent(makeToast(message.taskId, `Undeleting notes in progress: ${message.progressCount}`));
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === 'taskSucceeded') {
|
||||
const toast = makeToast(message.taskId, "Undeleting notes finished successfully.");
|
||||
const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
@@ -13,22 +13,23 @@ import ExecuteScriptBulkAction from "../widgets/bulk_actions/execute_script.js";
|
||||
import AddLabelBulkAction from "../widgets/bulk_actions/label/add_label.js";
|
||||
import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation.js";
|
||||
import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
const ACTION_GROUPS = [
|
||||
{
|
||||
title: 'Labels',
|
||||
title: t("bulk_actions.labels"),
|
||||
actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction]
|
||||
},
|
||||
{
|
||||
title: 'Relations',
|
||||
title: t("bulk_actions.relations"),
|
||||
actions: [AddRelationBulkAction, UpdateRelationTargetBulkAction, RenameRelationBulkAction, DeleteRelationBulkAction]
|
||||
},
|
||||
{
|
||||
title: 'Notes',
|
||||
title: t("bulk_actions.notes"),
|
||||
actions: [RenameNoteBulkAction, MoveNoteBulkAction, DeleteNoteBulkAction, DeleteRevisionsBulkAction],
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
title: t("bulk_actions.other"),
|
||||
actions: [ExecuteScriptBulkAction]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import server from "./server.js";
|
||||
import toastService from "./toast.js";
|
||||
import froca from "./froca.js";
|
||||
import utils from "./utils.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
async function getAndExecuteBundle(noteId, originEntity = null, script = null, params = null) {
|
||||
const bundle = await server.post(`script/bundle/${noteId}`, {
|
||||
@@ -75,9 +76,23 @@ async function getWidgetBundlesByParent() {
|
||||
|
||||
try {
|
||||
widget = await executeBundle(bundle);
|
||||
widgetsByParent.add(widget);
|
||||
}
|
||||
catch (e) {
|
||||
if (widget) {
|
||||
widget._noteId = bundle.noteId;
|
||||
widgetsByParent.add(widget);
|
||||
}
|
||||
} catch (e) {
|
||||
const noteId = bundle.noteId;
|
||||
const note = await froca.getNote(noteId);
|
||||
toastService.showPersistent({
|
||||
title: t("toast.bundle-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.bundle-error.message", {
|
||||
id: noteId,
|
||||
title: note.title,
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
|
||||
logError("Widget initialization failed: ", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import toastService from "./toast.js";
|
||||
import froca from "./froca.js";
|
||||
import linkService from "./link.js";
|
||||
import utils from "./utils.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
let clipboardBranchIds = [];
|
||||
let clipboardMode = null;
|
||||
@@ -78,7 +79,7 @@ async function copy(branchIds) {
|
||||
clipboard.writeHTML(links.join(', '));
|
||||
}
|
||||
|
||||
toastService.showMessage("Note(s) have been copied into clipboard.");
|
||||
toastService.showMessage(t("clipboard.copied"));
|
||||
}
|
||||
|
||||
function cut(branchIds) {
|
||||
@@ -87,7 +88,7 @@ function cut(branchIds) {
|
||||
if (clipboardBranchIds.length > 0) {
|
||||
clipboardMode = 'cut';
|
||||
|
||||
toastService.showMessage("Note(s) have been cut into clipboard.");
|
||||
toastService.showMessage(t("clipboard.cut"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import treeService from "./tree.js";
|
||||
import FNote from "../entities/fnote.js";
|
||||
import FAttachment from "../entities/fattachment.js";
|
||||
import imageContextMenuService from "../menus/image_context_menu.js";
|
||||
import { applySingleBlockSyntaxHighlight, applySyntaxHighlight } from "./syntax_highlight.js";
|
||||
import mime_types from "./mime_types.js";
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
@@ -105,16 +107,25 @@ async function renderText(note, $renderedContent) {
|
||||
for (const el of referenceLinks) {
|
||||
await linkService.loadReferenceLinkTitle($(el));
|
||||
}
|
||||
|
||||
await applySyntaxHighlight($renderedContent);
|
||||
} else {
|
||||
await renderChildrenList($renderedContent, note);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {FNote} note */
|
||||
/**
|
||||
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
|
||||
*
|
||||
* @param {FNote} note
|
||||
*/
|
||||
async function renderCode(note, $renderedContent) {
|
||||
const blob = await note.getBlob();
|
||||
|
||||
$renderedContent.append($("<pre>").text(blob.content));
|
||||
const $codeBlock = $("<code>");
|
||||
$codeBlock.text(blob.content);
|
||||
$renderedContent.append($("<pre>").append($codeBlock));
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, mime_types.normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
function renderImage(entity, $renderedContent, options = {}) {
|
||||
|
||||
@@ -217,8 +217,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
|
||||
*/
|
||||
this.runOnBackend = async (func, params = []) => {
|
||||
if (func?.constructor.name === "AsyncFunction" || func?.startsWith?.("async ")) {
|
||||
toastService.showError("You're passing an async function to api.runOnBackend() which will likely not work as you intended. "
|
||||
+ "Either make the function synchronous (by removing 'async' keyword), or use api.runAsyncOnBackendWithManualTransactionHandling()");
|
||||
toastService.showError(t("frontend_script_api.async_warning"));
|
||||
}
|
||||
|
||||
return await this.__runOnBackendInner(func, params, true);
|
||||
@@ -240,8 +239,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
|
||||
*/
|
||||
this.runAsyncOnBackendWithManualTransactionHandling = async (func, params = []) => {
|
||||
if (func?.constructor.name === "Function" || func?.startsWith?.("function")) {
|
||||
toastService.showError("You're passing a synchronous function to api.runAsyncOnBackendWithManualTransactionHandling(), " +
|
||||
"while you should likely use api.runOnBackend() instead.");
|
||||
toastService.showError(t("frontend_script_api.sync_warning"));
|
||||
}
|
||||
|
||||
return await this.__runOnBackendInner(func, params, false);
|
||||
|
||||
@@ -26,21 +26,6 @@ function setupGlobs() {
|
||||
// for CKEditor integration (button on block toolbar)
|
||||
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
|
||||
|
||||
window.glob.SEARCH_HELP_TEXT = `
|
||||
<strong>Search tips</strong> - also see <button class="btn btn-sm" type="button" data-help-page="search.html">complete help on search</button>
|
||||
<p>
|
||||
<ul>
|
||||
<li>Just enter any text for full text search</li>
|
||||
<li><code>#abc</code> - returns notes with label abc</li>
|
||||
<li><code>#year = 2019</code> - matches notes with label <code>year</code> having value <code>2019</code></li>
|
||||
<li><code>#rock #pop</code> - matches notes which have both <code>rock</code> and <code>pop</code> labels</li>
|
||||
<li><code>#rock or #pop</code> - only one of the labels must be present</li>
|
||||
<li><code>#year <= 2000</code> - numerical comparison (also >, >=, <).</li>
|
||||
<li><code>note.dateCreated >= MONTH-1</code> - notes created in the last month</li>
|
||||
<li><code>=handler</code> - will execute script defined in <code>handler</code> relation to get results</li>
|
||||
</ul>
|
||||
</p>`;
|
||||
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
const string = msg.toLowerCase();
|
||||
|
||||
@@ -64,6 +49,28 @@ function setupGlobs() {
|
||||
return false;
|
||||
};
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const string = e?.reason?.message?.toLowerCase();
|
||||
|
||||
let message = "Uncaught error: ";
|
||||
|
||||
if (string?.includes("script error")) {
|
||||
message += 'No details available';
|
||||
} else {
|
||||
message += [
|
||||
`Message: ${e.reason.message}`,
|
||||
`Line: ${e.reason.lineNumber}`,
|
||||
`Column: ${e.reason.columnNumber}`,
|
||||
`Error object: ${JSON.stringify(e.reason)}`,
|
||||
`Stack: ${e.reason && e.reason.stack}`
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
ws.logError(message);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
for (const appCssNoteId of glob.appCssNoteIds || []) {
|
||||
libraryLoader.requireCss(`api/notes/download/${appCssNoteId}`, false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import appContext from "../components/app_context.js";
|
||||
import treeService from "./tree.js";
|
||||
import dialogService from "./dialog.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
function getHoistedNoteId() {
|
||||
const activeNoteContext = appContext.tabManager.getActiveContext();
|
||||
@@ -53,7 +54,7 @@ async function checkNoteAccess(notePath, noteContext) {
|
||||
const hoistedNote = await froca.getNote(hoistedNoteId);
|
||||
|
||||
if ((!hoistedNote.hasAncestor('_hidden') || resolvedNotePath.includes('_lbBookmarks'))
|
||||
&& !await dialogService.confirm(`Requested note '${requestedNote.title}' is outside of hoisted note '${hoistedNote.title}' subtree and you must unhoist to access the note. Do you want to proceed with unhoisting?`)) {
|
||||
&& !await dialogService.confirm(t("hoisted_note.confirm_unhoisting", { requestedNote: requestedNote.title, hoistedNote: hoistedNote.title }))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ function copyImageReferenceToClipboard($imageWrapper) {
|
||||
const success = document.execCommand('copy');
|
||||
|
||||
if (success) {
|
||||
toastService.showMessage("A reference to the image has been copied to clipboard. This can be pasted in any text note.");
|
||||
toastService.showMessage(t("image.copied-to-clipboard"));
|
||||
} else {
|
||||
toastService.showAndLogError("Could not copy the image reference to clipboard.");
|
||||
toastService.showAndLogError(t("image.cannot-copy"));
|
||||
}
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function uploadFiles(entityType, parentNoteId, files, options) {
|
||||
type: 'POST',
|
||||
timeout: 60 * 60 * 1000,
|
||||
error: function (xhr) {
|
||||
toastService.showError(`Import failed: ${xhr.responseText}`);
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false, // NEEDED, DON'T REMOVE THIS
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import mimeTypesService from "./mime_types.js";
|
||||
import optionsService from "./options.js";
|
||||
import { getStylesheetUrl } from "./syntax_highlight.js";
|
||||
|
||||
const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]};
|
||||
|
||||
const CODE_MIRROR = {
|
||||
@@ -84,18 +88,44 @@ const MIND_ELIXIR = {
|
||||
]
|
||||
};
|
||||
|
||||
const HIGHLIGHT_JS = {
|
||||
js: () => {
|
||||
const mimeTypes = mimeTypesService.getMimeTypes();
|
||||
const scriptsToLoad = new Set();
|
||||
scriptsToLoad.add("node_modules/@highlightjs/cdn-assets/highlight.min.js");
|
||||
for (const mimeType of mimeTypes) {
|
||||
if (mimeType.enabled && mimeType.highlightJs) {
|
||||
scriptsToLoad.add(`node_modules/@highlightjs/cdn-assets/languages/${mimeType.highlightJs}.min.js`);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTheme = optionsService.get("codeBlockTheme");
|
||||
loadHighlightingTheme(currentTheme);
|
||||
|
||||
return Array.from(scriptsToLoad);
|
||||
}
|
||||
};
|
||||
|
||||
async function requireLibrary(library) {
|
||||
if (library.css) {
|
||||
library.css.map(cssUrl => requireCss(cssUrl));
|
||||
}
|
||||
|
||||
if (library.js) {
|
||||
for (const scriptUrl of library.js) {
|
||||
for (const scriptUrl of unwrapValue(library.js)) {
|
||||
await requireScript(scriptUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapValue(value) {
|
||||
if (typeof value === "function") {
|
||||
return value();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// we save the promises in case of the same script being required concurrently multiple times
|
||||
const loadedScriptPromises = {};
|
||||
|
||||
@@ -127,9 +157,36 @@ async function requireCss(url, prependAssetPath = true) {
|
||||
}
|
||||
}
|
||||
|
||||
let highlightingThemeEl = null;
|
||||
function loadHighlightingTheme(theme) {
|
||||
if (!theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (theme === "none") {
|
||||
// Deactivate the theme.
|
||||
if (highlightingThemeEl) {
|
||||
highlightingThemeEl.remove();
|
||||
highlightingThemeEl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!highlightingThemeEl) {
|
||||
highlightingThemeEl = $(`<link rel="stylesheet" type="text/css" />`);
|
||||
$("head").append(highlightingThemeEl);
|
||||
}
|
||||
|
||||
const url = getStylesheetUrl(theme);
|
||||
if (url) {
|
||||
highlightingThemeEl.attr("href", url);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
requireCss,
|
||||
requireLibrary,
|
||||
loadHighlightingTheme,
|
||||
CKEDITOR,
|
||||
CODE_MIRROR,
|
||||
ESLINT,
|
||||
@@ -143,5 +200,6 @@ export default {
|
||||
EXCALIDRAW,
|
||||
MARKJS,
|
||||
I18NEXT,
|
||||
MIND_ELIXIR
|
||||
MIND_ELIXIR,
|
||||
HIGHLIGHT_JS
|
||||
}
|
||||
|
||||
@@ -254,8 +254,15 @@ function goToLinkExt(evt, hrefLink, $link) {
|
||||
window.open(hrefLink, '_blank');
|
||||
} else if (hrefLink.toLowerCase().startsWith('file:') && utils.isElectron()) {
|
||||
const electron = utils.dynamicRequire('electron');
|
||||
|
||||
electron.shell.openPath(hrefLink);
|
||||
} else {
|
||||
// Enable protocols supported by CKEditor 5 to be clickable.
|
||||
// Refer to `allowedProtocols` in https://github.com/TriliumNext/trilium-ckeditor5/blob/main/packages/ckeditor5-build-balloon-block/src/ckeditor.ts.
|
||||
// Adding `:` to these links might be safer.
|
||||
const otherAllowedProtocols = ['mailto:', 'tel:', 'sms:', 'sftp:', 'smb:', 'slack:', 'zotero:'];
|
||||
if (otherAllowedProtocols.some(protocol => hrefLink.toLowerCase().startsWith(protocol))){
|
||||
window.open(hrefLink, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +1,171 @@
|
||||
import options from "./options.js";
|
||||
|
||||
/**
|
||||
* A pseudo-MIME type which is used in the editor to automatically determine the language used in code blocks via heuristics.
|
||||
*/
|
||||
const MIME_TYPE_AUTO = "text-x-trilium-auto";
|
||||
|
||||
/**
|
||||
* For highlight.js-supported languages, see https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md.
|
||||
*/
|
||||
|
||||
const MIME_TYPES_DICT = [
|
||||
{ default: true, title: "Plain text", mime: "text/plain" },
|
||||
{ default: true, title: "Plain text", mime: "text/plain", highlightJs: "plaintext" },
|
||||
{ title: "APL", mime: "text/apl" },
|
||||
{ title: "ASN.1", mime: "text/x-ttcn-asn" },
|
||||
{ title: "ASP.NET", mime: "application/x-aspx" },
|
||||
{ title: "Asterisk", mime: "text/x-asterisk" },
|
||||
{ title: "Brainfuck", mime: "text/x-brainfuck" },
|
||||
{ default: true, title: "C", mime: "text/x-csrc" },
|
||||
{ default: true, title: "C#", mime: "text/x-csharp" },
|
||||
{ default: true, title: "C++", mime: "text/x-c++src" },
|
||||
{ title: "Clojure", mime: "text/x-clojure" },
|
||||
{ title: "Brainfuck", mime: "text/x-brainfuck", highlightJs: "brainfuck" },
|
||||
{ default: true, title: "C", mime: "text/x-csrc", highlightJs: "c" },
|
||||
{ default: true, title: "C#", mime: "text/x-csharp", highlightJs: "csharp" },
|
||||
{ default: true, title: "C++", mime: "text/x-c++src", highlightJs: "cpp" },
|
||||
{ title: "Clojure", mime: "text/x-clojure", highlightJs: "clojure" },
|
||||
{ title: "ClojureScript", mime: "text/x-clojurescript" },
|
||||
{ title: "Closure Stylesheets (GSS)", mime: "text/x-gss" },
|
||||
{ title: "CMake", mime: "text/x-cmake" },
|
||||
{ title: "CMake", mime: "text/x-cmake", highlightJs: "cmake" },
|
||||
{ title: "Cobol", mime: "text/x-cobol" },
|
||||
{ title: "CoffeeScript", mime: "text/coffeescript" },
|
||||
{ title: "Common Lisp", mime: "text/x-common-lisp" },
|
||||
{ title: "CoffeeScript", mime: "text/coffeescript", highlightJs: "coffeescript" },
|
||||
{ title: "Common Lisp", mime: "text/x-common-lisp", highlightJs: "lisp" },
|
||||
{ title: "CQL", mime: "text/x-cassandra" },
|
||||
{ title: "Crystal", mime: "text/x-crystal" },
|
||||
{ default: true, title: "CSS", mime: "text/css" },
|
||||
{ title: "Crystal", mime: "text/x-crystal", highlightJs: "crystal" },
|
||||
{ default: true, title: "CSS", mime: "text/css", highlightJs: "css" },
|
||||
{ title: "Cypher", mime: "application/x-cypher-query" },
|
||||
{ title: "Cython", mime: "text/x-cython" },
|
||||
{ title: "D", mime: "text/x-d" },
|
||||
{ title: "Dart", mime: "application/dart" },
|
||||
{ title: "diff", mime: "text/x-diff" },
|
||||
{ title: "Django", mime: "text/x-django" },
|
||||
{ title: "Dockerfile", mime: "text/x-dockerfile" },
|
||||
{ title: "D", mime: "text/x-d", highlightJs: "d" },
|
||||
{ title: "Dart", mime: "application/dart", highlightJs: "dart" },
|
||||
{ title: "diff", mime: "text/x-diff", highlightJs: "diff" },
|
||||
{ title: "Django", mime: "text/x-django", highlightJs: "django" },
|
||||
{ title: "Dockerfile", mime: "text/x-dockerfile", highlightJs: "dockerfile" },
|
||||
{ title: "DTD", mime: "application/xml-dtd" },
|
||||
{ title: "Dylan", mime: "text/x-dylan" },
|
||||
{ title: "EBNF", mime: "text/x-ebnf" },
|
||||
{ title: "EBNF", mime: "text/x-ebnf", highlightJs: "ebnf" },
|
||||
{ title: "ECL", mime: "text/x-ecl" },
|
||||
{ title: "edn", mime: "application/edn" },
|
||||
{ title: "Eiffel", mime: "text/x-eiffel" },
|
||||
{ title: "Elm", mime: "text/x-elm" },
|
||||
{ title: "Elm", mime: "text/x-elm", highlightJs: "elm" },
|
||||
{ title: "Embedded Javascript", mime: "application/x-ejs" },
|
||||
{ title: "Embedded Ruby", mime: "application/x-erb" },
|
||||
{ title: "Erlang", mime: "text/x-erlang" },
|
||||
{ title: "Embedded Ruby", mime: "application/x-erb", highlightJs: "erb" },
|
||||
{ title: "Erlang", mime: "text/x-erlang", highlightJs: "erlang" },
|
||||
{ title: "Esper", mime: "text/x-esper" },
|
||||
{ title: "F#", mime: "text/x-fsharp" },
|
||||
{ title: "F#", mime: "text/x-fsharp", highlightJs: "fsharp" },
|
||||
{ title: "Factor", mime: "text/x-factor" },
|
||||
{ title: "FCL", mime: "text/x-fcl" },
|
||||
{ title: "Forth", mime: "text/x-forth" },
|
||||
{ title: "Fortran", mime: "text/x-fortran" },
|
||||
{ title: "Fortran", mime: "text/x-fortran", highlightJs: "fortran" },
|
||||
{ title: "Gas", mime: "text/x-gas" },
|
||||
{ title: "Gherkin", mime: "text/x-feature" },
|
||||
{ title: "GitHub Flavored Markdown", mime: "text/x-gfm" },
|
||||
{ default: true, title: "Go", mime: "text/x-go" },
|
||||
{ default: true, title: "Groovy", mime: "text/x-groovy" },
|
||||
{ title: "HAML", mime: "text/x-haml" },
|
||||
{ default: true, title: "Haskell", mime: "text/x-haskell" },
|
||||
{ title: "Gherkin", mime: "text/x-feature", highlightJs: "gherkin" },
|
||||
{ title: "GitHub Flavored Markdown", mime: "text/x-gfm", highlightJs: "markdown" },
|
||||
{ default: true, title: "Go", mime: "text/x-go", highlightJs: "go" },
|
||||
{ default: true, title: "Groovy", mime: "text/x-groovy", highlightJs: "groovy" },
|
||||
{ title: "HAML", mime: "text/x-haml", highlightJs: "haml" },
|
||||
{ default: true, title: "Haskell", mime: "text/x-haskell", highlightJs: "haskell" },
|
||||
{ title: "Haskell (Literate)", mime: "text/x-literate-haskell" },
|
||||
{ title: "Haxe", mime: "text/x-haxe" },
|
||||
{ default: true, title: "HTML", mime: "text/html" },
|
||||
{ default: true, title: "HTTP", mime: "message/http" },
|
||||
{ title: "Haxe", mime: "text/x-haxe", highlightJs: "haxe" },
|
||||
{ default: true, title: "HTML", mime: "text/html", highlightJs: "xml" },
|
||||
{ default: true, title: "HTTP", mime: "message/http", highlightJs: "http" },
|
||||
{ title: "HXML", mime: "text/x-hxml" },
|
||||
{ title: "IDL", mime: "text/x-idl" },
|
||||
{ default: true, title: "Java", mime: "text/x-java" },
|
||||
{ title: "Java Server Pages", mime: "application/x-jsp" },
|
||||
{ default: true, title: "Java", mime: "text/x-java", highlightJs: "java" },
|
||||
{ title: "Java Server Pages", mime: "application/x-jsp", highlightJs: "java" },
|
||||
{ title: "Jinja2", mime: "text/jinja2" },
|
||||
{ default: true, title: "JS backend", mime: "application/javascript;env=backend" },
|
||||
{ default: true, title: "JS frontend", mime: "application/javascript;env=frontend" },
|
||||
{ default: true, title: "JSON", mime: "application/json" },
|
||||
{ title: "JSON-LD", mime: "application/ld+json" },
|
||||
{ title: "JSX", mime: "text/jsx" },
|
||||
{ title: "Julia", mime: "text/x-julia" },
|
||||
{ default: true, title: "Kotlin", mime: "text/x-kotlin" },
|
||||
{ title: "LaTeX", mime: "text/x-latex" },
|
||||
{ title: "LESS", mime: "text/x-less" },
|
||||
{ title: "LiveScript", mime: "text/x-livescript" },
|
||||
{ title: "Lua", mime: "text/x-lua" },
|
||||
{ title: "MariaDB SQL", mime: "text/x-mariadb" },
|
||||
{ default: true, title: "Markdown", mime: "text/x-markdown" },
|
||||
{ title: "Mathematica", mime: "text/x-mathematica" },
|
||||
{ default: true, title: "JS backend", mime: "application/javascript;env=backend", highlightJs: "javascript" },
|
||||
{ default: true, title: "JS frontend", mime: "application/javascript;env=frontend", highlightJs: "javascript" },
|
||||
{ default: true, title: "JSON", mime: "application/json", highlightJs: "json" },
|
||||
{ title: "JSON-LD", mime: "application/ld+json", highlightJs: "json" },
|
||||
{ title: "JSX", mime: "text/jsx", highlightJs: "javascript" },
|
||||
{ title: "Julia", mime: "text/x-julia", highlightJs: "julia" },
|
||||
{ default: true, title: "Kotlin", mime: "text/x-kotlin", highlightJs: "kotlin" },
|
||||
{ title: "LaTeX", mime: "text/x-latex", highlightJs: "latex" },
|
||||
{ title: "LESS", mime: "text/x-less", highlightJs: "less" },
|
||||
{ title: "LiveScript", mime: "text/x-livescript", highlightJs: "livescript" },
|
||||
{ title: "Lua", mime: "text/x-lua", highlightJs: "lua" },
|
||||
{ title: "MariaDB SQL", mime: "text/x-mariadb", highlightJs: "sql" },
|
||||
{ default: true, title: "Markdown", mime: "text/x-markdown", highlightJs: "markdown" },
|
||||
{ title: "Mathematica", mime: "text/x-mathematica", highlightJs: "mathematica" },
|
||||
{ title: "mbox", mime: "application/mbox" },
|
||||
{ title: "mIRC", mime: "text/mirc" },
|
||||
{ title: "Modelica", mime: "text/x-modelica" },
|
||||
{ title: "MS SQL", mime: "text/x-mssql" },
|
||||
{ title: "MS SQL", mime: "text/x-mssql", highlightJs: "sql" },
|
||||
{ title: "mscgen", mime: "text/x-mscgen" },
|
||||
{ title: "msgenny", mime: "text/x-msgenny" },
|
||||
{ title: "MUMPS", mime: "text/x-mumps" },
|
||||
{ title: "MySQL", mime: "text/x-mysql" },
|
||||
{ title: "Nginx", mime: "text/x-nginx-conf" },
|
||||
{ title: "NSIS", mime: "text/x-nsis" },
|
||||
{ title: "MySQL", mime: "text/x-mysql", highlightJs: "sql" },
|
||||
{ title: "Nginx", mime: "text/x-nginx-conf", highlightJs: "nginx" },
|
||||
{ title: "NSIS", mime: "text/x-nsis", highlightJs: "nsis" },
|
||||
{ title: "NTriples", mime: "application/n-triples" },
|
||||
{ title: "Objective-C", mime: "text/x-objectivec" },
|
||||
{ title: "OCaml", mime: "text/x-ocaml" },
|
||||
{ title: "Objective-C", mime: "text/x-objectivec", highlightJs: "objectivec" },
|
||||
{ title: "OCaml", mime: "text/x-ocaml", highlightJs: "ocaml" },
|
||||
{ title: "Octave", mime: "text/x-octave" },
|
||||
{ title: "Oz", mime: "text/x-oz" },
|
||||
{ title: "Pascal", mime: "text/x-pascal" },
|
||||
{ title: "Pascal", mime: "text/x-pascal", highlightJs: "delphi" },
|
||||
{ title: "PEG.js", mime: "null" },
|
||||
{ default: true, title: "Perl", mime: "text/x-perl" },
|
||||
{ title: "PGP", mime: "application/pgp" },
|
||||
{ default: true, title: "PHP", mime: "text/x-php" },
|
||||
{ title: "Pig", mime: "text/x-pig" },
|
||||
{ title: "PLSQL", mime: "text/x-plsql" },
|
||||
{ title: "PostgreSQL", mime: "text/x-pgsql" },
|
||||
{ title: "PowerShell", mime: "application/x-powershell" },
|
||||
{ title: "Properties files", mime: "text/x-properties" },
|
||||
{ title: "ProtoBuf", mime: "text/x-protobuf" },
|
||||
{ title: "PLSQL", mime: "text/x-plsql", highlightJs: "sql" },
|
||||
{ title: "PostgreSQL", mime: "text/x-pgsql", highlightJs: "pgsql" },
|
||||
{ title: "PowerShell", mime: "application/x-powershell", highlightJs: "powershell" },
|
||||
{ title: "Properties files", mime: "text/x-properties", highlightJs: "properties" },
|
||||
{ title: "ProtoBuf", mime: "text/x-protobuf", highlightJs: "protobuf" },
|
||||
{ title: "Pug", mime: "text/x-pug" },
|
||||
{ title: "Puppet", mime: "text/x-puppet" },
|
||||
{ default: true, title: "Python", mime: "text/x-python" },
|
||||
{ title: "Q", mime: "text/x-q" },
|
||||
{ title: "R", mime: "text/x-rsrc" },
|
||||
{ title: "Puppet", mime: "text/x-puppet", highlightJs: "puppet" },
|
||||
{ default: true, title: "Python", mime: "text/x-python", highlightJs: "python" },
|
||||
{ title: "Q", mime: "text/x-q", highlightJs: "q" },
|
||||
{ title: "R", mime: "text/x-rsrc", highlightJs: "r" },
|
||||
{ title: "reStructuredText", mime: "text/x-rst" },
|
||||
{ title: "RPM Changes", mime: "text/x-rpm-changes" },
|
||||
{ title: "RPM Spec", mime: "text/x-rpm-spec" },
|
||||
{ default: true, title: "Ruby", mime: "text/x-ruby" },
|
||||
{ title: "Rust", mime: "text/x-rustsrc" },
|
||||
{ title: "SAS", mime: "text/x-sas" },
|
||||
{ default: true, title: "Ruby", mime: "text/x-ruby", highlightJs: "ruby" },
|
||||
{ title: "Rust", mime: "text/x-rustsrc", highlightJs: "rust" },
|
||||
{ title: "SAS", mime: "text/x-sas", highlightJs: "sas" },
|
||||
{ title: "Sass", mime: "text/x-sass" },
|
||||
{ title: "Scala", mime: "text/x-scala" },
|
||||
{ title: "Scheme", mime: "text/x-scheme" },
|
||||
{ title: "SCSS", mime: "text/x-scss" },
|
||||
{ default: true, title: "Shell (bash)", mime: "text/x-sh" },
|
||||
{ title: "SCSS", mime: "text/x-scss", highlightJs: "scss" },
|
||||
{ default: true, title: "Shell (bash)", mime: "text/x-sh", highlightJs: "bash" },
|
||||
{ title: "Sieve", mime: "application/sieve" },
|
||||
{ title: "Slim", mime: "text/x-slim" },
|
||||
{ title: "Smalltalk", mime: "text/x-stsrc" },
|
||||
{ title: "Smalltalk", mime: "text/x-stsrc", highlightJs: "smalltalk" },
|
||||
{ title: "Smarty", mime: "text/x-smarty" },
|
||||
{ title: "SML", mime: "text/x-sml" },
|
||||
{ title: "SML", mime: "text/x-sml", highlightJs: "sml" },
|
||||
{ title: "Solr", mime: "text/x-solr" },
|
||||
{ title: "Soy", mime: "text/x-soy" },
|
||||
{ title: "SPARQL", mime: "application/sparql-query" },
|
||||
{ title: "Spreadsheet", mime: "text/x-spreadsheet" },
|
||||
{ default: true, title: "SQL", mime: "text/x-sql" },
|
||||
{ title: "SQLite", mime: "text/x-sqlite" },
|
||||
{ default: true, title: "SQLite (Trilium)", mime: "text/x-sqlite;schema=trilium" },
|
||||
{ default: true, title: "SQL", mime: "text/x-sql", highlightJs: "sql" },
|
||||
{ title: "SQLite", mime: "text/x-sqlite", highlightJs: "sql" },
|
||||
{ default: true, title: "SQLite (Trilium)", mime: "text/x-sqlite;schema=trilium", highlightJs: "sql" },
|
||||
{ title: "Squirrel", mime: "text/x-squirrel" },
|
||||
{ title: "sTeX", mime: "text/x-stex" },
|
||||
{ title: "Stylus", mime: "text/x-styl" },
|
||||
{ title: "Stylus", mime: "text/x-styl", highlightJs: "stylus" },
|
||||
{ default: true, title: "Swift", mime: "text/x-swift" },
|
||||
{ title: "SystemVerilog", mime: "text/x-systemverilog" },
|
||||
{ title: "Tcl", mime: "text/x-tcl" },
|
||||
{ title: "Tcl", mime: "text/x-tcl", highlightJs: "tcl" },
|
||||
{ title: "Textile", mime: "text/x-textile" },
|
||||
{ title: "TiddlyWiki ", mime: "text/x-tiddlywiki" },
|
||||
{ title: "Tiki wiki", mime: "text/tiki" },
|
||||
{ title: "TOML", mime: "text/x-toml" },
|
||||
{ title: "TOML", mime: "text/x-toml", highlightJs: "ini" },
|
||||
{ title: "Tornado", mime: "text/x-tornado" },
|
||||
{ title: "troff", mime: "text/troff" },
|
||||
{ title: "TTCN", mime: "text/x-ttcn" },
|
||||
{ title: "TTCN_CFG", mime: "text/x-ttcn-cfg" },
|
||||
{ title: "Turtle", mime: "text/turtle" },
|
||||
{ title: "Twig", mime: "text/x-twig" },
|
||||
{ title: "TypeScript", mime: "application/typescript" },
|
||||
{ title: "Twig", mime: "text/x-twig", highlightJs: "twig" },
|
||||
{ title: "TypeScript", mime: "application/typescript", highlightJs: "typescript" },
|
||||
{ title: "TypeScript-JSX", mime: "text/typescript-jsx" },
|
||||
{ title: "VB.NET", mime: "text/x-vb" },
|
||||
{ title: "VBScript", mime: "text/vbscript" },
|
||||
{ title: "VB.NET", mime: "text/x-vb", highlightJs: "vbnet" },
|
||||
{ title: "VBScript", mime: "text/vbscript", highlightJs: "vbscript" },
|
||||
{ title: "Velocity", mime: "text/velocity" },
|
||||
{ title: "Verilog", mime: "text/x-verilog" },
|
||||
{ title: "VHDL", mime: "text/x-vhdl" },
|
||||
{ title: "Verilog", mime: "text/x-verilog", highlightJs: "verilog" },
|
||||
{ title: "VHDL", mime: "text/x-vhdl", highlightJs: "vhdl" },
|
||||
{ title: "Vue.js Component", mime: "text/x-vue" },
|
||||
{ title: "Web IDL", mime: "text/x-webidl" },
|
||||
{ default: true, title: "XML", mime: "text/xml" },
|
||||
{ title: "XQuery", mime: "application/xquery" },
|
||||
{ default: true, title: "XML", mime: "text/xml", highlightJs: "xml" },
|
||||
{ title: "XQuery", mime: "application/xquery", highlightJs: "xquery" },
|
||||
{ title: "xu", mime: "text/x-xu" },
|
||||
{ title: "Yacas", mime: "text/x-yacas" },
|
||||
{ default: true, title: "YAML", mime: "text/x-yaml" },
|
||||
{ default: true, title: "YAML", mime: "text/x-yaml", highlightJs: "yaml" },
|
||||
{ title: "Z80", mime: "text/x-z80" }
|
||||
];
|
||||
|
||||
@@ -173,7 +182,7 @@ function loadMimeTypes() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getMimeTypes() {
|
||||
function getMimeTypes() {
|
||||
if (mimeTypes === null) {
|
||||
loadMimeTypes();
|
||||
}
|
||||
@@ -181,7 +190,46 @@ async function getMimeTypes() {
|
||||
return mimeTypes;
|
||||
}
|
||||
|
||||
export default {
|
||||
getMimeTypes,
|
||||
loadMimeTypes
|
||||
let mimeToHighlightJsMapping = null;
|
||||
|
||||
/**
|
||||
* Obtains the corresponding language tag for highlight.js for a given MIME type.
|
||||
*
|
||||
* The mapping is built the first time this method is built and then the results are cached for better performance.
|
||||
*
|
||||
* @param {string} mimeType The MIME type of the code block, in the CKEditor-normalized format (e.g. `text-c-src` instead of `text/c-src`).
|
||||
* @returns the corresponding highlight.js tag, for example `c` for `text-c-src`.
|
||||
*/
|
||||
function getHighlightJsNameForMime(mimeType) {
|
||||
if (!mimeToHighlightJsMapping) {
|
||||
const mimeTypes = getMimeTypes();
|
||||
mimeToHighlightJsMapping = {};
|
||||
for (const mimeType of mimeTypes) {
|
||||
// The mime stored by CKEditor is text-x-csrc instead of text/x-csrc so we keep this format for faster lookup.
|
||||
const normalizedMime = normalizeMimeTypeForCKEditor(mimeType.mime);
|
||||
mimeToHighlightJsMapping[normalizedMime] = mimeType.highlightJs;
|
||||
}
|
||||
}
|
||||
|
||||
return mimeToHighlightJsMapping[mimeType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a MIME type in the usual format (e.g. `text/csrc`), it returns a MIME type that can be passed down to the CKEditor
|
||||
* code plugin.
|
||||
*
|
||||
* @param {string} mimeType The MIME type to normalize, in the usual format (e.g. `text/c-src`).
|
||||
* @returns the normalized MIME type (e.g. `text-c-src`).
|
||||
*/
|
||||
function normalizeMimeTypeForCKEditor(mimeType) {
|
||||
return mimeType.toLowerCase()
|
||||
.replace(/[\W_]+/g,"-");
|
||||
}
|
||||
|
||||
export default {
|
||||
MIME_TYPE_AUTO,
|
||||
getMimeTypes,
|
||||
loadMimeTypes,
|
||||
getHighlightJsNameForMime,
|
||||
normalizeMimeTypeForCKEditor
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ async function autocompleteSource(term, cb, options = {}) {
|
||||
].concat(results);
|
||||
}
|
||||
|
||||
if (term.trim().length >= 1 && options.allowSearchNotes) {
|
||||
results = results.concat([
|
||||
{
|
||||
action: 'search-notes',
|
||||
noteTitle: term,
|
||||
highlightedNotePathTitle: `Search for "${utils.escapeHtml(term)}" <kbd style='color: var(--muted-text-color); background-color: transparent; float: right;'>Ctrl+Enter</kbd>`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) {
|
||||
results = [
|
||||
{
|
||||
@@ -138,6 +148,17 @@ function initNoteAutocomplete($el, options) {
|
||||
autocompleteOptions.debug = true; // don't close on blur
|
||||
}
|
||||
|
||||
if (options.allowSearchNotes) {
|
||||
$el.on('keydown', (event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
// Prevent Ctrl + Enter from triggering autoComplete.
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
$el.trigger('autocomplete:selected', { action: 'search-notes', noteTitle: $el.autocomplete("val")});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$el.autocomplete({
|
||||
...autocompleteOptions,
|
||||
appendTo: document.querySelector('body'),
|
||||
@@ -192,6 +213,12 @@ function initNoteAutocomplete($el, options) {
|
||||
suggestion.notePath = note.getBestNotePathString(hoistedNoteId);
|
||||
}
|
||||
|
||||
if (suggestion.action === 'search-notes') {
|
||||
const searchString = suggestion.noteTitle;
|
||||
appContext.triggerCommand('searchNotes', { searchString });
|
||||
return;
|
||||
}
|
||||
|
||||
$el.setSelectedNotePath(suggestion.notePath);
|
||||
$el.setSelectedExternalLink(null);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import ws from "./ws.js";
|
||||
import froca from "./froca.js";
|
||||
import treeService from "./tree.js";
|
||||
import toastService from "./toast.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
async function createNote(parentNotePath, options = {}) {
|
||||
options = Object.assign({
|
||||
@@ -119,7 +120,7 @@ async function duplicateSubtree(noteId, parentNotePath) {
|
||||
activeNoteContext.setNote(`${parentNotePath}/${note.noteId}`);
|
||||
|
||||
const origNote = await froca.getNote(noteId);
|
||||
toastService.showMessage(`Note "${origNote.title}" has been duplicated`);
|
||||
toastService.showMessage(t("note_create.duplicated", { title: origNote.title }));
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -366,12 +366,13 @@ class NoteListRenderer {
|
||||
separateWordSearch: false,
|
||||
caseSensitive: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$content.append($renderedContent);
|
||||
$content.addClass(`type-${type}`);
|
||||
} catch (e) {
|
||||
console.log(`Caught error while rendering note '${note.noteId}' of type '${note.type}': ${e.message}, stack: ${e.stack}`);
|
||||
console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`);
|
||||
console.error(e);
|
||||
|
||||
$content.append("rendering error");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import appContext from "../components/app_context.js";
|
||||
import froca from "./froca.js";
|
||||
import utils from "./utils.js";
|
||||
import options from "./options.js";
|
||||
import { t } from './i18n.js';
|
||||
|
||||
let protectedSessionDeferred = null;
|
||||
|
||||
@@ -50,7 +51,7 @@ async function setupProtectedSession(password) {
|
||||
const response = await server.post('login/protected', { password: password });
|
||||
|
||||
if (!response.success) {
|
||||
toastService.showError("Wrong password.", 3000);
|
||||
toastService.showError(t("protected_session.wrong_password"), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ ws.subscribeToMessages(async message => {
|
||||
protectedSessionDeferred = null;
|
||||
}
|
||||
|
||||
toastService.showMessage("Protected session has been started.");
|
||||
toastService.showMessage(t("protected_session.started"));
|
||||
}
|
||||
else if (message.type === 'protectedSessionLogout') {
|
||||
utils.reloadFrontendApp(`Protected session logout`);
|
||||
@@ -85,10 +86,10 @@ async function protectNote(noteId, protect, includingSubtree) {
|
||||
await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`);
|
||||
}
|
||||
|
||||
function makeToast(message, protectingLabel, text) {
|
||||
function makeToast(message, title, text) {
|
||||
return {
|
||||
id: message.taskId,
|
||||
title: `${protectingLabel} status`,
|
||||
title,
|
||||
message: text,
|
||||
icon: message.data.protect ? "check-shield" : "shield"
|
||||
};
|
||||
@@ -99,15 +100,19 @@ ws.subscribeToMessages(async message => {
|
||||
return;
|
||||
}
|
||||
|
||||
const protectingLabel = message.data.protect ? "Protecting" : "Unprotecting";
|
||||
|
||||
const isProtecting = message.data.protect;
|
||||
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
|
||||
|
||||
if (message.type === 'taskError') {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === 'taskProgressCount') {
|
||||
toastService.showPersistent(makeToast(message, protectingLabel,`${protectingLabel} in progress: ${message.progressCount}`));
|
||||
const count = message.progressCount;
|
||||
const text = ( isProtecting ? t("protected_session.protecting-in-progress", { count }) : t("protected_session.unprotecting-in-progress-count", { count }));
|
||||
toastService.showPersistent(makeToast(message, title, text));
|
||||
} else if (message.type === 'taskSucceeded') {
|
||||
const toast = makeToast(message, protectingLabel, `${protectingLabel} finished successfully.`);
|
||||
const text = (isProtecting ? t("protected_session.protecting-finished-successfully") : t("protected_session.unprotecting-finished-successfully"))
|
||||
const toast = makeToast(message, title, text);
|
||||
toast.closeAfter = 3000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from './i18n.js';
|
||||
import server from './server.js';
|
||||
import toastService from "./toast.js";
|
||||
|
||||
@@ -5,7 +6,7 @@ async function syncNow(ignoreNotConfigured = false) {
|
||||
const result = await server.post('sync/now');
|
||||
|
||||
if (result.success) {
|
||||
toastService.showMessage("Sync finished successfully.");
|
||||
toastService.showMessage(t("sync.finished-successfully"));
|
||||
}
|
||||
else {
|
||||
if (result.message.length > 200) {
|
||||
@@ -13,7 +14,7 @@ async function syncNow(ignoreNotConfigured = false) {
|
||||
}
|
||||
|
||||
if (!ignoreNotConfigured || result.errorCode !== 'NOT_CONFIGURED') {
|
||||
toastService.showError(`Sync failed: ${result.message}`);
|
||||
toastService.showError(t("sync.failed", { message: result.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
94
src/public/app/services/syntax_highlight.js
Normal file
94
src/public/app/services/syntax_highlight.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import library_loader from "./library_loader.js";
|
||||
import mime_types from "./mime_types.js";
|
||||
import options from "./options.js";
|
||||
|
||||
export function getStylesheetUrl(theme) {
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultPrefix = "default:";
|
||||
if (theme.startsWith(defaultPrefix)) {
|
||||
return `${window.glob.assetPath}/node_modules/@highlightjs/cdn-assets/styles/${theme.substr(defaultPrefix.length)}.min.css`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies all the code blocks (as `pre code`) under the specified hierarchy and uses the highlight.js library to obtain the highlighted text which is then applied on to the code blocks.
|
||||
*
|
||||
* @param $container the container under which to look for code blocks and to apply syntax highlighting to them.
|
||||
*/
|
||||
export async function applySyntaxHighlight($container) {
|
||||
if (!isSyntaxHighlightEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const codeBlocks = $container.find("pre code");
|
||||
for (const codeBlock of codeBlocks) {
|
||||
const normalizedMimeType = extractLanguageFromClassList(codeBlock);
|
||||
if (!normalizedMimeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
applySingleBlockSyntaxHighlight($(codeBlock, normalizedMimeType));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
|
||||
*
|
||||
* @param {*} $codeBlock
|
||||
* @param {*} normalizedMimeType
|
||||
*/
|
||||
export async function applySingleBlockSyntaxHighlight($codeBlock, normalizedMimeType) {
|
||||
$codeBlock.parent().toggleClass("hljs");
|
||||
const text = $codeBlock.text();
|
||||
|
||||
if (!window.hljs) {
|
||||
await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
|
||||
}
|
||||
|
||||
let highlightedText = null;
|
||||
if (normalizedMimeType === mime_types.MIME_TYPE_AUTO) {
|
||||
highlightedText = hljs.highlightAuto(text);
|
||||
} else if (normalizedMimeType) {
|
||||
const language = mime_types.getHighlightJsNameForMime(normalizedMimeType);
|
||||
if (language) {
|
||||
highlightedText = hljs.highlight(text, { language });
|
||||
} else {
|
||||
console.warn(`Unknown mime type: ${normalizedMimeType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (highlightedText) {
|
||||
$codeBlock.html(highlightedText.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether syntax highlighting should be enabled for code blocks, by querying the value of the `codeblockTheme` option.
|
||||
* @returns whether syntax highlighting should be enabled for code blocks.
|
||||
*/
|
||||
export function isSyntaxHighlightEnabled() {
|
||||
const theme = options.get("codeBlockTheme");
|
||||
return theme && theme !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a HTML element, tries to extract the `language-` class name out of it.
|
||||
*
|
||||
* @param {string} el the HTML element from which to extract the language tag.
|
||||
* @returns the normalized MIME type (e.g. `text-css` instead of `language-text-css`).
|
||||
*/
|
||||
function extractLanguageFromClassList(el) {
|
||||
const prefix = "language-";
|
||||
for (const className of el.classList) {
|
||||
if (className.startsWith(prefix)) {
|
||||
return className.substr(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ function toast(options) {
|
||||
);
|
||||
|
||||
$toast.find('.toast-title').text(options.title);
|
||||
$toast.find('.toast-body').text(options.message);
|
||||
$toast.find('.toast-body').html(options.message);
|
||||
|
||||
if (options.id) {
|
||||
$toast.attr("id", `toast-${options.id}`);
|
||||
|
||||
@@ -527,6 +527,58 @@ function downloadSvg(nameWithoutExtension, svgContent) {
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings.
|
||||
* Returns:
|
||||
* 1 if v1 is greater than v2
|
||||
* 0 if v1 is equal to v2
|
||||
* -1 if v1 is less than v2
|
||||
*
|
||||
* @param {string} v1 First version string
|
||||
* @param {string} v2 Second version string
|
||||
* @returns {number}
|
||||
*/
|
||||
function compareVersions(v1, v2) {
|
||||
|
||||
// Remove 'v' prefix and everything after dash if present
|
||||
v1 = v1.replace(/^v/, '').split('-')[0];
|
||||
v2 = v2.replace(/^v/, '').split('-')[0];
|
||||
|
||||
const v1parts = v1.split('.').map(Number);
|
||||
const v2parts = v2.split('.').map(Number);
|
||||
|
||||
// Pad shorter version with zeros
|
||||
while (v1parts.length < 3) v1parts.push(0);
|
||||
while (v2parts.length < 3) v2parts.push(0);
|
||||
|
||||
// Compare major version
|
||||
if (v1parts[0] !== v2parts[0]) {
|
||||
return v1parts[0] > v2parts[0] ? 1 : -1;
|
||||
}
|
||||
|
||||
// Compare minor version
|
||||
if (v1parts[1] !== v2parts[1]) {
|
||||
return v1parts[1] > v2parts[1] ? 1 : -1;
|
||||
}
|
||||
|
||||
// Compare patch version
|
||||
if (v1parts[2] !== v2parts[2]) {
|
||||
return v1parts[2] > v2parts[2] ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
* @param {string} latestVersion
|
||||
* @param {string} currentVersion
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isUpdateAvailable(latestVersion, currentVersion) {
|
||||
return compareVersions(latestVersion, currentVersion) > 0;
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
parseDate,
|
||||
@@ -567,5 +619,7 @@ export default {
|
||||
areObjectsEqual,
|
||||
copyHtmlToClipboard,
|
||||
createImageSrcUrl,
|
||||
downloadSvg
|
||||
downloadSvg,
|
||||
compareVersions,
|
||||
isUpdateAvailable
|
||||
};
|
||||
|
||||
@@ -114,10 +114,10 @@ async function handleMessage(event) {
|
||||
await executeFrontendUpdate(message.data.entityChanges);
|
||||
}
|
||||
else if (message.type === 'sync-hash-check-failed') {
|
||||
toastService.showError("Sync check failed!", 60000);
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
}
|
||||
else if (message.type === 'consistency-checks-failed') {
|
||||
toastService.showError("Consistency checks failed! See logs for details.", 50 * 60000);
|
||||
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
}
|
||||
else if (message.type === 'api-log-messages') {
|
||||
appContext.triggerEvent("apiLogMessages", {noteId: message.noteId, messages: message.messages});
|
||||
@@ -189,7 +189,7 @@ async function consumeFrontendUpdateData() {
|
||||
else {
|
||||
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
|
||||
|
||||
toastService.showError(`Encountered error "${e.message}", check out the console.`);
|
||||
toastService.showError(t("ws.encountered-error", { message: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,8 +347,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.$editor.on("click", e => this.handleEditorClick(e));
|
||||
|
||||
/** @property {BalloonEditor} */
|
||||
this.textEditor = await BalloonEditor.create(this.$editor[0], editorConfig);
|
||||
this.textEditor = await CKEditor.BalloonEditor.create(this.$editor[0], editorConfig);
|
||||
this.textEditor.model.document.on('change:data', () => this.dataChanged());
|
||||
this.textEditor.editing.view.document.on('enter', (event, data) => {
|
||||
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
|
||||
@@ -358,9 +357,6 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
|
||||
|
||||
// disable spellcheck for attribute editor
|
||||
this.textEditor.editing.view.change(writer => writer.setAttribute('spellcheck', 'false', this.textEditor.editing.view.document.getRoot()));
|
||||
|
||||
//await import(/* webpackIgnore: true */'../../libraries/ckeditor/inspector');
|
||||
//CKEditorInspector.attach(this.textEditor);
|
||||
}
|
||||
|
||||
dataChanged() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Component from "../components/component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import toastService from "../services/toast.js";
|
||||
|
||||
@@ -84,15 +85,8 @@ class BasicWidget extends Component {
|
||||
render() {
|
||||
try {
|
||||
this.doRender();
|
||||
} catch (e) {
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.widget-error.message", {
|
||||
title: this.widgetTitle,
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
this.logRenderingError(e);
|
||||
}
|
||||
|
||||
this.$widget.attr('data-component-id', this.componentId);
|
||||
@@ -131,6 +125,35 @@ class BasicWidget extends Component {
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
logRenderingError(e) {
|
||||
console.log("Got issue in widget ", this);
|
||||
console.error(e);
|
||||
|
||||
let noteId = this._noteId;
|
||||
if (this._noteId) {
|
||||
froca.getNote(noteId, true).then((note) => {
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.widget-error.message-custom", {
|
||||
id: noteId,
|
||||
title: note.title,
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.widget-error.message-unknown", {
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the widget is enabled. Widgets are enabled by default. Generally setting this to `false` will cause the widget not to be displayed, however it will still be available on the DOM but hidden.
|
||||
* @returns
|
||||
|
||||
@@ -20,6 +20,13 @@ const TPL = `
|
||||
width: 20em;
|
||||
}
|
||||
|
||||
.attachment-actions .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
@@ -32,16 +39,39 @@ const TPL = `
|
||||
style="position: relative; top: 3px;"></button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a data-trigger-command="openAttachment" class="dropdown-item"
|
||||
title="${t('attachments_actions.open_externally_title')}">${t('attachments_actions.open_externally')}</a>
|
||||
<a data-trigger-command="openAttachmentCustom" class="dropdown-item"
|
||||
title="${t('attachments_actions.open_custom_title')}">${t('attachments_actions.open_custom')}</a>
|
||||
<a data-trigger-command="downloadAttachment" class="dropdown-item">${t('attachments_actions.download')}</a>
|
||||
<a data-trigger-command="renameAttachment" class="dropdown-item">${t('attachments_actions.rename_attachment')}</a>
|
||||
<a data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item">${t('attachments_actions.upload_new_revision')}</a>
|
||||
<a data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item">${t('attachments_actions.copy_link_to_clipboard')}</a>
|
||||
<a data-trigger-command="convertAttachmentIntoNote" class="dropdown-item">${t('attachments_actions.convert_attachment_into_note')}</a>
|
||||
<a data-trigger-command="deleteAttachment" class="dropdown-item">${t('attachments_actions.delete_attachment')}</a>
|
||||
|
||||
<li data-trigger-command="openAttachment" class="dropdown-item"
|
||||
title="${t('attachments_actions.open_externally_title')}"><span class="bx bx-file-find"></span> ${t('attachments_actions.open_externally')}</li>
|
||||
|
||||
<li data-trigger-command="openAttachmentCustom" class="dropdown-item"
|
||||
title="${t('attachments_actions.open_custom_title')}"><span class="bx bx-customize"></span> ${t('attachments_actions.open_custom')}</li>
|
||||
|
||||
<li data-trigger-command="downloadAttachment" class="dropdown-item">
|
||||
<span class="bx bx-download"></span> ${t('attachments_actions.download')}</li>
|
||||
|
||||
<li data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item"><span class="bx bx-link">
|
||||
</span> ${t('attachments_actions.copy_link_to_clipboard')}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item"><span class="bx bx-upload">
|
||||
</span> ${t('attachments_actions.upload_new_revision')}</li>
|
||||
|
||||
<li data-trigger-command="renameAttachment" class="dropdown-item">
|
||||
<span class="bx bx-rename"></span> ${t('attachments_actions.rename_attachment')}</li>
|
||||
|
||||
<li data-trigger-command="deleteAttachment" class="dropdown-item">
|
||||
<span class="bx bx-trash destructive-action-icon"></span> ${t('attachments_actions.delete_attachment')}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="convertAttachmentIntoNote" class="dropdown-item"><span class="bx bx-note">
|
||||
</span> ${t('attachments_actions.convert_attachment_into_note')}</li>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="file" class="attachment-upload-new-revision-input" style="display: none">
|
||||
@@ -83,14 +113,14 @@ export default class AttachmentActionsWidget extends BasicWidget {
|
||||
const $openAttachmentButton = this.$widget.find("[data-trigger-command='openAttachment']");
|
||||
$openAttachmentButton
|
||||
.addClass("disabled")
|
||||
.append($('<span class="disabled-tooltip"> (?)</span>')
|
||||
.append($('<span class="bx bx-info-circle disabled-tooltip" />')
|
||||
.attr("title", t('attachments_actions.open_externally_detail_page'))
|
||||
);
|
||||
if (isElectron) {
|
||||
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
|
||||
$openAttachmentCustomButton
|
||||
.addClass("disabled")
|
||||
.append($('<span class="disabled-tooltip"> (?)</span>')
|
||||
.append($('<span class="bx bx-info-circle disabled-tooltip" />')
|
||||
.attr("title", t('attachments_actions.open_externally_detail_page'))
|
||||
);
|
||||
}
|
||||
@@ -99,7 +129,7 @@ export default class AttachmentActionsWidget extends BasicWidget {
|
||||
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
|
||||
$openAttachmentCustomButton
|
||||
.addClass("disabled")
|
||||
.append($('<span class="disabled-tooltip"> (?)</span>')
|
||||
.append($('<span class="bx bx-info-circle disabled-tooltip" />')
|
||||
.attr("title", t('attachments_actions.open_custom_client_only'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ const TPL = `
|
||||
position: relative;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
--dropdown-shadow-opacity: 0;
|
||||
--submenu-opening-delay: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,28 +127,20 @@ const TPL = `
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li class="dropdown-item" data-trigger-command="showOptions">
|
||||
<span class="bx bx-cog"></span>
|
||||
${t('global_menu.options')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="openNewWindow">
|
||||
<span class="bx bx-window-open"></span>
|
||||
${t('global_menu.open_new_window')}
|
||||
<kbd data-command="openNewWindow"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item switch-to-mobile-version-button" data-trigger-command="switchToMobileVersion">
|
||||
<span class="bx bx-mobile"></span>
|
||||
${t('global_menu.switch_to_mobile_version')}
|
||||
<li class="dropdown-item" data-trigger-command="showShareSubtree">
|
||||
<span class="bx bx-share-alt"></span>
|
||||
${t('global_menu.show_shared_notes_subtree')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item switch-to-desktop-version-button" data-trigger-command="switchToDesktopVersion">
|
||||
<span class="bx bx-desktop"></span>
|
||||
${t('global_menu.switch_to_desktop_version')}
|
||||
</li>
|
||||
|
||||
<span class="zoom-container dropdown-item">
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<span class="zoom-container dropdown-item dropdown-item-container">
|
||||
<div>
|
||||
<span class="bx bx-empty"></span>
|
||||
${t('global_menu.zoom')}
|
||||
@@ -165,16 +159,23 @@ const TPL = `
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="dropdown-divider zoom-container-separator"></div>
|
||||
|
||||
<li class="dropdown-item switch-to-mobile-version-button" data-trigger-command="switchToMobileVersion">
|
||||
<span class="bx bx-mobile"></span>
|
||||
${t('global_menu.switch_to_mobile_version')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item switch-to-desktop-version-button" data-trigger-command="switchToDesktopVersion">
|
||||
<span class="bx bx-desktop"></span>
|
||||
${t('global_menu.switch_to_desktop_version')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showLaunchBarSubtree">
|
||||
<span class="bx bx-sidebar"></span>
|
||||
${t('global_menu.configure_launchbar')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showShareSubtree">
|
||||
<span class="bx bx-share-alt"></span>
|
||||
${t('global_menu.show_shared_notes_subtree')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item dropdown-submenu">
|
||||
<span class="dropdown-toggle">
|
||||
<span class="bx bx-chip"></span>
|
||||
@@ -182,10 +183,22 @@ const TPL = `
|
||||
</span>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
|
||||
<span class="bx bx-bug-alt"></span>
|
||||
${t('global_menu.open_dev_tools')}
|
||||
<kbd data-command="openDevTools"></kbd>
|
||||
<li class="dropdown-item" data-trigger-command="showHiddenSubtree">
|
||||
<span class="bx bx-hide"></span>
|
||||
${t('global_menu.show_hidden_subtree')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSearchHistory">
|
||||
<span class="bx bx-search-alt"></span>
|
||||
${t('global_menu.open_search_history')}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showBackendLog">
|
||||
<span class="bx bx-detail"></span>
|
||||
${t('global_menu.show_backend_log')}
|
||||
<kbd data-command="showBackendLog"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSQLConsole">
|
||||
@@ -198,16 +211,13 @@ const TPL = `
|
||||
<span class="bx bx-data"></span>
|
||||
${t('global_menu.open_sql_console_history')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSearchHistory">
|
||||
<span class="bx bx-search-alt"></span>
|
||||
${t('global_menu.open_search_history')}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showBackendLog">
|
||||
<span class="bx bx-detail"></span>
|
||||
${t('global_menu.show_backend_log')}
|
||||
<kbd data-command="showBackendLog"></kbd>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<li class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
|
||||
<span class="bx bx-bug-alt"></span>
|
||||
${t('global_menu.open_dev_tools')}
|
||||
<kbd data-command="openDevTools"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="reloadFrontendApp"
|
||||
@@ -217,13 +227,16 @@ const TPL = `
|
||||
<kbd data-command="reloadFrontendApp"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showHiddenSubtree">
|
||||
<span class="bx bx-hide"></span>
|
||||
${t('global_menu.show_hidden_subtree')}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showOptions">
|
||||
<span class="bx bx-cog"></span>
|
||||
${t('global_menu.options')}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<li class="dropdown-item show-help-button" data-trigger-command="showHelp">
|
||||
<span class="bx bx-help-circle"></span>
|
||||
${t('global_menu.show_help')}
|
||||
@@ -241,6 +254,8 @@ const TPL = `
|
||||
<span class="version-text"></span>
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider logout-button-separator"></div>
|
||||
|
||||
<li class="dropdown-item logout-button" data-trigger-command="logout">
|
||||
<span class="bx bx-log-out"></span>
|
||||
${t('global_menu.logout')}
|
||||
@@ -268,6 +283,8 @@ export default class GlobalMenuWidget extends BasicWidget {
|
||||
const isElectron = utils.isElectron();
|
||||
|
||||
this.$widget.find(".logout-button").toggle(!isElectron);
|
||||
this.$widget.find(".logout-button-separator").toggle(!isElectron);
|
||||
|
||||
this.$widget.find(".open-dev-tools-button").toggle(isElectron);
|
||||
this.$widget.find(".switch-to-mobile-version-button").toggle(!isElectron && utils.isDesktop());
|
||||
this.$widget.find(".switch-to-desktop-version-button").toggle(!isElectron && utils.isMobile());
|
||||
@@ -293,6 +310,7 @@ export default class GlobalMenuWidget extends BasicWidget {
|
||||
|
||||
if (!utils.isElectron()) {
|
||||
this.$widget.find(".zoom-container").hide();
|
||||
this.$widget.find(".zoom-container-separator").hide();
|
||||
}
|
||||
|
||||
this.$zoomState = this.$widget.find(".zoom-state");
|
||||
@@ -333,7 +351,8 @@ export default class GlobalMenuWidget extends BasicWidget {
|
||||
|
||||
const latestVersion = await this.fetchLatestVersion();
|
||||
this.updateAvailableWidget.updateVersionStatus(latestVersion);
|
||||
this.$updateToLatestVersionButton.toggle(latestVersion > glob.triliumVersion);
|
||||
// Show "click to download" button in options menu if there's a new version available
|
||||
this.$updateToLatestVersionButton.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
|
||||
this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,42 +11,91 @@ import { t } from "../../services/i18n.js";
|
||||
const TPL = `
|
||||
<div class="dropdown note-actions">
|
||||
<style>
|
||||
.note-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-menu {
|
||||
min-width: 15em;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none; /* makes it unclickable */
|
||||
}
|
||||
.note-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-menu {
|
||||
min-width: 15em;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none; /* makes it unclickable */
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false" class="icon-action bx bx-dots-vertical-rounded"></button>
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
|
||||
class="icon-action bx bx-dots-vertical-rounded"></button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a data-trigger-command="convertNoteIntoAttachment" class="dropdown-item">${t('note_actions.convert_into_attachment')}</a>
|
||||
<a data-trigger-command="renderActiveNote" class="dropdown-item render-note-button"><kbd data-command="renderActiveNote"></kbd> ${t('note_actions.re_render_note')}</a>
|
||||
<a data-trigger-command="findInText" class="dropdown-item find-in-text-button">${t('note_actions.search_in_note')} <kbd data-command="findInText"></kbd></a>
|
||||
<a data-trigger-command="showNoteSource" class="dropdown-item show-source-button"><kbd data-command="showNoteSource"></kbd> ${t('note_actions.note_source')}</a>
|
||||
<a data-trigger-command="showAttachments" class="dropdown-item show-attachments-button"><kbd data-command="showAttachments"></kbd> ${t('note_actions.note_attachments')}</a>
|
||||
<a data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button"
|
||||
title="${t('note_actions.open_note_externally_title')}">
|
||||
<kbd data-command="openNoteExternally"></kbd>
|
||||
${t('note_actions.open_note_externally')}
|
||||
</a>
|
||||
<a data-trigger-command="openNoteCustom" class="dropdown-item open-note-custom-button"><kbd data-command="openNoteCustom"></kbd> ${t('note_actions.open_note_custom')}</a>
|
||||
<a class="dropdown-item import-files-button">${t('note_actions.import_files')}</a>
|
||||
<a class="dropdown-item export-note-button">${t('note_actions.export_note')}</a>
|
||||
<a class="dropdown-item delete-note-button">${t('note_actions.delete_note')}</a>
|
||||
<a data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button"><kbd data-command="printActiveNote"></kbd> ${t('note_actions.print_note')}</a>
|
||||
<a data-trigger-command="forceSaveRevision" class="dropdown-item save-revision-button"><kbd data-command="forceSaveRevision"></kbd> ${t('note_actions.save_revision')}</a>
|
||||
<li data-trigger-command="convertNoteIntoAttachment" class="dropdown-item">
|
||||
<span class="bx bx-paperclip"></span> ${t('note_actions.convert_into_attachment')}
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="renderActiveNote" class="dropdown-item render-note-button">
|
||||
<span class="bx bx-extension"></span> ${t('note_actions.re_render_note')}<kbd data-command="renderActiveNote"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="findInText" class="dropdown-item find-in-text-button">
|
||||
<span class='bx bx-search'></span> ${t('note_actions.search_in_note')}<kbd data-command="findInText"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button">
|
||||
<span class="bx bx-printer"></span> ${t('note_actions.print_note')}<kbd data-command="printActiveNote"></kbd></li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li class="dropdown-item import-files-button"><span class="bx bx-import"></span> ${t('note_actions.import_files')}</li>
|
||||
|
||||
<li class="dropdown-item export-note-button"><span class="bx bx-export"></span> ${t('note_actions.export_note')}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
|
||||
<li data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button" title="${t('note_actions.open_note_externally_title')}">
|
||||
<span class="bx bx-file-find"></span> ${t('note_actions.open_note_externally')}<kbd data-command="openNoteExternally"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="openNoteCustom" class="dropdown-item open-note-custom-button">
|
||||
<span class="bx bx-customize"></span> ${t('note_actions.open_note_custom')}<kbd data-command="openNoteCustom"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="showNoteSource" class="dropdown-item show-source-button">
|
||||
<span class="bx bx-code"></span> ${t('note_actions.note_source')}<kbd data-command="showNoteSource"></kbd>
|
||||
</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="forceSaveRevision" class="dropdown-item save-revision-button">
|
||||
<span class="bx bx-save"></span> ${t('note_actions.save_revision')}<kbd data-command="forceSaveRevision"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item delete-note-button"><span class="bx bx-trash destructive-action-icon"></span> ${t('note_actions.delete_note')}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="showAttachments" class="dropdown-item show-attachments-button">
|
||||
<span class="bx bx-paperclip"></span> ${t('note_actions.note_attachments')}<kbd data-command="showAttachments"></kbd>
|
||||
</li>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -127,18 +176,18 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
async convertNoteIntoAttachmentCommand() {
|
||||
if (!await dialogService.confirm(`Are you sure you want to convert note '${this.note.title}' into an attachment of the parent note?`)) {
|
||||
if (!await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {attachment: newAttachment} = await server.post(`notes/${this.noteId}/convert-to-attachment`);
|
||||
|
||||
if (!newAttachment) {
|
||||
toastService.showMessage(`Converting note '${this.note.title}' failed.`);
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_failed", { title: this.note.title }));
|
||||
return;
|
||||
}
|
||||
|
||||
toastService.showMessage(`Note '${newAttachment.title}' has been converted to attachment.`);
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_successful", { title: newAttachment.title }));
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
await appContext.tabManager.getActiveContext().setNote(newAttachment.ownerId, {
|
||||
viewScope: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
const TPL = `
|
||||
<div style="display: none;">
|
||||
@@ -34,6 +35,6 @@ export default class UpdateAvailableWidget extends BasicWidget {
|
||||
}
|
||||
|
||||
updateVersionStatus(latestVersion) {
|
||||
this.$widget.toggle(latestVersion > glob.triliumVersion);
|
||||
this.$widget.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ export default class Container extends BasicWidget {
|
||||
|
||||
renderChildren() {
|
||||
for (const widget of this.children) {
|
||||
this.$widget.append(widget.render());
|
||||
try {
|
||||
this.$widget.append(widget.render());
|
||||
} catch (e) {
|
||||
widget.logRenderingError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
this.$tabContainer.empty();
|
||||
|
||||
for (const ribbonWidget of this.ribbonWidgets) {
|
||||
const ret = ribbonWidget.getTitle(note);
|
||||
const ret = await ribbonWidget.getTitle(note);
|
||||
|
||||
if (!ret.show) {
|
||||
continue;
|
||||
@@ -351,6 +351,21 @@ export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
}
|
||||
}
|
||||
|
||||
noteTypeMimeChangedEvent() {
|
||||
// We are ignoring the event which triggers a refresh since it is usually already done by a different
|
||||
// event and causing a race condition in which the items appear twice.
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed as soon as the user presses the "Edit" floating button in a read-only text note.
|
||||
*
|
||||
* <p>
|
||||
* We need to refresh the ribbon for cases such as the classic editor which relies on the read-only state.
|
||||
*/
|
||||
readOnlyTemporarilyDisabledEvent() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
getActiveRibbonWidget() {
|
||||
return this.ribbonWidgets.find(ch => ch.componentId === this.lastActiveComponentId)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const TPL = `
|
||||
</div>
|
||||
|
||||
<div class="delete-notes-list-wrapper">
|
||||
<h4>${t('delete_notes.notes_to_be_deleted')} (<span class="deleted-notes-count"></span>)</h4>
|
||||
<h4>${t('delete_notes.notes_to_be_deleted', { noteCount: '<span class="deleted-notes-count"></span>' })}</h4>
|
||||
|
||||
<ul class="delete-notes-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ const TPL = `
|
||||
|
||||
<div class="broken-relations-wrapper">
|
||||
<div class="alert alert-danger">
|
||||
<h4>${t('delete_notes.broken_relations_to_be_deleted')} (<span class="broke-relations-count"></span>)</h4>
|
||||
<h4>${t('delete_notes.broken_relations_to_be_deleted', { relationCount: '<span class="broke-relations-count"></span>'})}</h4>
|
||||
|
||||
<ul class="broken-relations-list" style="max-height: 200px; overflow: auto;"></ul>
|
||||
</div>
|
||||
@@ -126,11 +126,11 @@ export default class DeleteNotesDialog extends BasicWidget {
|
||||
|
||||
for (const attr of response.brokenRelations) {
|
||||
this.$brokenRelationsList.append(
|
||||
$("<li>")
|
||||
.append(`${t('delete_notes.note')} `)
|
||||
.append(await linkService.createLink(attr.value))
|
||||
.append(` ${t('delete_notes.to_be_deleted', {attrName: attr.name})} `)
|
||||
.append(await linkService.createLink(attr.noteId))
|
||||
$("<li>").html(t("delete_notes.deleted_relation_text", {
|
||||
note: (await linkService.createLink(attr.value)).html(),
|
||||
relation: `<code>${attr.name}</code>`,
|
||||
source: (await linkService.createLink(attr.noteId)).html()
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowSearchNotes: true,
|
||||
container: this.$results
|
||||
})
|
||||
// clear any event listener added in previous invocation of this function
|
||||
|
||||
@@ -54,7 +54,7 @@ const TPL = `
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
</button>
|
||||
|
||||
<div class="revision-list dropdown-menu" style="position: static; height: 100%; overflow: auto;"></div>
|
||||
<div class="revision-list dropdown-menu static" style="position: static; height: 100%; overflow: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div class="revision-content-wrapper">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import FindInText from "./find_in_text.js";
|
||||
import FindInCode from "./find_in_code.js";
|
||||
import FindInHtml from "./find_in_html.js";
|
||||
@@ -16,27 +17,26 @@ const waitForEnter = (findWidgetDelayMillis < 0);
|
||||
// the focusout handler is called with relatedTarget equal to the label instead
|
||||
// of undefined. It's -1 instead of > 0, so they don't tabstop
|
||||
const TPL = `
|
||||
<div style="contain: none;">
|
||||
<div class='find-replace-widget' style="contain: none; border-top: 1px solid var(--main-border-color);">
|
||||
<style>
|
||||
.find-widget-box {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
.find-widget-box, .replace-widget-box {
|
||||
padding: 2px 10px 2px 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.find-widget-box > * {
|
||||
.find-widget-box > *, .replace-widget-box > *{
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.find-widget-box {
|
||||
.find-widget-box, .replace-widget-box {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
.find-widget-found-wrapper {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.find-widget-search-term-input-group {
|
||||
.find-widget-search-term-input-group, .replace-widget-replacetext-input {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
@@ -47,19 +47,23 @@ const TPL = `
|
||||
|
||||
<div class="find-widget-box">
|
||||
<div class="input-group find-widget-search-term-input-group">
|
||||
<input type="text" class="form-control find-widget-search-term-input">
|
||||
<input type="text" class="form-control find-widget-search-term-input" placeholder="${t('find.find_placeholder')}">
|
||||
<button class="btn btn-outline-secondary bx bxs-chevron-up find-widget-previous-button" type="button"></button>
|
||||
<button class="btn btn-outline-secondary bx bxs-chevron-down find-widget-next-button" type="button"></button>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input find-widget-case-sensitive-checkbox">
|
||||
<label tabIndex="-1" class="form-check-label">${t('find.case_sensitive')}</label>
|
||||
<label tabIndex="-1" class="form-check-label">
|
||||
<input type="checkbox" class="form-check-input find-widget-case-sensitive-checkbox">
|
||||
${t('find.case_sensitive')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input find-widget-match-words-checkbox">
|
||||
<label tabIndex="-1" class="form-check-label">${t('find.match_words')}</label>
|
||||
<label tabIndex="-1" class="form-check-label">
|
||||
<input type="checkbox" class="form-check-input find-widget-match-words-checkbox">
|
||||
${t('find.match_words')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="find-widget-found-wrapper">
|
||||
@@ -72,6 +76,12 @@ const TPL = `
|
||||
|
||||
<div class="find-widget-close-button"><button class="btn icon-action bx bx-x"></button></div>
|
||||
</div>
|
||||
|
||||
<div class="replace-widget-box" style='display: none'>
|
||||
<input type="text" class="form-control replace-widget-replacetext-input" placeholder="${t('find.replace_placeholder')}">
|
||||
<button class="btn btn-sm replace-widget-replaceall-button" type="button">${t('find.replace_all')}</button>
|
||||
<button class="btn btn-sm replace-widget-replace-button" type="button">${t('find.replace')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class FindWidget extends NoteContextAwareWidget {
|
||||
@@ -93,8 +103,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$findBox = this.$widget.find('.find-widget-box');
|
||||
this.$findBox.hide();
|
||||
this.$widget.hide();
|
||||
this.$input = this.$widget.find('.find-widget-search-term-input');
|
||||
this.$currentFound = this.$widget.find('.find-widget-current-found');
|
||||
this.$totalFound = this.$widget.find('.find-widget-total-found');
|
||||
@@ -109,6 +118,13 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
this.$closeButton = this.$widget.find(".find-widget-close-button");
|
||||
this.$closeButton.on("click", () => this.closeSearch());
|
||||
|
||||
this.$replaceWidgetBox = this.$widget.find(".replace-widget-box");
|
||||
this.$replaceTextInput = this.$widget.find(".replace-widget-replacetext-input");
|
||||
this.$replaceAllButton = this.$widget.find(".replace-widget-replaceall-button");
|
||||
this.$replaceAllButton.on("click", () => this.replaceAll());
|
||||
this.$replaceButton = this.$widget.find(".replace-widget-replace-button");
|
||||
this.$replaceButton.on("click", () => this.replace());
|
||||
|
||||
this.$input.keydown(async e => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'F' || e.key === 'f')) {
|
||||
// If ctrl+f is pressed when the findbox is shown, select the
|
||||
@@ -121,7 +137,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
|
||||
this.$findBox.keydown(async e => {
|
||||
this.$widget.keydown(async e => {
|
||||
if (e.key === 'Escape') {
|
||||
await this.closeSearch();
|
||||
}
|
||||
@@ -142,13 +158,25 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
this.handler = await this.getHandler();
|
||||
|
||||
const isReadOnly = await this.noteContext.isReadOnly();
|
||||
|
||||
const selectedText = window.getSelection().toString() || "";
|
||||
|
||||
this.$findBox.show();
|
||||
let selectedText = '';
|
||||
if (this.note.type === 'code' && !isReadOnly){
|
||||
const codeEditor = await this.noteContext.getCodeEditor();
|
||||
selectedText = codeEditor.getSelection();
|
||||
}else{
|
||||
selectedText = window.getSelection().toString() || "";
|
||||
}
|
||||
this.$widget.show();
|
||||
this.$input.focus();
|
||||
if (['text', 'code'].includes(this.note.type) && !isReadOnly) {
|
||||
this.$replaceWidgetBox.show();
|
||||
}else{
|
||||
this.$replaceWidgetBox.hide();
|
||||
}
|
||||
|
||||
const isAlreadyVisible = this.$findBox.is(":visible");
|
||||
const isAlreadyVisible = this.$widget.is(":visible");
|
||||
|
||||
if (isAlreadyVisible) {
|
||||
if (selectedText) {
|
||||
@@ -254,8 +282,8 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
async closeSearch() {
|
||||
if (this.$findBox.is(":visible")) {
|
||||
this.$findBox.hide();
|
||||
if (this.$widget.is(":visible")) {
|
||||
this.$widget.hide();
|
||||
|
||||
// Restore any state, if there's a current occurrence clear markers
|
||||
// and scroll to and select the last occurrence
|
||||
@@ -268,13 +296,27 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
}
|
||||
|
||||
async replace() {
|
||||
const replaceText = this.$replaceTextInput.val();
|
||||
await this.handler.replace(replaceText);
|
||||
}
|
||||
|
||||
async replaceAll() {
|
||||
const replaceText = this.$replaceTextInput.val();
|
||||
await this.handler.replaceAll(replaceText);
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && ['text', 'code', 'render'].includes(this.note.type);
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({loadResults}) {
|
||||
async entitiesReloadedEvent({ loadResults }) {
|
||||
if (loadResults.isNoteContentReloaded(this.noteId)) {
|
||||
this.$totalFound.text("?")
|
||||
} else if (loadResults.getAttributeRows().find(attr => attr.type === 'label'
|
||||
&& (attr.name.toLowerCase().includes('readonly'))
|
||||
&& attributeService.isAffecting(attr, this.note))) {
|
||||
this.closeSearch();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,4 +170,55 @@ export default class FindInCode {
|
||||
|
||||
codeEditor.focus();
|
||||
}
|
||||
async replace(replaceText) {
|
||||
// this.findResult may be undefined and null
|
||||
if (!this.findResult || this.findResult.length===0){
|
||||
return;
|
||||
}
|
||||
let currentFound = -1;
|
||||
this.findResult.forEach((marker, index) => {
|
||||
const pos = marker.find();
|
||||
if (pos) {
|
||||
if (marker.className === FIND_RESULT_SELECTED_CSS_CLASSNAME) {
|
||||
currentFound = index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (currentFound >= 0) {
|
||||
let marker = this.findResult[currentFound];
|
||||
let pos = marker.find();
|
||||
const codeEditor = await this.getCodeEditor();
|
||||
const doc = codeEditor.doc;
|
||||
doc.replaceRange(replaceText, pos.from, pos.to);
|
||||
marker.clear();
|
||||
|
||||
let nextFound;
|
||||
if (currentFound === this.findResult.length - 1) {
|
||||
nextFound = 0;
|
||||
} else {
|
||||
nextFound = currentFound;
|
||||
}
|
||||
this.findResult.splice(currentFound, 1);
|
||||
if (this.findResult.length > 0) {
|
||||
this.findNext(0, nextFound, nextFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
async replaceAll(replaceText) {
|
||||
if (!this.findResult || this.findResult.length===0){
|
||||
return;
|
||||
}
|
||||
const codeEditor = await this.getCodeEditor();
|
||||
const doc = codeEditor.doc;
|
||||
codeEditor.operation(() => {
|
||||
for (let currentFound = 0; currentFound < this.findResult.length; currentFound++) {
|
||||
let marker = this.findResult[currentFound];
|
||||
let pos = marker.find();
|
||||
doc.replaceRange(replaceText, pos.from, pos.to);
|
||||
marker.clear();
|
||||
}
|
||||
});
|
||||
this.findResult = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class FindInText {
|
||||
const findAndReplaceEditing = textEditor.plugins.get('FindAndReplaceEditing');
|
||||
findAndReplaceEditing.state.clear(model);
|
||||
findAndReplaceEditing.stop();
|
||||
this.editingState = findAndReplaceEditing.state;
|
||||
if (searchTerm !== "") {
|
||||
// Parameters are callback/text, options.matchCase=false, options.wholeWords=false
|
||||
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findcommand.js#L44
|
||||
@@ -29,7 +30,7 @@ export default class FindInText {
|
||||
// let re = new RegExp(searchTerm, 'gi');
|
||||
// let m = text.match(re);
|
||||
// totalFound = m ? m.length : 0;
|
||||
const options = { "matchCase" : matchCase, "wholeWords" : wholeWord };
|
||||
const options = { "matchCase": matchCase, "wholeWords": wholeWord };
|
||||
findResult = textEditor.execute('find', searchTerm, options);
|
||||
totalFound = findResult.results.length;
|
||||
// Find the result beyond the cursor
|
||||
@@ -102,4 +103,18 @@ export default class FindInText {
|
||||
|
||||
textEditor.focus();
|
||||
}
|
||||
|
||||
async replace(replaceText) {
|
||||
if (this.editingState !== undefined && this.editingState.highlightedResult !== null) {
|
||||
const textEditor = await this.getTextEditor();
|
||||
textEditor.execute('replace', replaceText, this.editingState.highlightedResult);
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAll(replaceText) {
|
||||
if (this.editingState !== undefined && this.editingState.results.length > 0) {
|
||||
const textEditor = await this.getTextEditor();
|
||||
textEditor.execute('replaceAll', replaceText, this.editingState.results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5171,7 +5171,7 @@ const icons = [
|
||||
"type_of_icon": "REGULAR"
|
||||
},
|
||||
{
|
||||
"name": '_share',
|
||||
"name": "share",
|
||||
"slug": "share-regular",
|
||||
"category_id": 101,
|
||||
"type_of_icon": "REGULAR"
|
||||
@@ -6826,7 +6826,7 @@ const icons = [
|
||||
"type_of_icon": "SOLID"
|
||||
},
|
||||
{
|
||||
"name": '_share',
|
||||
"name": "share",
|
||||
"slug": "share-solid",
|
||||
"category_id": 101,
|
||||
"type_of_icon": "SOLID"
|
||||
|
||||
@@ -50,6 +50,9 @@ class NoteContextAwareWidget extends BasicWidget {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* <p>
|
||||
* If the widget is not enabled, it will not receive `refreshWithNote` updates.
|
||||
*
|
||||
* @returns {boolean} true when an active note exists
|
||||
*/
|
||||
isEnabled() {
|
||||
|
||||
@@ -30,6 +30,7 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
||||
import { getStylesheetUrl, isSyntaxHighlightEnabled } from "../services/syntax_highlight.js";
|
||||
|
||||
const TPL = `
|
||||
<div class="note-detail">
|
||||
@@ -255,6 +256,19 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
const {assetPath} = window.glob;
|
||||
const cssToLoad = [
|
||||
`${assetPath}/node_modules/codemirror/lib/codemirror.css`,
|
||||
`${assetPath}/libraries/ckeditor/ckeditor-content.css`,
|
||||
`${assetPath}/node_modules/bootstrap/dist/css/bootstrap.min.css`,
|
||||
`${assetPath}/node_modules/katex/dist/katex.min.css`,
|
||||
`${assetPath}/stylesheets/print.css`,
|
||||
`${assetPath}/stylesheets/relation_map.css`,
|
||||
`${assetPath}/stylesheets/ckeditor-theme.css`
|
||||
];
|
||||
|
||||
if (isSyntaxHighlightEnabled()) {
|
||||
cssToLoad.push(getStylesheetUrl("default:vs"));
|
||||
}
|
||||
|
||||
this.$widget.find('.note-detail-printable:visible').printThis({
|
||||
header: $("<div>")
|
||||
@@ -273,15 +287,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
</script>
|
||||
`,
|
||||
importCSS: false,
|
||||
loadCSS: [
|
||||
`${assetPath}/node_modules/codemirror/lib/codemirror.css`,
|
||||
`${assetPath}/libraries/ckeditor/ckeditor-content.css`,
|
||||
`${assetPath}/node_modules/bootstrap/dist/css/bootstrap.min.css`,
|
||||
`${assetPath}/node_modules/katex/dist/katex.min.css`,
|
||||
`${assetPath}/stylesheets/print.css`,
|
||||
`${assetPath}/stylesheets/relation_map.css`,
|
||||
`${assetPath}/stylesheets/ckeditor-theme.css`
|
||||
],
|
||||
loadCSS: cssToLoad,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -666,8 +666,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
const node = this.prepareNode(branch);
|
||||
|
||||
noteList.push(node);
|
||||
if (node) {
|
||||
noteList.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return noteList;
|
||||
@@ -709,7 +710,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const note = branch.getNoteFromCache();
|
||||
|
||||
if (!note) {
|
||||
throw new Error(`Branch '${branch.branchId}' has no child note '${branch.noteId}'`);
|
||||
console.warn(`Branch '${branch.branchId}' has no child note '${branch.noteId}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = `${branch.prefix ? (`${branch.prefix} - `) : ""}${note.title}`;
|
||||
@@ -1031,7 +1033,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
activeNode.load(true);
|
||||
activeNode.setExpanded(true, {noAnimation: true});
|
||||
|
||||
toastService.showMessage("Saved search note refreshed.");
|
||||
toastService.showMessage(t("note_tree.saved-search-note-refreshed"));
|
||||
}
|
||||
|
||||
async batchUpdate(cb) {
|
||||
@@ -1075,7 +1077,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
node.setExpanded(false);
|
||||
|
||||
if (noneCollapsedYet) {
|
||||
toastService.showMessage("Auto collapsing notes after inactivity...");
|
||||
toastService.showMessage(t("note_tree.auto-collapsing-notes-after-inactivity"));
|
||||
noneCollapsedYet = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
|
||||
this.$noteTypeDropdown.append($typeLink);
|
||||
}
|
||||
|
||||
for (const mimeType of await mimeTypesService.getMimeTypes()) {
|
||||
for (const mimeType of mimeTypesService.getMimeTypes()) {
|
||||
if (!mimeType.enabled) {
|
||||
continue;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
|
||||
|
||||
async findTypeTitle(type, mime) {
|
||||
if (type === 'code') {
|
||||
const mimeTypes = await mimeTypesService.getMimeTypes();
|
||||
const mimeTypes = mimeTypesService.getMimeTypes();
|
||||
const found = mimeTypes.find(mt => mt.mime === mime);
|
||||
|
||||
return found ? found.title : mime;
|
||||
@@ -159,7 +159,7 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await dialogService.confirm("It is not recommended to change note type when note content is not empty. Do you want to continue anyway?");
|
||||
return await dialogService.confirm(t("note_types.confirm-change"));
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import options from "../../services/options.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = `\
|
||||
<div class="classic-toolbar-widget"></div>
|
||||
|
||||
<style>
|
||||
.classic-toolbar-widget {
|
||||
--ck-color-toolbar-background: transparent;
|
||||
--ck-color-button-default-background: transparent;
|
||||
--ck-color-button-default-disabled-background: transparent;
|
||||
min-height: 39px;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-toolbar {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-button.ck-disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
body.mobile .classic-toolbar-widget {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
body.mobile .classic-toolbar-widget .ck.ck-toolbar {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Handles the editing toolbar when the CKEditor is in decoupled mode.
|
||||
*
|
||||
* <p>
|
||||
* This toolbar is only enabled if the user has selected the classic CKEditor.
|
||||
*
|
||||
* <p>
|
||||
* The ribbon item is active by default for text notes, as long as they are not in read-only mode.
|
||||
*/
|
||||
export default class ClassicEditorToolbar extends NoteContextAwareWidget {
|
||||
get name() {
|
||||
return "classicEditor";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabClassicEditor";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
}
|
||||
|
||||
async getTitle() {
|
||||
return {
|
||||
show: await this.#shouldDisplay(),
|
||||
activate: true,
|
||||
title: t("classic_editor_toolbar.title"),
|
||||
icon: "bx bx-text"
|
||||
};
|
||||
}
|
||||
|
||||
async #shouldDisplay() {
|
||||
if (options.get("textNoteEditorType") !== "ckeditor-classic") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.note.type !== "text") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.noteContext.isReadOnly()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,6 +42,10 @@ const TPL = `
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="promoted-attributes-container"></div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
|
||||
const WIDGET_TPL = `
|
||||
<div class="card widget">
|
||||
@@ -54,7 +56,9 @@ class RightPanelWidget extends NoteContextAwareWidget {
|
||||
this.$buttons.append(buttonWidget.render());
|
||||
}
|
||||
|
||||
this.initialized = this.doRenderBody();
|
||||
this.initialized = this.doRenderBody().catch(e => {
|
||||
this.logRenderingError(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,9 +61,9 @@ export default class SearchString extends AbstractSearchOption {
|
||||
|
||||
await this.setAttribute('label', 'searchString', searchString);
|
||||
|
||||
if (this.note.title.startsWith('Search: ')) {
|
||||
if (this.note.title.startsWith(t("search_string.search_prefix"))) {
|
||||
await server.put(`notes/${this.note.noteId}/title`, {
|
||||
title: `Search: ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}…`}`
|
||||
title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}…`}`
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
import contextMenu from "../menus/context_menu.js";
|
||||
import utils from "../services/utils.js";
|
||||
@@ -37,11 +38,11 @@ const TAB_TPL = `
|
||||
<div class="note-tab-drag-handle"></div>
|
||||
<div class="note-tab-icon"></div>
|
||||
<div class="note-tab-title"></div>
|
||||
<div class="note-tab-close bx bx-x" title="Close tab" data-trigger-command="closeActiveTab"></div>
|
||||
<div class="note-tab-close bx bx-x" title="${t('tab_row.close_tab')}" data-trigger-command="closeActiveTab"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const NEW_TAB_BUTTON_TPL = `<div class="note-new-tab" data-trigger-command="openNewTab" title="Add new tab">+</div>`;
|
||||
const NEW_TAB_BUTTON_TPL = `<div class="note-new-tab" data-trigger-command="openNewTab" title="${t('tab_row.add_new_tab')}">+</div>`;
|
||||
const FILLER_TPL = `<div class="tab-row-filler"></div>`;
|
||||
|
||||
const TAB_ROW_TPL = `
|
||||
@@ -258,10 +259,19 @@ export default class TabRowWidget extends BasicWidget {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{title: "Close", command: "closeTab", uiIcon: "bx bx-x"},
|
||||
{title: "Close other tabs", command: "closeOtherTabs", uiIcon: "bx bx-x"},
|
||||
{title: "Close all tabs", command: "closeAllTabs", uiIcon: "bx bx-x"},
|
||||
{title: "Move this tab to a new window", command: "moveTabToNewWindow", uiIcon: "bx bx-window-open"}
|
||||
{title: t('tab_row.close'), command: "closeTab", uiIcon: "bx bx-x"},
|
||||
{title: t('tab_row.close_other_tabs'), command: "closeOtherTabs", uiIcon: "bx bx-empty", enabled: appContext.tabManager.noteContexts.length !== 1},
|
||||
{title: t('tab_row.close_right_tabs'), command: "closeRightTabs", uiIcon: "bx bx-empty", enabled: appContext.tabManager.noteContexts.at(-1).ntxId !== ntxId},
|
||||
{title: t('tab_row.close_all_tabs'), command: "closeAllTabs", uiIcon: "bx bx-empty"},
|
||||
|
||||
{title: "----"},
|
||||
|
||||
{title: t('tab_row.reopen_last_tab'), command: "reopenLastTab", uiIcon: "bx bx-undo", enabled: appContext.tabManager.recentlyClosedTabs.length !== 0},
|
||||
|
||||
{title: "----"},
|
||||
|
||||
{title: t('tab_row.move_tab_to_new_window'), command: "moveTabToNewWindow", uiIcon: "bx bx-window-open"},
|
||||
{title: t('tab_row.copy_tab_to_new_window'), command: "copyTabToNewWindow", uiIcon: "bx bx-empty"}
|
||||
],
|
||||
selectMenuItemHandler: ({command}) => {
|
||||
this.triggerCommand(command, {ntxId});
|
||||
@@ -387,7 +397,7 @@ export default class TabRowWidget extends BasicWidget {
|
||||
this.$newTab.before($tab);
|
||||
this.setVisibility();
|
||||
this.setTabCloseEvent($tab);
|
||||
this.updateTitle($tab, 'New tab');
|
||||
this.updateTitle($tab, t('tab_row.new_tab'));
|
||||
this.cleanUpPreviouslyDraggedTabs();
|
||||
this.layoutTabs();
|
||||
this.setupDraggabilly();
|
||||
@@ -672,7 +682,7 @@ export default class TabRowWidget extends BasicWidget {
|
||||
const {note} = noteContext;
|
||||
|
||||
if (!note) {
|
||||
this.updateTitle($tab, 'New tab');
|
||||
this.updateTitle($tab, t('tab_row.new_tab'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* to the wrong heading (although what "right" means in those cases is not
|
||||
* clear), but it won't crash.
|
||||
*/
|
||||
|
||||
import { t } from "../services/i18n.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import RightPanelWidget from "./right_panel_widget.js";
|
||||
import options from "../services/options.js";
|
||||
@@ -55,14 +55,14 @@ const TPL = `<div class="toc-widget">
|
||||
|
||||
export default class TocWidget extends RightPanelWidget {
|
||||
get widgetTitle() {
|
||||
return "Table of Contents";
|
||||
return t("toc.table_of_contents");
|
||||
}
|
||||
|
||||
get widgetButtons() {
|
||||
return [
|
||||
new OnClickButtonWidget()
|
||||
.icon("bx-cog")
|
||||
.title("Options")
|
||||
.title(t("toc.options"))
|
||||
.titlePlacement("left")
|
||||
.onClick(() => appContext.tabManager.openContextWithNote('_optionsTextNotes', {activate: true}))
|
||||
.class("icon-action"),
|
||||
@@ -125,18 +125,18 @@ export default class TocWidget extends RightPanelWidget {
|
||||
*
|
||||
* @param {string} html Note's html content
|
||||
* @returns {string} The HTML content with mathematical formulas rendered by KaTeX.
|
||||
*/
|
||||
*/
|
||||
async replaceMathTextWithKatax(html) {
|
||||
const mathTextRegex = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
|
||||
var matches = [...html.matchAll(mathTextRegex)];
|
||||
let modifiedText = html;
|
||||
|
||||
|
||||
if (matches.length > 0) {
|
||||
// Process all matches asynchronously
|
||||
for (const match of matches) {
|
||||
let latexCode = match[1];
|
||||
let rendered;
|
||||
|
||||
|
||||
try {
|
||||
rendered = katex.renderToString(latexCode, {
|
||||
throwOnError: false
|
||||
@@ -158,7 +158,7 @@ export default class TocWidget extends RightPanelWidget {
|
||||
rendered = match[0]; // Fall back to original on error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Replace the matched formula in the modified text
|
||||
modifiedText = modifiedText.replace(match[0], rendered);
|
||||
}
|
||||
|
||||
120
src/public/app/widgets/type_widgets/abstract_code_type_widget.js
Normal file
120
src/public/app/widgets/type_widgets/abstract_code_type_widget.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import libraryLoader from "../../services/library_loader.js";
|
||||
import options from "../../services/options.js";
|
||||
|
||||
/**
|
||||
* An abstract {@link TypeWidget} which implements the CodeMirror editor, meant to be used as a parent for
|
||||
* widgets requiring the editor.
|
||||
*
|
||||
* The widget handles the loading and initialization of the CodeMirror editor, as well as some common
|
||||
* actions.
|
||||
*
|
||||
* The derived class must:
|
||||
*
|
||||
* - Define `$editor` in the constructor.
|
||||
* - Call `super.doRender()` in the extended class.
|
||||
* - Call `this._update(note, content)` in `#doRefresh(note)`.
|
||||
*/
|
||||
export default class AbstractCodeTypeWidget extends TypeWidget {
|
||||
|
||||
doRender() {
|
||||
this.initialized = this.#initEditor();
|
||||
}
|
||||
|
||||
async #initEditor() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR);
|
||||
|
||||
// these conflict with backward/forward navigation shortcuts
|
||||
delete CodeMirror.keyMap.default["Alt-Left"];
|
||||
delete CodeMirror.keyMap.default["Alt-Right"];
|
||||
|
||||
CodeMirror.modeURL = `${window.glob.assetPath}/node_modules/codemirror/mode/%N/%N.js`;
|
||||
CodeMirror.modeInfo.find(mode=>mode.name === "JavaScript").mimes.push(...["application/javascript;env=frontend", "application/javascript;env=backend"]);
|
||||
CodeMirror.modeInfo.find(mode=>mode.name === "SQLite").mimes=["text/x-sqlite", "text/x-sqlite;schema=trilium"];
|
||||
|
||||
this.codeEditor = CodeMirror(this.$editor[0], {
|
||||
value: "",
|
||||
viewportMargin: Infinity,
|
||||
indentUnit: 4,
|
||||
matchBrackets: true,
|
||||
matchTags: {bothTags: true},
|
||||
highlightSelectionMatches: {showToken: false, annotateScrollbar: false},
|
||||
lineNumbers: true,
|
||||
// we line wrap partly also because without it horizontal scrollbar displays only when you scroll
|
||||
// all the way to the bottom of the note. With line wrap, there's no horizontal scrollbar so no problem
|
||||
lineWrapping: options.is('codeLineWrapEnabled'),
|
||||
...this.getExtraOpts()
|
||||
});
|
||||
this.onEditorInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be extended in derived classes to add extra options to the CodeMirror constructor. The options are appended
|
||||
* at the end, so it is possible to override the default values introduced by the abstract editor as well.
|
||||
*
|
||||
* @returns the extra options to be passed to the CodeMirror constructor.
|
||||
*/
|
||||
getExtraOpts() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called as soon as the CodeMirror library has been loaded and the editor was constructed. Can be extended in
|
||||
* derived classes to add additional functionality or to register event handlers.
|
||||
*
|
||||
* By default, it does nothing.
|
||||
*/
|
||||
onEditorInitialized() {
|
||||
// Do nothing by default.
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called by the derived classes in `#doRefresh(note)` in order to react to changes.
|
||||
*
|
||||
* @param {*} note the note that was changed.
|
||||
* @param {*} content the new content of the note.
|
||||
*/
|
||||
_update(note, content) {
|
||||
// CodeMirror breaks pretty badly on null, so even though it shouldn't happen (guarded by a consistency check)
|
||||
// we provide fallback
|
||||
this.codeEditor.setValue(content || "");
|
||||
this.codeEditor.clearHistory();
|
||||
|
||||
let info = CodeMirror.findModeByMIME(note.mime);
|
||||
if (!info) {
|
||||
// Switch back to plain text if CodeMirror does not have a mode for whatever MIME type we're editing.
|
||||
// To avoid inheriting a mode from a previously open code note.
|
||||
info = CodeMirror.findModeByMIME("text/plain");
|
||||
}
|
||||
|
||||
this.codeEditor.setOption("mode", info.mime);
|
||||
CodeMirror.autoLoadMode(this.codeEditor, info.mode);
|
||||
};
|
||||
|
||||
show() {
|
||||
this.$widget.show();
|
||||
|
||||
if (this.codeEditor) { // show can be called before render
|
||||
this.codeEditor.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.focus();
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
this.codeEditor.setCursor(this.codeEditor.lineCount(), 0);
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.codeEditor) {
|
||||
this.spacedUpdate.allowUpdateWithoutChange(() => {
|
||||
this.codeEditor.setValue('');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import contentRenderer from "../../services/content_renderer.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import options from "../../services/options.js";
|
||||
|
||||
export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
this.refreshCodeBlockOptions();
|
||||
}
|
||||
|
||||
setupImageOpening(singleClickOpens) {
|
||||
this.$widget.on("dblclick", "img", e => this.openImageInCurrentTab($(e.target)));
|
||||
|
||||
@@ -25,7 +32,7 @@ export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
|
||||
async openImageInCurrentTab($img) {
|
||||
const { noteId, viewScope } = await this.parseFromImage($img);
|
||||
|
||||
|
||||
if (noteId) {
|
||||
appContext.tabManager.getActiveContext().setNote(noteId, { viewScope });
|
||||
} else {
|
||||
@@ -33,8 +40,8 @@ export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
}
|
||||
}
|
||||
|
||||
openImageInNewTab($img) {
|
||||
const { noteId, viewScope } = this.parseFromImage($img);
|
||||
async openImageInNewTab($img) {
|
||||
const { noteId, viewScope } = await this.parseFromImage($img);
|
||||
|
||||
if (noteId) {
|
||||
appContext.tabManager.openTabWithNoteWithHoisting(noteId, { viewScope });
|
||||
@@ -108,4 +115,16 @@ export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshCodeBlockOptions() {
|
||||
const wordWrap = options.is("codeBlockWordWrap");
|
||||
this.$widget.toggleClass("word-wrap", wordWrap);
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({loadResults}) {
|
||||
if (loadResults.isOptionReloaded("codeBlockWordWrap")) {
|
||||
this.refreshCodeBlockOptions();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
354
src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.js
Normal file
354
src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* This code is an adaptation of https://github.com/antoniotejada/Trilium-SyntaxHighlightWidget with additional improvements, such as:
|
||||
*
|
||||
* - support for selecting the language manually;
|
||||
* - support for determining the language automatically, if a special language is selected ("Auto-detected");
|
||||
* - limit for highlighting.
|
||||
*
|
||||
* TODO: Generally this class can be done directly in the CKEditor repository.
|
||||
*/
|
||||
|
||||
import library_loader from "../../../services/library_loader.js";
|
||||
import mime_types from "../../../services/mime_types.js";
|
||||
import { isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js";
|
||||
|
||||
export async function initSyntaxHighlighting(editor) {
|
||||
if (!isSyntaxHighlightEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
|
||||
initTextEditor(editor);
|
||||
}
|
||||
|
||||
const HIGHLIGHT_MAX_BLOCK_COUNT = 500;
|
||||
|
||||
const tag = "SyntaxHighlightWidget";
|
||||
const debugLevels = ["error", "warn", "info", "log", "debug"];
|
||||
const debugLevel = "debug";
|
||||
|
||||
let warn = function() {};
|
||||
if (debugLevel >= debugLevels.indexOf("warn")) {
|
||||
warn = console.warn.bind(console, tag + ": ");
|
||||
}
|
||||
|
||||
let info = function() {};
|
||||
if (debugLevel >= debugLevels.indexOf("info")) {
|
||||
info = console.info.bind(console, tag + ": ");
|
||||
}
|
||||
|
||||
let log = function() {};
|
||||
if (debugLevel >= debugLevels.indexOf("log")) {
|
||||
log = console.log.bind(console, tag + ": ");
|
||||
}
|
||||
|
||||
let dbg = function() {};
|
||||
if (debugLevel >= debugLevels.indexOf("debug")) {
|
||||
dbg = console.debug.bind(console, tag + ": ");
|
||||
}
|
||||
|
||||
function assert(e, msg) {
|
||||
console.assert(e, tag + ": " + msg);
|
||||
}
|
||||
|
||||
// TODO: Should this be scoped to note?
|
||||
let markerCounter = 0;
|
||||
|
||||
function initTextEditor(textEditor) {
|
||||
log("initTextEditor");
|
||||
|
||||
let widget = this;
|
||||
const document = textEditor.model.document;
|
||||
|
||||
// Create a conversion from model to view that converts
|
||||
// hljs:hljsClassName:uniqueId into a span with hljsClassName
|
||||
// See the list of hljs class names at
|
||||
// https://github.com/highlightjs/highlight.js/blob/6b8c831f00c4e87ecd2189ebbd0bb3bbdde66c02/docs/css-classes-reference.rst
|
||||
|
||||
textEditor.conversion.for('editingDowncast').markerToHighlight( {
|
||||
model: "hljs",
|
||||
view: ( { markerName } ) => {
|
||||
dbg("markerName " + markerName);
|
||||
// markerName has the pattern addMarker:cssClassName:uniqueId
|
||||
const [ , cssClassName, id ] = markerName.split( ':' );
|
||||
|
||||
// The original code at
|
||||
// https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js
|
||||
// has this comment
|
||||
// Marker removal from the view has a bug:
|
||||
// https://github.com/ckeditor/ckeditor5/issues/7499
|
||||
// A minimal option is to return a new object for each converted marker...
|
||||
return {
|
||||
name: 'span',
|
||||
classes: [ cssClassName ],
|
||||
attributes: {
|
||||
// ...however, adding a unique attribute should be future-proof..
|
||||
'data-syntax-result': id
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// XXX This is done at BalloonEditor.create time, so it assumes this
|
||||
// document is always attached to this textEditor, empirically that
|
||||
// seems to be the case even with two splits showing the same note,
|
||||
// it's not clear if CKEditor5 has apis to attach and detach
|
||||
// documents around
|
||||
document.registerPostFixer(function(writer) {
|
||||
log("postFixer");
|
||||
// Postfixers are a simpler way of tracking changes than onchange
|
||||
// See
|
||||
// https://github.com/ckeditor/ckeditor5/blob/b53d2a4b49679b072f4ae781ac094e7e831cfb14/packages/ckeditor5-block-quote/src/blockquoteediting.js#L54
|
||||
const changes = document.differ.getChanges();
|
||||
let dirtyCodeBlocks = new Set();
|
||||
|
||||
for (const change of changes) {
|
||||
dbg("change " + JSON.stringify(change));
|
||||
|
||||
if ((change.type == "insert") && (change.name == "codeBlock")) {
|
||||
// A new code block was inserted
|
||||
const codeBlock = change.position.nodeAfter;
|
||||
// Even if it's a new codeblock, it needs dirtying in case
|
||||
// it already has children, like when pasting one or more
|
||||
// full codeblocks, undoing a delete, changing the language,
|
||||
// etc (the postfixer won't get later changes for those).
|
||||
log("dirtying inserted codeBlock " + JSON.stringify(codeBlock.toJSON()));
|
||||
dirtyCodeBlocks.add(codeBlock);
|
||||
|
||||
} else if (change.type == "remove" && (change.name == "codeBlock")) {
|
||||
// An existing codeblock was removed, do nothing. Note the
|
||||
// node is no longer in the editor so the codeblock cannot
|
||||
// be inspected here. No need to dirty the codeblock since
|
||||
// it has been removed
|
||||
log("removing codeBlock at path " + JSON.stringify(change.position.toJSON()));
|
||||
|
||||
} else if (((change.type == "remove") || (change.type == "insert")) &&
|
||||
change.position.parent.is('element', 'codeBlock')) {
|
||||
// Text was added or removed from the codeblock, force a
|
||||
// highlight
|
||||
const codeBlock = change.position.parent;
|
||||
log("dirtying codeBlock " + JSON.stringify(codeBlock.toJSON()));
|
||||
dirtyCodeBlocks.add(codeBlock);
|
||||
}
|
||||
}
|
||||
for (let codeBlock of dirtyCodeBlocks) {
|
||||
highlightCodeBlock(codeBlock, writer);
|
||||
}
|
||||
// Adding markers doesn't modify the document data so no need for
|
||||
// postfixers to run again
|
||||
return false;
|
||||
});
|
||||
|
||||
// This assumes the document is empty and a explicit call to highlight
|
||||
// is not necessary here. Empty documents have a single children of type
|
||||
// paragraph with no text
|
||||
assert((document.getRoot().childCount == 1) &&
|
||||
(document.getRoot().getChild(0).name == "paragraph") &&
|
||||
document.getRoot().getChild(0).isEmpty);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This implements highlighting via ephemeral markers (not stored in the
|
||||
* document).
|
||||
*
|
||||
* XXX Another option would be to use formatting markers, which would have
|
||||
* the benefit of making it work for readonly notes. On the flip side,
|
||||
* the formatting would be stored with the note and it would need a
|
||||
* way to remove that formatting when editing back the note.
|
||||
*/
|
||||
function highlightCodeBlock(codeBlock, writer) {
|
||||
log("highlighting codeblock " + JSON.stringify(codeBlock.toJSON()));
|
||||
const model = codeBlock.root.document.model;
|
||||
|
||||
// Can't invoke addMarker with an already existing marker name,
|
||||
// clear all highlight markers first. Marker names follow the
|
||||
// pattern hljs:cssClassName:uniqueId, eg hljs:hljs-comment:1
|
||||
const codeBlockRange = model.createRangeIn(codeBlock);
|
||||
for (const marker of model.markers.getMarkersIntersectingRange(codeBlockRange)) {
|
||||
dbg("removing marker " + marker.name);
|
||||
writer.removeMarker(marker.name);
|
||||
}
|
||||
|
||||
// Don't highlight if plaintext (note this needs to remove the markers
|
||||
// above first, in case this was a switch from non plaintext to
|
||||
// plaintext)
|
||||
const mimeType = codeBlock.getAttribute("language");
|
||||
if (mimeType == "text-plain") {
|
||||
// XXX There's actually a plaintext language that could be used
|
||||
// if you wanted the non-highlight formatting of
|
||||
// highlight.js css applied, see
|
||||
// https://github.com/highlightjs/highlight.js/issues/700
|
||||
log("not highlighting plaintext codeblock");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the corresponding language for the given mimetype.
|
||||
const highlightJsLanguage = mime_types.getHighlightJsNameForMime(mimeType);
|
||||
|
||||
if (mimeType !== mime_types.MIME_TYPE_AUTO && !highlightJsLanguage) {
|
||||
console.warn(`Unsupported highlight.js for mime type ${mimeType}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't highlight if the code is too big, as the typing performance will be highly degraded.
|
||||
if (codeBlock.childCount >= HIGHLIGHT_MAX_BLOCK_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// highlight.js needs the full text without HTML tags, eg for the
|
||||
// text
|
||||
// #include <stdio.h>
|
||||
// the highlighted html is
|
||||
// <span class="hljs-meta">#<span class="hljs-keyword">include</span> <span class="hljs-string"><stdio.h></span></span>
|
||||
// But CKEditor codeblocks have <br> instead of \n
|
||||
|
||||
// Do a two pass algorithm:
|
||||
// - First pass collect the codeblock children text, change <br> to
|
||||
// \n
|
||||
// - invoke highlight.js on the collected text generating html
|
||||
// - Second pass parse the highlighted html spans and match each
|
||||
// char to the CodeBlock text. Issue addMarker CKEditor calls for
|
||||
// each span
|
||||
|
||||
// XXX This is brittle and assumes how highlight.js generates html
|
||||
// (blanks, which characters escapes, etc), a better approach
|
||||
// would be to use highlight.js beta api TreeTokenizer?
|
||||
|
||||
// Collect all the text nodes to pass to the highlighter Text is
|
||||
// direct children of the codeBlock
|
||||
let text = "";
|
||||
for (let i = 0; i < codeBlock.childCount; ++i) {
|
||||
let child = codeBlock.getChild(i);
|
||||
|
||||
// We only expect text and br elements here
|
||||
if (child.is("$text")) {
|
||||
dbg("child text " + child.data);
|
||||
text += child.data;
|
||||
|
||||
} else if (child.is("element") &&
|
||||
(child.name == "softBreak")) {
|
||||
dbg("softBreak");
|
||||
text += "\n";
|
||||
|
||||
} else {
|
||||
warn("Unkown child " + JSON.stringify(child.toJSON()));
|
||||
}
|
||||
}
|
||||
|
||||
let highlightRes;
|
||||
if (mimeType === mime_types.MIME_TYPE_AUTO) {
|
||||
highlightRes = hljs.highlightAuto(text);
|
||||
} else {
|
||||
highlightRes = hljs.highlight(text, { language: highlightJsLanguage });
|
||||
}
|
||||
dbg("text\n" + text);
|
||||
dbg("html\n" + highlightRes.value);
|
||||
|
||||
let iHtml = 0;
|
||||
let html = highlightRes.value;
|
||||
let spanStack = [];
|
||||
let iChild = -1;
|
||||
let childText = "";
|
||||
let child = null;
|
||||
let iChildText = 0;
|
||||
|
||||
while (iHtml < html.length) {
|
||||
// Advance the text index and fetch a new child if necessary
|
||||
if (iChildText >= childText.length) {
|
||||
iChild++;
|
||||
if (iChild < codeBlock.childCount) {
|
||||
dbg("Fetching child " + iChild);
|
||||
child = codeBlock.getChild(iChild);
|
||||
if (child.is("$text")) {
|
||||
dbg("child text " + child.data);
|
||||
childText = child.data;
|
||||
iChildText = 0;
|
||||
} else if (child.is("element", "softBreak")) {
|
||||
dbg("softBreak");
|
||||
iChildText = 0;
|
||||
childText = "\n";
|
||||
} else {
|
||||
warn("child unknown!!!");
|
||||
}
|
||||
} else {
|
||||
// Don't bail if beyond the last children, since there's
|
||||
// still html text, it must be a closing span tag that
|
||||
// needs to be dealt with below
|
||||
childText = "";
|
||||
}
|
||||
}
|
||||
|
||||
// This parsing is made slightly simpler and faster by only
|
||||
// expecting <span> and </span> tags in the highlighted html
|
||||
if ((html[iHtml] == "<") && (html[iHtml+1] != "/")) {
|
||||
// new span, note they can be nested eg C preprocessor lines
|
||||
// are inside a hljs-meta span, hljs-title function names
|
||||
// inside a hljs-function span, etc
|
||||
let iStartQuot = html.indexOf("\"", iHtml+1);
|
||||
let iEndQuot = html.indexOf("\"", iStartQuot+1);
|
||||
let className = html.slice(iStartQuot+1, iEndQuot);
|
||||
// XXX highlight js uses scope for Python "title function_",
|
||||
// etc for now just use the first style only
|
||||
// See https://highlightjs.readthedocs.io/en/latest/css-classes-reference.html#a-note-on-scopes-with-sub-scopes
|
||||
let iBlank = className.indexOf(" ");
|
||||
if (iBlank > 0) {
|
||||
className = className.slice(0, iBlank);
|
||||
}
|
||||
dbg("Found span start " + className);
|
||||
|
||||
iHtml = html.indexOf(">", iHtml) + 1;
|
||||
|
||||
// push the span
|
||||
let posStart = writer.createPositionAt(codeBlock, child.startOffset + iChildText);
|
||||
spanStack.push({ "className" : className, "posStart": posStart});
|
||||
|
||||
} else if ((html[iHtml] == "<") && (html[iHtml+1] == "/")) {
|
||||
// Done with this span, pop the span and mark the range
|
||||
iHtml = html.indexOf(">", iHtml+1) + 1;
|
||||
|
||||
let stackTop = spanStack.pop();
|
||||
let posStart = stackTop.posStart;
|
||||
let className = stackTop.className;
|
||||
let posEnd = writer.createPositionAt(codeBlock, child.startOffset + iChildText);
|
||||
let range = writer.createRange(posStart, posEnd);
|
||||
let markerName = "hljs:" + className + ":" + markerCounter;
|
||||
// Use an incrementing number for the uniqueId, random of
|
||||
// 10000000 is known to cause collisions with a few
|
||||
// codeblocks of 10s of lines on real notes (each line is
|
||||
// one or more marker).
|
||||
// Wrap-around for good measure so all numbers are positive
|
||||
// XXX Another option is to catch the exception and retry or
|
||||
// go through the markers and get the largest + 1
|
||||
markerCounter = (markerCounter + 1) & 0xFFFFFF;
|
||||
dbg("Found span end " + className);
|
||||
dbg("Adding marker " + markerName + ": " + JSON.stringify(range.toJSON()));
|
||||
writer.addMarker(markerName, {"range": range, "usingOperation": false});
|
||||
|
||||
} else {
|
||||
// Text, we should also have text in the children
|
||||
assert(
|
||||
((iChild < codeBlock.childCount) && (iChildText < childText.length)),
|
||||
"Found text in html with no corresponding child text!!!!"
|
||||
);
|
||||
if (html[iHtml] == "&") {
|
||||
// highlight.js only encodes
|
||||
// .replace(/&/g, '&')
|
||||
// .replace(/</g, '<')
|
||||
// .replace(/>/g, '>')
|
||||
// .replace(/"/g, '"')
|
||||
// .replace(/'/g, ''');
|
||||
// see https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/lib/utils.js#L5
|
||||
let iAmpEnd = html.indexOf(";", iHtml);
|
||||
dbg(html.slice(iHtml, iAmpEnd));
|
||||
iHtml = iAmpEnd + 1;
|
||||
} else {
|
||||
// regular text
|
||||
dbg(html[iHtml]);
|
||||
iHtml++;
|
||||
}
|
||||
iChildText++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import BackendLogWidget from "./content/backend_log.js";
|
||||
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
|
||||
import RibbonOptions from "./options/appearance/ribbon.js";
|
||||
import LocalizationOptions from "./options/appearance/i18n.js";
|
||||
import CodeBlockOptions from "./options/appearance/code_block.js";
|
||||
import EditorOptions from "./options/text_notes/editor.js";
|
||||
|
||||
const TPL = `<div class="note-detail-content-widget note-detail-printable">
|
||||
<style>
|
||||
@@ -59,6 +61,7 @@ const CONTENT_WIDGETS = {
|
||||
LocalizationOptions,
|
||||
ThemeOptions,
|
||||
FontsOptions,
|
||||
CodeBlockOptions,
|
||||
ZoomFactorOptions,
|
||||
NativeTitleBarOptions,
|
||||
MaxContentWidthOptions,
|
||||
@@ -66,6 +69,7 @@ const CONTENT_WIDGETS = {
|
||||
],
|
||||
_optionsShortcuts: [ KeyboardShortcutsOptions ],
|
||||
_optionsTextNotes: [
|
||||
EditorOptions,
|
||||
HeadingStyleOptions,
|
||||
TableOfContentsOptions,
|
||||
HighlightsListOptions,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import libraryLoader from "../../services/library_loader.js";
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import keyboardActionService from "../../services/keyboard_actions.js";
|
||||
import options from "../../services/options.js";
|
||||
import AbstractCodeTypeWidget from "./abstract_code_type_widget.js";
|
||||
|
||||
const TPL = `
|
||||
<div class="note-detail-code note-detail-printable">
|
||||
@@ -21,53 +20,31 @@ const TPL = `
|
||||
<div class="note-detail-code-editor"></div>
|
||||
</div>`;
|
||||
|
||||
export default class EditableCodeTypeWidget extends TypeWidget {
|
||||
export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
|
||||
static getType() { return "editableCode"; }
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$editor = this.$widget.find('.note-detail-code-editor');
|
||||
|
||||
keyboardActionService.setupActionsForElement('code-detail', this.$widget, this);
|
||||
|
||||
super.doRender();
|
||||
|
||||
this.initialized = this.initEditor();
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async initEditor() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR);
|
||||
|
||||
CodeMirror.keyMap.default["Shift-Tab"] = "indentLess";
|
||||
CodeMirror.keyMap.default["Tab"] = "indentMore";
|
||||
|
||||
// these conflict with backward/forward navigation shortcuts
|
||||
delete CodeMirror.keyMap.default["Alt-Left"];
|
||||
delete CodeMirror.keyMap.default["Alt-Right"];
|
||||
|
||||
CodeMirror.modeURL = `${window.glob.assetPath}/node_modules/codemirror/mode/%N/%N.js`;
|
||||
CodeMirror.modeInfo.find(mode=>mode.name === "JavaScript").mimes.push(...["application/javascript;env=frontend", "application/javascript;env=backend"]);
|
||||
CodeMirror.modeInfo.find(mode=>mode.name === "SQLite").mimes=["text/x-sqlite", "text/x-sqlite;schema=trilium"];
|
||||
|
||||
this.codeEditor = CodeMirror(this.$editor[0], {
|
||||
value: "",
|
||||
viewportMargin: Infinity,
|
||||
indentUnit: 4,
|
||||
matchBrackets: true,
|
||||
|
||||
getExtraOpts() {
|
||||
return {
|
||||
keyMap: options.is('vimKeymapEnabled') ? "vim": "default",
|
||||
matchTags: {bothTags: true},
|
||||
highlightSelectionMatches: {showToken: false, annotateScrollbar: false},
|
||||
lint: true,
|
||||
gutters: ["CodeMirror-lint-markers"],
|
||||
lineNumbers: true,
|
||||
tabindex: 300,
|
||||
// we line wrap partly also because without it horizontal scrollbar displays only when you scroll
|
||||
// all the way to the bottom of the note. With line wrap, there's no horizontal scrollbar so no problem
|
||||
lineWrapping: options.is('codeLineWrapEnabled'),
|
||||
dragDrop: false, // with true the editor inlines dropped files which is not what we expect
|
||||
placeholder: t('editable_code.placeholder'),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
onEditorInitialized() {
|
||||
this.codeEditor.on('change', () => this.spacedUpdate.scheduleUpdate());
|
||||
}
|
||||
|
||||
@@ -75,57 +52,18 @@ export default class EditableCodeTypeWidget extends TypeWidget {
|
||||
const blob = await this.note.getBlob();
|
||||
|
||||
await this.spacedUpdate.allowUpdateWithoutChange(() => {
|
||||
// CodeMirror breaks pretty badly on null, so even though it shouldn't happen (guarded by a consistency check)
|
||||
// we provide fallback
|
||||
this.codeEditor.setValue(blob.content || "");
|
||||
this.codeEditor.clearHistory();
|
||||
|
||||
let info = CodeMirror.findModeByMIME(note.mime);
|
||||
if (!info) {
|
||||
// Switch back to plain text if CodeMirror does not have a mode for whatever MIME type we're editing.
|
||||
// To avoid inheriting a mode from a previously open code note.
|
||||
info = CodeMirror.findModeByMIME("text/plain");
|
||||
}
|
||||
|
||||
this.codeEditor.setOption("mode", info.mime);
|
||||
CodeMirror.autoLoadMode(this.codeEditor, info.mode);
|
||||
this._update(note, blob.content);
|
||||
});
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.$widget.show();
|
||||
|
||||
if (this.codeEditor) { // show can be called before render
|
||||
this.codeEditor.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
getData() {
|
||||
return {
|
||||
content: this.codeEditor.getValue()
|
||||
};
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.focus();
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
this.codeEditor.setCursor(this.codeEditor.lineCount(), 0);
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.codeEditor) {
|
||||
this.spacedUpdate.allowUpdateWithoutChange(() => {
|
||||
this.codeEditor.setValue('');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async executeWithCodeEditorEvent({resolve, ntxId}) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,8 @@ import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
|
||||
import link from "../../services/link.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import dialogService from "../../services/dialog.js";
|
||||
import { initSyntaxHighlighting } from "./ckeditor/syntax_highlight.js";
|
||||
import options from "../../services/options.js";
|
||||
|
||||
const ENABLE_INSPECTOR = false;
|
||||
|
||||
@@ -87,6 +89,29 @@ const TPL = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
function buildListOfLanguages() {
|
||||
const userLanguages = (mimeTypesService.getMimeTypes())
|
||||
.filter(mt => mt.enabled)
|
||||
.map(mt => ({
|
||||
language: mimeTypesService.normalizeMimeTypeForCKEditor(mt.mime),
|
||||
label: mt.title
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
language: mimeTypesService.MIME_TYPE_AUTO,
|
||||
label: t("editable-text.auto-detect-language")
|
||||
},
|
||||
...userLanguages
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor can operate into two distinct modes:
|
||||
*
|
||||
* - Ballon block mode, in which there is a floating toolbar for the selected text, but another floating button for the entire block (i.e. paragraph).
|
||||
* - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works.
|
||||
*/
|
||||
export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
static getType() { return "editableText"; }
|
||||
|
||||
@@ -105,21 +130,17 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
|
||||
async initEditor() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
|
||||
const isClassicEditor = (options.get("textNoteEditorType") === "ckeditor-classic")
|
||||
const editorClass = (isClassicEditor ? CKEditor.DecoupledEditor : CKEditor.BalloonEditor);
|
||||
|
||||
const codeBlockLanguages =
|
||||
(await mimeTypesService.getMimeTypes())
|
||||
.filter(mt => mt.enabled)
|
||||
.map(mt => ({
|
||||
language: mt.mime.toLowerCase().replace(/[\W_]+/g,"-"),
|
||||
label: mt.title
|
||||
}));
|
||||
const codeBlockLanguages = buildListOfLanguages();
|
||||
|
||||
// CKEditor since version 12 needs the element to be visible before initialization. At the same time,
|
||||
// we want to avoid flicker - i.e., show editor only once everything is ready. That's why we have separate
|
||||
// display of $widget in both branches.
|
||||
this.$widget.show();
|
||||
|
||||
this.watchdog = new EditorWatchdog(BalloonEditor, {
|
||||
this.watchdog = new CKEditor.EditorWatchdog(editorClass, {
|
||||
// An average number of milliseconds between the last editor errors (defaults to 5000).
|
||||
// When the period of time between errors is lower than that and the crashNumberLimit
|
||||
// is also reached, the watchdog changes its state to crashedPermanently, and it stops
|
||||
@@ -155,7 +176,22 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
});
|
||||
|
||||
this.watchdog.setCreator(async (elementOrData, editorConfig) => {
|
||||
const editor = await BalloonEditor.create(elementOrData, editorConfig);
|
||||
const editor = await editorClass.create(elementOrData, editorConfig);
|
||||
|
||||
await initSyntaxHighlighting(editor);
|
||||
|
||||
if (isClassicEditor) {
|
||||
let $classicToolbarWidget;
|
||||
if (!utils.isMobile()) {
|
||||
const $parentSplit = this.$widget.parents(".note-split.type-text");
|
||||
$classicToolbarWidget = $parentSplit.find("> .ribbon-container .classic-toolbar-widget");
|
||||
} else {
|
||||
$classicToolbarWidget = $("body").find(".classic-toolbar-widget");
|
||||
}
|
||||
|
||||
$classicToolbarWidget.empty();
|
||||
$classicToolbarWidget[0].appendChild(editor.ui.view.toolbar.element);
|
||||
}
|
||||
|
||||
editor.model.document.on('change:data', () => this.spacedUpdate.scheduleUpdate());
|
||||
|
||||
|
||||
@@ -18,14 +18,28 @@ const TPL = `
|
||||
width: 130px;
|
||||
text-align: center;
|
||||
margin: 10px;
|
||||
padding; 10px;
|
||||
border: 1px transparent solid;
|
||||
}
|
||||
|
||||
|
||||
.workspace-notes .workspace-note:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
overflow: scroll;
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search .note-autocomplete-input {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search .input-clearer-button {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.workspace-icon {
|
||||
text-align: center;
|
||||
@@ -33,14 +47,14 @@ const TPL = `
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="workspace-notes"></div>
|
||||
<div class="form-group empty-tab-search">
|
||||
<label>${t('empty.open_note_instruction')}</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group mt-1">
|
||||
<input class="form-control note-autocomplete" placeholder="${t('empty.search_placeholder')}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-notes"></div>
|
||||
<div class="note-detail-empty-results"></div>
|
||||
</div>`;
|
||||
|
||||
export default class EmptyTypeWidget extends TypeWidget {
|
||||
@@ -51,10 +65,13 @@ export default class EmptyTypeWidget extends TypeWidget {
|
||||
|
||||
this.$widget = $(TPL);
|
||||
this.$autoComplete = this.$widget.find(".note-autocomplete");
|
||||
this.$results = this.$widget.find(".note-detail-empty-results");
|
||||
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowCreatingNotes: true
|
||||
allowCreatingNotes: true,
|
||||
allowSearchNotes: true,
|
||||
container: this.$results
|
||||
})
|
||||
.on('autocomplete:noteselected', function(event, suggestion, dataset) {
|
||||
if (!suggestion.notePath) {
|
||||
@@ -66,6 +83,7 @@ export default class EmptyTypeWidget extends TypeWidget {
|
||||
|
||||
this.$workspaceNotes = this.$widget.find('.workspace-notes');
|
||||
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import library_loader from "../../../../services/library_loader.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
|
||||
const SAMPLE_LANGUAGE = "javascript";
|
||||
const SAMPLE_CODE = `\
|
||||
const n = 10;
|
||||
greet(n); // Print "Hello World" for n times
|
||||
|
||||
/**
|
||||
* Displays a "Hello World!" message for a given amount of times, on the standard console. The "Hello World!" text will be displayed once per line.
|
||||
*
|
||||
* @param {number} times The number of times to print the \`Hello World!\` message.
|
||||
*/
|
||||
function greet(times) {
|
||||
for (let i = 0; i++; i < times) {
|
||||
console.log("Hello World!");
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TPL = `
|
||||
<div class="options-section">
|
||||
<h4>${t("highlighting.title")}</h4>
|
||||
|
||||
<p>${t("highlighting.description")}</p>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-6">
|
||||
<label>${t("highlighting.color-scheme")}</label>
|
||||
<select class="theme-select form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6 side-checkbox">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" class="word-wrap form-check-input" />
|
||||
${t("code_block.word_wrapping")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="note-detail-readonly-text-content ck-content code-sample-wrapper">
|
||||
<pre class="hljs"><code class="code-sample">${SAMPLE_CODE}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.code-sample-wrapper {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Contains appearance settings for code blocks within text notes, such as the theme for the syntax highlighter.
|
||||
*/
|
||||
export default class CodeBlockOptions extends OptionsWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$themeSelect = this.$widget.find(".theme-select");
|
||||
this.$themeSelect.on("change", async () => {
|
||||
const newTheme = this.$themeSelect.val();
|
||||
library_loader.loadHighlightingTheme(newTheme);
|
||||
await server.put(`options/codeBlockTheme/${newTheme}`);
|
||||
});
|
||||
|
||||
this.$wordWrap = this.$widget.find("input.word-wrap");
|
||||
this.$wordWrap.on("change", () => this.updateCheckboxOption("codeBlockWordWrap", this.$wordWrap));
|
||||
|
||||
// Set up preview
|
||||
this.$sampleEl = this.$widget.find(".code-sample");
|
||||
}
|
||||
|
||||
#setupPreview(shouldEnableSyntaxHighlight) {
|
||||
const text = SAMPLE_CODE;
|
||||
if (shouldEnableSyntaxHighlight) {
|
||||
library_loader
|
||||
.requireLibrary(library_loader.HIGHLIGHT_JS)
|
||||
.then(() => {
|
||||
const highlightedText = hljs.highlight(text, {
|
||||
language: SAMPLE_LANGUAGE
|
||||
});
|
||||
this.$sampleEl.html(highlightedText.value);
|
||||
});
|
||||
} else {
|
||||
this.$sampleEl.text(text);
|
||||
}
|
||||
}
|
||||
|
||||
async optionsLoaded(options) {
|
||||
const themeGroups = await server.get("options/codeblock-themes");
|
||||
this.$themeSelect.empty();
|
||||
|
||||
for (const [ key, themes ] of Object.entries(themeGroups)) {
|
||||
const $group = (key ? $("<optgroup>").attr("label", key) : null);
|
||||
|
||||
for (const theme of themes) {
|
||||
const option = $("<option>")
|
||||
.attr("value", theme.val)
|
||||
.text(theme.title);
|
||||
|
||||
if ($group) {
|
||||
$group.append(option);
|
||||
} else {
|
||||
this.$themeSelect.append(option);
|
||||
}
|
||||
}
|
||||
this.$themeSelect.append($group);
|
||||
}
|
||||
this.$themeSelect.val(options.codeBlockTheme);
|
||||
this.setCheckboxState(this.$wordWrap, options.codeBlockWordWrap);
|
||||
this.$widget.closest(".note-detail-printable").toggleClass("word-wrap", options.codeBlockWordWrap === "true");
|
||||
|
||||
this.#setupPreview(options.codeBlockTheme !== "none");
|
||||
}
|
||||
}
|
||||
@@ -133,14 +133,17 @@ export default class FontsOptions extends OptionsWidget {
|
||||
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp("changes from appearance options"));
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this._isEnabled;
|
||||
}
|
||||
|
||||
async optionsLoaded(options) {
|
||||
if (options.overrideThemeFonts !== 'true') {
|
||||
this.toggleInt(false);
|
||||
this._isEnabled = (options.overrideThemeFonts === 'true');
|
||||
this.toggleInt(this._isEnabled);
|
||||
if (!this._isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleInt(true);
|
||||
|
||||
this.$mainFontSize.val(options.mainFontSize);
|
||||
this.fillFontFamilyOptions(this.$mainFontFamily, options.mainFontFamily);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import OptionsWidget from "../options_widget.js";
|
||||
import utils from "../../../../services/utils.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
|
||||
const MIN_VALUE = 640;
|
||||
|
||||
const TPL = `
|
||||
<div class="options-section">
|
||||
<h4>${t("max_content_width.title")}</h4>
|
||||
@@ -11,7 +13,7 @@ const TPL = `
|
||||
<div class="form-group row">
|
||||
<div class="col-6">
|
||||
<label>${t("max_content_width.max_width_label")}</label>
|
||||
<input type="number" min="200" step="10" class="max-content-width form-control options-number-input">
|
||||
<input type="number" min="${MIN_VALUE}" step="10" class="max-content-width form-control options-number-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +36,6 @@ export default class MaxContentWidthOptions extends OptionsWidget {
|
||||
}
|
||||
|
||||
async optionsLoaded(options) {
|
||||
this.$maxContentWidth.val(options.maxContentWidth);
|
||||
this.$maxContentWidth.val(Math.max(MIN_VALUE, options.maxContentWidth));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ const TPL = `
|
||||
<select class="theme-select form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<label>${t("theme.override_theme_fonts_label")}</label>
|
||||
<div class="form-check">
|
||||
<div class="col-6 side-checkbox">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" class="override-theme-fonts form-check-input">
|
||||
</div>
|
||||
${t("theme.override_theme_fonts_label")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -42,7 +42,21 @@ const TPL = `
|
||||
<div class="options-section">
|
||||
<h4>${t('backup.existing_backups')}</h4>
|
||||
|
||||
<ul class="existing-backup-list"></ul>
|
||||
<table class="table table-stripped">
|
||||
<colgroup>
|
||||
<col width="33%" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("backup.date-and-time")}</th>
|
||||
<th>${t("backup.path")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="existing-backup-list-items">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -73,7 +87,7 @@ export default class BackupOptions extends OptionsWidget {
|
||||
this.$monthlyBackupEnabled.on('change', () =>
|
||||
this.updateCheckboxOption('monthlyBackupEnabled', this.$monthlyBackupEnabled));
|
||||
|
||||
this.$existingBackupList = this.$widget.find(".existing-backup-list");
|
||||
this.$existingBackupList = this.$widget.find(".existing-backup-list-items");
|
||||
}
|
||||
|
||||
optionsLoaded(options) {
|
||||
@@ -85,11 +99,34 @@ export default class BackupOptions extends OptionsWidget {
|
||||
this.$existingBackupList.empty();
|
||||
|
||||
if (!backupFiles.length) {
|
||||
backupFiles = [{filePath: t('backup.no_backup_yet'), mtime: ''}];
|
||||
this.$existingBackupList.append($(`
|
||||
<tr>
|
||||
<td class="empty-table-placeholder" colspan="2">${t('backup.no_backup_yet')}</td>
|
||||
</tr>
|
||||
`));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the backup files by modification date & time in a desceding order
|
||||
backupFiles.sort((a, b) => {
|
||||
if (a.mtime < b.mtime) return 1;
|
||||
if (a.mtime > b.mtime) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat(navigator.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium"
|
||||
});
|
||||
|
||||
for (const {filePath, mtime} of backupFiles) {
|
||||
this.$existingBackupList.append($("<li>").text(`${filePath} ${mtime ? ` - ${mtime}` : ''}`));
|
||||
this.$existingBackupList.append($(`
|
||||
<tr>
|
||||
<td>${(mtime) ? dateTimeFormatter.format(new Date(mtime)) : "-"}</td>
|
||||
<td>${filePath}</td>
|
||||
</tr>
|
||||
`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,9 +19,24 @@ export default class CodeMimeTypesOptions extends OptionsWidget {
|
||||
|
||||
async optionsLoaded(options) {
|
||||
this.$mimeTypes.empty();
|
||||
let index = -1;
|
||||
let prevInitial = "";
|
||||
|
||||
for (const mimeType of await mimeTypesService.getMimeTypes()) {
|
||||
for (const mimeType of mimeTypesService.getMimeTypes()) {
|
||||
const id = "code-mime-type-" + (idCtr++);
|
||||
index++;
|
||||
|
||||
// Append a heading to group items by the first letter, excepting for the
|
||||
// first item ("Plain Text"). Note: this code assumes the items are already
|
||||
// in alphabetical ordered.
|
||||
if (index > 0) {
|
||||
const initial = mimeType.title.charAt(0).toUpperCase();
|
||||
|
||||
if (initial !== prevInitial) {
|
||||
this.$mimeTypes.append($("<h5>").text(initial));
|
||||
prevInitial = initial;
|
||||
}
|
||||
}
|
||||
|
||||
this.$mimeTypes.append($("<li>")
|
||||
.append($('<input type="checkbox" class="form-check-input">')
|
||||
|
||||
@@ -95,9 +95,9 @@ export default class EtapiOptions extends OptionsWidget {
|
||||
.append($("<td>").text(token.name))
|
||||
.append($("<td>").text(token.utcDateCreated))
|
||||
.append($("<td>").append(
|
||||
$('<span class="bx bx-pen token-table-button" title="${t("etapi.rename_token")}"></span>')
|
||||
$(`<span class="bx bx-pen token-table-button" title="${t("etapi.rename_token")}"></span>`)
|
||||
.on("click", () => this.renameToken(token.etapiTokenId, token.name)),
|
||||
$('<span class="bx bx-trash token-table-button" title="${t("etapi.delete_token")}"></span>')
|
||||
$(`<span class="bx bx-trash token-table-button" title="${t("etapi.delete_token")}"></span>`)
|
||||
.on("click", () => this.deleteToken(token.etapiTokenId, token.name))
|
||||
))
|
||||
);
|
||||
|
||||
@@ -44,6 +44,20 @@ export default class OptionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
optionsLoaded(options) {}
|
||||
|
||||
async refresh() {
|
||||
this.toggleInt(this.isEnabled());
|
||||
try {
|
||||
await this.refreshWithNote(this.note);
|
||||
} catch (e) {
|
||||
// Ignore errors when user is refreshing or navigating away.
|
||||
if (e === "rejected by browser") {
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithNote(note) {
|
||||
const options = await server.get('options');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user