mirror of
https://github.com/zadam/trilium.git
synced 2026-03-10 06:00:23 +01:00
Compare commits
254 Commits
v0.102.0
...
autocomple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e5c051864 | ||
|
|
69702ef0f3 | ||
|
|
0f5c777a57 | ||
|
|
640c324c45 | ||
|
|
109149a334 | ||
|
|
7a6c888150 | ||
|
|
66ac51bd8a | ||
|
|
9018b49548 | ||
|
|
bfd7f481f8 | ||
|
|
a462fe13c4 | ||
|
|
248348f7d4 | ||
|
|
b878b61e47 | ||
|
|
0fc2c9f263 | ||
|
|
249fec6191 | ||
|
|
bea6988f57 | ||
|
|
13e3f76191 | ||
|
|
64790c1830 | ||
|
|
e220e4ec9d | ||
|
|
e5a9c0de49 | ||
|
|
2690256f27 | ||
|
|
50ee113bd1 | ||
|
|
a6e0df70d7 | ||
|
|
0ef7dd8fc2 | ||
|
|
bd1f6b7a0f | ||
|
|
cc24c2a4dc | ||
|
|
eaa6da73ca | ||
|
|
84ea5d3401 | ||
|
|
65095713cc | ||
|
|
8f6b565673 | ||
|
|
c0dd59458b | ||
|
|
d363d2016e | ||
|
|
702df56a8a | ||
|
|
91677eb7da | ||
|
|
e74f619d06 | ||
|
|
1c79beaa57 | ||
|
|
42a2b1bc13 | ||
|
|
8393b916c1 | ||
|
|
656796d18d | ||
|
|
0fe00b77cf | ||
|
|
150f88cc56 | ||
|
|
1b013e6888 | ||
|
|
2dfc4514b0 | ||
|
|
d652f67364 | ||
|
|
5e54d098c5 | ||
|
|
ec95303c31 | ||
|
|
07aafe7e89 | ||
|
|
dc7acbb70e | ||
|
|
0dcb8b3ff8 | ||
|
|
e4ddff01ca | ||
|
|
015c1161d4 | ||
|
|
ca0c6076c5 | ||
|
|
80a02f88be | ||
|
|
430833bedb | ||
|
|
dc80d83964 | ||
|
|
5f7ade45f4 | ||
|
|
8b36a7ab1e | ||
|
|
fd18276693 | ||
|
|
0becfc16ba | ||
|
|
d480d1f6ba | ||
|
|
f5c9a71ba0 | ||
|
|
c177a8a464 | ||
|
|
c826564c9e | ||
|
|
ccb13fa6b9 | ||
|
|
69e374138f | ||
|
|
3156b2cb59 | ||
|
|
d6217ffed4 | ||
|
|
fc90c6af9d | ||
|
|
a1118419ec | ||
|
|
8599785ee8 | ||
|
|
99ba192a44 | ||
|
|
b86d3587ac | ||
|
|
b2a0baf56a | ||
|
|
22f37817e5 | ||
|
|
6b4fe03625 | ||
|
|
f44b47ec23 | ||
|
|
8d667e838a | ||
|
|
f32385de2e | ||
|
|
90796fc4fa | ||
|
|
4960c49cb2 | ||
|
|
b112e8b56b | ||
|
|
83095130f6 | ||
|
|
d005c0ef2d | ||
|
|
c135578626 | ||
|
|
9a6e20029e | ||
|
|
39bd4ccea1 | ||
|
|
ea7aac2030 | ||
|
|
e7f98f08d0 | ||
|
|
8ac9daa5d3 | ||
|
|
0b506c6327 | ||
|
|
d2b62540ec | ||
|
|
64418c7fec | ||
|
|
8c1a58e64f | ||
|
|
b27fd31c1f | ||
|
|
f18a531924 | ||
|
|
3cabb4b661 | ||
|
|
5c88b1c6b8 | ||
|
|
c2adc43780 | ||
|
|
7eaa5352ba | ||
|
|
17e3e3187b | ||
|
|
2ad7cd3a49 | ||
|
|
39aa8d61c2 | ||
|
|
1a3ea977b7 | ||
|
|
4cd8f9a1e6 | ||
|
|
87ce6d1231 | ||
|
|
8fdbeacf77 | ||
|
|
f4f775a1c9 | ||
|
|
fe1154cb2d | ||
|
|
638f479ff3 | ||
|
|
70436bdb04 | ||
|
|
575ecaae07 | ||
|
|
d277e6db94 | ||
|
|
25efcd12d0 | ||
|
|
10129321be | ||
|
|
72710a8f6b | ||
|
|
6a7c5c04d8 | ||
|
|
5d89591dea | ||
|
|
a88bf5a87b | ||
|
|
bbe5d3506e | ||
|
|
c2993d4e7d | ||
|
|
17ba479182 | ||
|
|
a465014bbe | ||
|
|
5dfe253ef6 | ||
|
|
ae7ca6021f | ||
|
|
c389697acd | ||
|
|
c13c3e0f4a | ||
|
|
82c042d045 | ||
|
|
9145ba1690 | ||
|
|
d60653ee17 | ||
|
|
dae8613b4e | ||
|
|
2f8e2c40be | ||
|
|
d85225a0dc | ||
|
|
0cb66df2b2 | ||
|
|
92e0578cb6 | ||
|
|
2eee06786e | ||
|
|
19053dcb3b | ||
|
|
e10c30c59f | ||
|
|
c356159664 | ||
|
|
579be68ca1 | ||
|
|
a6326a682e | ||
|
|
4595a3a5dd | ||
|
|
ee21185e64 | ||
|
|
6d0676c37d | ||
|
|
1d4768a581 | ||
|
|
d086bb7fcb | ||
|
|
2607c4a32e | ||
|
|
624333a2ef | ||
|
|
d4acb37f21 | ||
|
|
6c1a1e9812 | ||
|
|
9a13641f9b | ||
|
|
699e0624c9 | ||
|
|
47ceb0d4d2 | ||
|
|
15c42f4a09 | ||
|
|
bf8401bb26 | ||
|
|
f234433c63 | ||
|
|
1b70101123 | ||
|
|
d610c63c28 | ||
|
|
5e820a407f | ||
|
|
62610979b7 | ||
|
|
700e99e854 | ||
|
|
7767116b3d | ||
|
|
0206e8247b | ||
|
|
5476fe3df9 | ||
|
|
d9a4581d37 | ||
|
|
8d9c888481 | ||
|
|
11e4b672d1 | ||
|
|
bace3daadc | ||
|
|
dee5380e60 | ||
|
|
bc6a6fd860 | ||
|
|
e928337fe9 | ||
|
|
432f86ea4b | ||
|
|
5d2daecee0 | ||
|
|
7c8eb311af | ||
|
|
4ac22678df | ||
|
|
5057c02176 | ||
|
|
d301e56216 | ||
|
|
3c22ab8c9c | ||
|
|
0212398815 | ||
|
|
db0c515bad | ||
|
|
9b4f8c5003 | ||
|
|
85d8c4c8fa | ||
|
|
5afab6938a | ||
|
|
a437169ad5 | ||
|
|
f632d3aeb6 | ||
|
|
513fffcb1a | ||
|
|
c80bb9657c | ||
|
|
0ca179f990 | ||
|
|
9d104015f3 | ||
|
|
2c4cf2dcf1 | ||
|
|
d2e0124962 | ||
|
|
cd59c75c04 | ||
|
|
caa9143591 | ||
|
|
7e53810c02 | ||
|
|
12efa8dc0b | ||
|
|
4d0ccac7b5 | ||
|
|
8b023a55d0 | ||
|
|
b4df5fcbd9 | ||
|
|
6fbe5718e9 | ||
|
|
908bafca63 | ||
|
|
d7313efd67 | ||
|
|
a51e15c9b8 | ||
|
|
37e9c7d639 | ||
|
|
2d00ac4dfb | ||
|
|
6aec7eae00 | ||
|
|
6bfbc2d35e | ||
|
|
2ffc854ce6 | ||
|
|
ddd4a374e4 | ||
|
|
0d6e2fc00f | ||
|
|
366a8e8726 | ||
|
|
7f0aa0697a | ||
|
|
d123ce33b8 | ||
|
|
55588f5962 | ||
|
|
f32130d5c2 | ||
|
|
03f4ff9e7c | ||
|
|
6de78c7154 | ||
|
|
d331e418d4 | ||
|
|
4ace74bcb8 | ||
|
|
1d4a336256 | ||
|
|
ee6c192ab9 | ||
|
|
b220bdce9c | ||
|
|
4d86c6c4f1 | ||
|
|
4fd68bf12d | ||
|
|
3ffe34964f | ||
|
|
faaf26c174 | ||
|
|
f9c7518db2 | ||
|
|
8357c2a39c | ||
|
|
793dcee562 | ||
|
|
00368fc131 | ||
|
|
f81b686f41 | ||
|
|
4c5aada5d3 | ||
|
|
05551cec9e | ||
|
|
6300a8c8d1 | ||
|
|
ca4d15727d | ||
|
|
2fe076086e | ||
|
|
56b65ddfae | ||
|
|
fcf6673825 | ||
|
|
9eda264f52 | ||
|
|
fe1270c679 | ||
|
|
679e1ac678 | ||
|
|
e309ff2d17 | ||
|
|
c910335155 | ||
|
|
5606cde506 | ||
|
|
0e2f4f4e13 | ||
|
|
1f6c6f2acd | ||
|
|
37d2e9f14b | ||
|
|
0fcf30a3b8 | ||
|
|
8712e7dd16 | ||
|
|
2ee4e9cc14 | ||
|
|
b257b75be2 | ||
|
|
2de2709420 | ||
|
|
34ca7912fc | ||
|
|
dc3de5bf36 | ||
|
|
1041bf70e1 | ||
|
|
0c6326b678 | ||
|
|
fd805a5279 |
35
.github/workflows/dev.yml
vendored
35
.github/workflows/dev.yml
vendored
@@ -37,8 +37,35 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run the unit tests
|
||||
run: pnpm run test:all
|
||||
- name: Run the client-side tests
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
path: apps/client/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run the server-side tests
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
path: apps/server/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run CKEditor e2e tests
|
||||
run: |
|
||||
pnpm run --filter=ckeditor5-mermaid test
|
||||
pnpm run --filter=ckeditor5-math test
|
||||
|
||||
- name: Run the rest of the tests
|
||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -63,7 +90,7 @@ jobs:
|
||||
- name: Trigger server build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
@@ -100,7 +127,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
|
||||
134
.github/workflows/main-docker.yml
vendored
134
.github/workflows/main-docker.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -164,11 +164,9 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
@@ -177,36 +175,27 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: |
|
||||
type=image,name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
type=image,name=${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
@@ -239,75 +228,86 @@ jobs:
|
||||
- name: Set TEST_TAG to lowercase
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=false
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@v0.5
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Verify digests exist on GHCR
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
# Extract the branch or tag name from the ref
|
||||
REF_NAME=$(echo "${GITHUB_REF}" | sed 's/refs\/heads\///' | sed 's/refs\/tags\///')
|
||||
echo "Verifying all digests are available on GHCR..."
|
||||
for DIGEST_FILE in *; do
|
||||
DIGEST="sha256:${DIGEST_FILE}"
|
||||
echo -n " ${DIGEST}: "
|
||||
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" > /dev/null
|
||||
echo "OK"
|
||||
done
|
||||
|
||||
# Create and push the manifest list with both the branch/tag name and the commit SHA
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
|
||||
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
GHCR_IMAGE="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
DOCKERHUB_IMAGE="${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
|
||||
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 ' *)
|
||||
# Build -m flags for crane index append from digest files
|
||||
MANIFEST_ARGS=""
|
||||
for d in *; do
|
||||
MANIFEST_ARGS="${MANIFEST_ARGS} -m ${GHCR_IMAGE}@sha256:${d}"
|
||||
done
|
||||
|
||||
# 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...
|
||||
# Create multi-arch manifest for each tag from metadata, plus copy to DockerHub
|
||||
while IFS= read -r TAG; do
|
||||
echo "Creating manifest: ${TAG}"
|
||||
crane index append ${MANIFEST_ARGS} -t "${TAG}"
|
||||
|
||||
SUFFIX="${TAG#*:}"
|
||||
echo "Copying to DockerHub: ${DOCKERHUB_IMAGE}:${SUFFIX}"
|
||||
crane copy "${TAG}" "${DOCKERHUB_IMAGE}:${SUFFIX}"
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# For stable releases (tags without hyphens), also create stable + latest
|
||||
REF_NAME="${GITHUB_REF#refs/tags/}"
|
||||
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
|
||||
echo "Creating stable tags..."
|
||||
crane index append ${MANIFEST_ARGS} -t "${GHCR_IMAGE}:stable"
|
||||
crane copy "${GHCR_IMAGE}:stable" "${DOCKERHUB_IMAGE}:stable"
|
||||
|
||||
echo "Creating latest tags..."
|
||||
crane copy "${GHCR_IMAGE}:stable" "${GHCR_IMAGE}:latest"
|
||||
crane copy "${GHCR_IMAGE}:latest" "${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
- name: Inspect manifests
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
REF_NAME="${GITHUB_REF#refs/heads/}"
|
||||
REF_NAME="${REF_NAME#refs/tags/}"
|
||||
echo "=== GHCR ==="
|
||||
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
|
||||
echo ""
|
||||
echo "=== DockerHub ==="
|
||||
crane manifest "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.19.2",
|
||||
"@redocly/cli": "2.20.2",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -16,6 +16,7 @@
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-js": "1.19.6",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
@@ -28,15 +29,22 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.16.1",
|
||||
"@univerjs/preset-sheets-core": "0.16.1",
|
||||
"@univerjs/preset-sheets-data-validation": "0.16.1",
|
||||
"@univerjs/preset-sheets-filter": "0.16.1",
|
||||
"@univerjs/preset-sheets-find-replace": "0.16.1",
|
||||
"@univerjs/preset-sheets-note": "0.16.1",
|
||||
"@univerjs/preset-sheets-sort": "0.16.1",
|
||||
"@univerjs/presets": "0.16.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
@@ -44,29 +52,29 @@
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.3.0",
|
||||
"i18next": "25.8.13",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.8.14",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.33",
|
||||
"katex": "0.16.38",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.9.1",
|
||||
"mind-elixir": "5.9.2",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-i18next": "16.5.6",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "5.2.1",
|
||||
"rrule": "2.8.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"tabulator-tables": "6.4.0",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -79,10 +87,10 @@
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.7.0",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.3",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||
|
||||
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
|
||||
import bundleService from "../services/bundle.js";
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import froca from "../services/froca.js";
|
||||
@@ -197,7 +198,7 @@ export default class Entrypoints extends Component {
|
||||
|
||||
hideAllPopups() {
|
||||
if (utils.isDesktop()) {
|
||||
$(".aa-input").autocomplete("close");
|
||||
closeAllHeadlessAutocompletes();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import Component from "./component.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import server from "../services/server.js";
|
||||
import options from "../services/options.js";
|
||||
import froca from "../services/froca.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import NoteContext from "./note_context.js";
|
||||
import appContext from "./app_context.js";
|
||||
import Mutex from "../utils/mutex.js";
|
||||
import linkService from "../services/link.js";
|
||||
import type { EventData } from "./app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import options from "../services/options.js";
|
||||
import server from "../services/server.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import Mutex from "../utils/mutex.js";
|
||||
import type { EventData } from "./app_context.js";
|
||||
import appContext from "./app_context.js";
|
||||
import Component from "./component.js";
|
||||
import NoteContext from "./note_context.js";
|
||||
|
||||
interface TabState {
|
||||
contexts: NoteContext[];
|
||||
@@ -429,10 +430,7 @@ export default class TabManager extends Component {
|
||||
}
|
||||
|
||||
// close dangling autocompletes after closing the tab
|
||||
const $autocompleteEl = $(".aa-input");
|
||||
if ("autocomplete" in $autocompleteEl) {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
closeAllHeadlessAutocompletes();
|
||||
|
||||
// close dangling tooltips
|
||||
$("body > div.tooltip").remove();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const RELATION = "relation";
|
||||
* end user. Those types should be used only for checking against, they are
|
||||
* not for direct use.
|
||||
*/
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap";
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
|
||||
|
||||
export interface NotePathRecord {
|
||||
isArchived: boolean;
|
||||
|
||||
@@ -16,17 +16,6 @@ async function initJQuery() {
|
||||
const $ = (await import("jquery")).default;
|
||||
window.$ = $;
|
||||
window.jQuery = $;
|
||||
|
||||
// Polyfill removed jQuery methods for autocomplete.js compatibility
|
||||
($ as any).isArray = Array.isArray;
|
||||
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
|
||||
($ as any).isPlainObject = function(obj: any) {
|
||||
if (obj == null || typeof obj !== 'object') { return false; }
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto === null) { return true; }
|
||||
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
|
||||
return typeof Ctor === 'function' && Ctor === Object;
|
||||
};
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import appContext from "./components/app_context.js";
|
||||
import glob from "./services/glob.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
|
||||
@@ -8,17 +8,6 @@ async function loadBootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
// Polyfill removed jQuery methods for autocomplete.js compatibility
|
||||
($ as any).isArray = Array.isArray;
|
||||
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
|
||||
($ as any).isPlainObject = function(obj: any) {
|
||||
if (obj == null || typeof obj !== 'object') { return false; }
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto === null) { return true; }
|
||||
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
|
||||
return typeof Ctor === 'function' && Ctor === Object;
|
||||
};
|
||||
|
||||
(window as any).$ = $;
|
||||
(window as any).jQuery = $;
|
||||
await loadBootstrap();
|
||||
|
||||
47
apps/client/src/services/attribute_autocomplete.spec.ts
Normal file
47
apps/client/src/services/attribute_autocomplete.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js";
|
||||
|
||||
describe("attribute autocomplete enter handling", () => {
|
||||
it("delegates plain Enter when the panel is open and an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delegate plain Enter when there is no active suggestion", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: false }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate plain Enter when the panel is closed", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: false, hasActiveItem: false }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate Ctrl+Enter even when an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: true, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate Cmd+Enter even when an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: true },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-Enter keys", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "ArrowDown", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: false, hasActiveItem: false }
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +1,450 @@
|
||||
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
||||
import { createAutocomplete } from "@algolia/autocomplete-core";
|
||||
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
|
||||
import server from "./server.js";
|
||||
|
||||
interface InitOptions {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NameItem extends BaseItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function shouldAutocompleteHandleEnterKey(
|
||||
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
|
||||
{ isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean }
|
||||
) {
|
||||
if (event.key !== "Enter") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPanelOpen && hasActiveItem;
|
||||
}
|
||||
|
||||
interface InitAttributeNameOptions {
|
||||
/** The <input> element where the user types */
|
||||
$el: JQuery<HTMLElement>;
|
||||
attributeType?: AttributeType | (() => AttributeType);
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
/** Called when the user selects a value or the panel closes */
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $el - element on which to init autocomplete
|
||||
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
|
||||
* @param open - should the autocomplete be opened after init?
|
||||
*/
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
|
||||
if (!$el.hasClass("aa-input")) {
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "name",
|
||||
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
|
||||
cache: false,
|
||||
source: async (term, cb) => {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
|
||||
const result = names.map((name) => ({ name }));
|
||||
interface ManagedInstance {
|
||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
||||
panelEl: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
cb(result);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
function renderItems(
|
||||
panelEl: HTMLElement,
|
||||
items: NameItem[],
|
||||
activeItemId: number | null,
|
||||
onSelect: (item: NameItem) => void,
|
||||
onActivate: (index: number) => void,
|
||||
onDeactivate: () => void
|
||||
): void {
|
||||
panelEl.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
panelEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "aa-core-list";
|
||||
items.forEach((item, index) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "aa-core-item";
|
||||
if (index === activeItemId) {
|
||||
li.classList.add("aa-core-item--active");
|
||||
}
|
||||
li.textContent = item.name;
|
||||
li.addEventListener("mousemove", () => {
|
||||
if (activeItemId === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
onActivate(index);
|
||||
});
|
||||
}
|
||||
li.addEventListener("mouseleave", (event) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
}
|
||||
onDeactivate();
|
||||
});
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
panelEl.appendChild(list);
|
||||
}
|
||||
|
||||
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
|
||||
if ($el.hasClass("aa-input")) {
|
||||
// we reinit every time because autocomplete seems to have a bug where it retains state from last
|
||||
// open even though the value was reset
|
||||
$el.autocomplete("destroy");
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let attributeName = "";
|
||||
if (nameCallback) {
|
||||
attributeName = nameCallback();
|
||||
}
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
if (attributeName.trim() === "") {
|
||||
// Already initialized — just open if requested
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||
const panelController = createHeadlessPanelController({ inputEl });
|
||||
const { panelEl } = panelController;
|
||||
|
||||
if (attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: false, // handled manually
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: 0,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
cache: false,
|
||||
source: async function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
getSources({ query }) {
|
||||
return [
|
||||
withHeadlessSourceDefaults({
|
||||
sourceId: "attribute-names",
|
||||
getItems() {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
return server
|
||||
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
|
||||
.then((names) => names.map((name) => ({ name })));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
// Render items
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(
|
||||
panelEl,
|
||||
items,
|
||||
activeId,
|
||||
(item) => {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
(index) => {
|
||||
autocomplete.setActiveItemId(index);
|
||||
},
|
||||
() => {
|
||||
autocomplete.setActiveItemId(null);
|
||||
}
|
||||
);
|
||||
panelController.startPositioning();
|
||||
} else {
|
||||
panelController.hide();
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
if (!state.isOpen) {
|
||||
panelController.hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
});
|
||||
|
||||
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput(e, handlers) {
|
||||
handlers.onChange(e as any);
|
||||
},
|
||||
onFocus(e, handlers) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
},
|
||||
onBlur() {
|
||||
// Delay to allow mousedown on panel items
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
},
|
||||
onKeyDown(e, handlers) {
|
||||
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// Prevent the enter key from propagating to parent dialogs
|
||||
// (which might interpret it as "submit" or "save and close")
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e as any);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
unregisterGlobalCloser();
|
||||
cleanupInputBindings();
|
||||
panelController.destroy();
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
panelController.startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (headless autocomplete-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LabelValueInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const panelController = createHeadlessPanelController({ inputEl });
|
||||
const { panelEl } = panelController;
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
let isSelecting = false;
|
||||
|
||||
let cachedAttributeName = "";
|
||||
let cachedAttributeValues: NameItem[] = [];
|
||||
|
||||
const handleSelect = (item: NameItem) => {
|
||||
isSelecting = true;
|
||||
inputEl.value = item.name;
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
isSelecting = false;
|
||||
|
||||
setTimeout(() => {
|
||||
// Preserve the legacy contract: several consumers still commit the
|
||||
// selected value from their existing Enter key handlers instead of
|
||||
// listening to the autocomplete selection event directly.
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: null,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
withHeadlessSourceDefaults({
|
||||
sourceId: "attribute-values",
|
||||
async getItems() {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (!attributeName.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
|
||||
cachedAttributeName = attributeName;
|
||||
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
|
||||
cachedAttributeValues = values.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
handleSelect(item);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(
|
||||
panelEl,
|
||||
items,
|
||||
activeId,
|
||||
handleSelect,
|
||||
(index) => {
|
||||
autocomplete.setActiveItemId(index);
|
||||
},
|
||||
() => {
|
||||
autocomplete.setActiveItemId(null);
|
||||
}
|
||||
);
|
||||
panelController.startPositioning();
|
||||
} else {
|
||||
panelController.hide();
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelController.hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
});
|
||||
|
||||
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput(e, handlers) {
|
||||
if (!isSelecting) {
|
||||
handlers.onChange(e as any);
|
||||
}
|
||||
},
|
||||
onFocus(e, handlers) {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (attributeName !== cachedAttributeName) {
|
||||
cachedAttributeName = "";
|
||||
cachedAttributeValues = [];
|
||||
}
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
},
|
||||
onBlur() {
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
},
|
||||
onKeyDown(e, handlers) {
|
||||
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e as any);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
unregisterGlobalCloser();
|
||||
cleanupInputBindings();
|
||||
panelController.destroy();
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
panelController.startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
|
||||
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
|
||||
const instance = instanceMap.get(inputEl);
|
||||
if (instance) {
|
||||
instance.cleanup();
|
||||
instanceMap.delete(inputEl);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initAttributeNameAutocomplete,
|
||||
initLabelValueAutocomplete
|
||||
destroyAutocomplete,
|
||||
initLabelValueAutocomplete,
|
||||
};
|
||||
|
||||
93
apps/client/src/services/autocomplete_core.spec.ts
Normal file
93
apps/client/src/services/autocomplete_core.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import $ from "jquery";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
showSpy,
|
||||
hideSpy,
|
||||
updateDisplayedShortcutsSpy,
|
||||
saveFocusedElementSpy,
|
||||
focusSavedElementSpy
|
||||
} = vi.hoisted(() => ({
|
||||
showSpy: vi.fn(),
|
||||
hideSpy: vi.fn(),
|
||||
updateDisplayedShortcutsSpy: vi.fn(),
|
||||
saveFocusedElementSpy: vi.fn(),
|
||||
focusSavedElementSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("bootstrap", () => ({
|
||||
Modal: {
|
||||
getOrCreateInstance: vi.fn(() => ({
|
||||
show: showSpy,
|
||||
hide: hideSpy
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("./keyboard_actions.js", () => ({
|
||||
default: {
|
||||
updateDisplayedShortcuts: updateDisplayedShortcutsSpy
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("./focus.js", () => ({
|
||||
saveFocusedElement: saveFocusedElementSpy,
|
||||
focusSavedElement: focusSavedElementSpy
|
||||
}));
|
||||
|
||||
import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js";
|
||||
import { openDialog } from "./dialog.js";
|
||||
|
||||
describe("headless autocomplete closing", () => {
|
||||
const unregisterClosers: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window as any).glob = {
|
||||
...(window as any).glob,
|
||||
activeDialog: null
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (unregisterClosers.length > 0) {
|
||||
unregisterClosers.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes every registered closer and skips unregistered ones", () => {
|
||||
const closer1 = vi.fn();
|
||||
const closer2 = vi.fn();
|
||||
const closer3 = vi.fn();
|
||||
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1));
|
||||
const unregister2 = registerHeadlessAutocompleteCloser(closer2);
|
||||
unregisterClosers.push(unregister2);
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3));
|
||||
|
||||
unregister2();
|
||||
|
||||
closeAllHeadlessAutocompletes();
|
||||
|
||||
expect(closer1).toHaveBeenCalledTimes(1);
|
||||
expect(closer2).not.toHaveBeenCalled();
|
||||
expect(closer3).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes registered autocompletes when a dialog finishes hiding", async () => {
|
||||
const closer = vi.fn();
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer));
|
||||
|
||||
const dialogEl = document.createElement("div");
|
||||
const $dialog = $(dialogEl);
|
||||
|
||||
await openDialog($dialog, false);
|
||||
$dialog.trigger("hidden.bs.modal");
|
||||
|
||||
expect(showSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog);
|
||||
expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closer).toHaveBeenCalledTimes(1);
|
||||
expect(focusSavedElementSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
195
apps/client/src/services/autocomplete_core.ts
Normal file
195
apps/client/src/services/autocomplete_core.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
|
||||
|
||||
export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel";
|
||||
|
||||
type HeadlessSourceDefaults = Required<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
|
||||
|
||||
const headlessAutocompleteClosers = new Set<() => void>();
|
||||
|
||||
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
|
||||
source: TSource
|
||||
): TSource & HeadlessSourceDefaults {
|
||||
return {
|
||||
getItemUrl() {
|
||||
return undefined;
|
||||
},
|
||||
onActive() {
|
||||
// Headless consumers handle highlight side effects themselves.
|
||||
},
|
||||
onResolve() {
|
||||
// Headless consumers resolve and render items manually.
|
||||
},
|
||||
...source
|
||||
} as TSource & HeadlessSourceDefaults;
|
||||
}
|
||||
|
||||
export function registerHeadlessAutocompleteCloser(close: () => void) {
|
||||
headlessAutocompleteClosers.add(close);
|
||||
|
||||
return () => {
|
||||
headlessAutocompleteClosers.delete(close);
|
||||
};
|
||||
}
|
||||
|
||||
export function closeAllHeadlessAutocompletes() {
|
||||
for (const close of Array.from(headlessAutocompleteClosers)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
interface HeadlessPanelControllerOptions {
|
||||
inputEl: HTMLElement;
|
||||
container?: HTMLElement | null;
|
||||
className?: string;
|
||||
containedClassName?: string;
|
||||
}
|
||||
|
||||
export function createHeadlessPanelController({
|
||||
inputEl,
|
||||
container,
|
||||
className = "aa-core-panel",
|
||||
containedClassName = "aa-core-panel--contained"
|
||||
}: HeadlessPanelControllerOptions) {
|
||||
const panelEl = document.createElement("div");
|
||||
panelEl.className = className;
|
||||
|
||||
const isContained = Boolean(container);
|
||||
if (isContained) {
|
||||
panelEl.classList.add(containedClassName);
|
||||
container!.appendChild(panelEl);
|
||||
} else {
|
||||
document.body.appendChild(panelEl);
|
||||
}
|
||||
|
||||
panelEl.style.display = "none";
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const positionPanel = () => {
|
||||
if (isContained) {
|
||||
panelEl.style.position = "static";
|
||||
panelEl.style.top = "";
|
||||
panelEl.style.left = "";
|
||||
panelEl.style.width = "100%";
|
||||
panelEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
panelEl.style.position = "fixed";
|
||||
panelEl.style.top = `${rect.bottom}px`;
|
||||
panelEl.style.left = `${rect.left}px`;
|
||||
panelEl.style.width = `${rect.width}px`;
|
||||
panelEl.style.display = "block";
|
||||
};
|
||||
|
||||
const stopPositioning = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPositioning = () => {
|
||||
if (isContained) {
|
||||
positionPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rafId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
positionPanel();
|
||||
rafId = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
update();
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
hide();
|
||||
panelEl.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
panelEl,
|
||||
hide,
|
||||
destroy,
|
||||
startPositioning,
|
||||
stopPositioning
|
||||
};
|
||||
}
|
||||
|
||||
type InputHandlers<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
|
||||
|
||||
interface InputBinding<TEvent extends Event = Event> {
|
||||
type: string;
|
||||
listener: (event: TEvent) => void;
|
||||
}
|
||||
|
||||
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
|
||||
inputEl: HTMLInputElement;
|
||||
autocomplete: AutocompleteApi<TItem>;
|
||||
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
|
||||
extraBindings?: InputBinding[];
|
||||
}
|
||||
|
||||
export function bindAutocompleteInput<TItem extends BaseItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
extraBindings = []
|
||||
}: BindAutocompleteInputOptions<TItem>) {
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
|
||||
const bindings: InputBinding[] = [
|
||||
{
|
||||
type: "input",
|
||||
listener: (event: Event) => {
|
||||
onInput?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "focus",
|
||||
listener: (event: Event) => {
|
||||
onFocus?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "blur",
|
||||
listener: (event: Event) => {
|
||||
onBlur?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "keydown",
|
||||
listener: (event: Event) => {
|
||||
onKeyDown?.(event as KeyboardEvent, handlers);
|
||||
}
|
||||
},
|
||||
...extraBindings
|
||||
];
|
||||
|
||||
bindings.forEach(({ type, listener }) => {
|
||||
inputEl.addEventListener(type, listener as EventListener);
|
||||
});
|
||||
|
||||
return () => {
|
||||
bindings.forEach(({ type, listener }) => {
|
||||
inputEl.removeEventListener(type, listener as EventListener);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
await renderText(entity, $renderedContent, options);
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||
if (closeActDialog) {
|
||||
@@ -15,10 +17,7 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
|
||||
Modal.getOrCreateInstance($dialog[0], config).show();
|
||||
|
||||
$dialog.on("hidden.bs.modal", () => {
|
||||
const $autocompleteEl = $(".aa-input");
|
||||
if ("autocomplete" in $autocompleteEl) {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
closeAllHeadlessAutocompletes();
|
||||
|
||||
if (!glob.activeDialog || glob.activeDialog === $dialog) {
|
||||
focusSavedElement();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../entities/fnote";
|
||||
import { ViewTypeOptions } from "../widgets/collections/interface";
|
||||
|
||||
@@ -17,7 +18,8 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
render: null,
|
||||
search: null,
|
||||
text: null,
|
||||
webView: null
|
||||
webView: null,
|
||||
spreadsheet: null
|
||||
};
|
||||
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
@@ -38,6 +40,6 @@ export function getHelpUrlForNote(note: FNote | null | undefined) {
|
||||
} else if (note?.hasLabel("textSnippet")) {
|
||||
return "pwc194wlRzcH";
|
||||
} else if (note && note.type === "book") {
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import server from "./server.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type Component from "../components/component.js";
|
||||
import server from "./server.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
@@ -51,7 +52,10 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => {
|
||||
const ntxId = appContext.tabManager?.activeNtxId ?? null;
|
||||
appContext.triggerCommand(action.actionName, { ntxId });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import { t } from "./i18n.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import server from "./server.js";
|
||||
|
||||
export interface NoteTypeMapping {
|
||||
type: NoteType;
|
||||
@@ -26,6 +26,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
|
||||
// The default note type (always the first item)
|
||||
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
|
||||
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
|
||||
|
||||
// Text notes group
|
||||
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
|
||||
@@ -96,9 +97,9 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
|
||||
title: nt.title,
|
||||
command,
|
||||
type: nt.type,
|
||||
uiIcon: "bx " + nt.icon,
|
||||
uiIcon: `bx ${nt.icon}`,
|
||||
badges: []
|
||||
}
|
||||
};
|
||||
|
||||
if (nt.isNew) {
|
||||
menuItem.badges?.push(NEW_BADGE);
|
||||
@@ -130,7 +131,7 @@ async function getUserTemplates(command?: TreeCommandNames) {
|
||||
const item: MenuItem<TreeCommandNames> = {
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command: command,
|
||||
command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
};
|
||||
@@ -159,7 +160,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
|
||||
const items: MenuItem<TreeCommandNames>[] = [];
|
||||
if (title) {
|
||||
items.push({
|
||||
title: title,
|
||||
title,
|
||||
kind: "header"
|
||||
});
|
||||
} else {
|
||||
@@ -175,7 +176,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
|
||||
const item: MenuItem<TreeCommandNames> = {
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command: command,
|
||||
command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
};
|
||||
@@ -193,7 +194,7 @@ async function isNewTemplate(templateNoteId) {
|
||||
if (rootCreationDate === undefined) {
|
||||
// Retrieve the root note creation date
|
||||
try {
|
||||
let rootNoteInfo: any = await server.get("notes/root");
|
||||
const rootNoteInfo: any = await server.get("notes/root");
|
||||
if ("dateCreated" in rootNoteInfo) {
|
||||
rootCreationDate = new Date(rootNoteInfo.dateCreated);
|
||||
}
|
||||
@@ -208,7 +209,7 @@ async function isNewTemplate(templateNoteId) {
|
||||
if (creationDate === undefined) {
|
||||
// The creation date isn't available in the cache, try to retrieve it from the server
|
||||
try {
|
||||
const noteInfo: any = await server.get("notes/" + templateNoteId);
|
||||
const noteInfo: any = await server.get(`notes/${templateNoteId}`);
|
||||
if ("dateCreated" in noteInfo) {
|
||||
creationDate = new Date(noteInfo.dateCreated);
|
||||
creationDateCache.set(templateNoteId, creationDate);
|
||||
@@ -230,9 +231,8 @@ async function isNewTemplate(templateNoteId) {
|
||||
const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
|
||||
// Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
|
||||
return (age <= NEW_TEMPLATE_MAX_AGE);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -89,7 +89,7 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
@@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: "PUT",
|
||||
type: method,
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
|
||||
@@ -12,6 +12,7 @@ export default class SpacedUpdate {
|
||||
private updateInterval: number;
|
||||
private changeForbidden?: boolean;
|
||||
private stateCallback?: StateCallback;
|
||||
private lastState: SaveState = "saved";
|
||||
|
||||
constructor(updater: Callback, updateInterval = 1000, stateCallback?: StateCallback) {
|
||||
this.updater = updater;
|
||||
@@ -24,7 +25,7 @@ export default class SpacedUpdate {
|
||||
scheduleUpdate() {
|
||||
if (!this.changeForbidden) {
|
||||
this.changed = true;
|
||||
this.stateCallback?.("unsaved");
|
||||
this.onStateChanged("unsaved");
|
||||
setTimeout(() => this.triggerUpdate());
|
||||
}
|
||||
}
|
||||
@@ -34,12 +35,12 @@ export default class SpacedUpdate {
|
||||
this.changed = false; // optimistic...
|
||||
|
||||
try {
|
||||
this.stateCallback?.("saving");
|
||||
this.onStateChanged("saving");
|
||||
await this.updater();
|
||||
this.stateCallback?.("saved");
|
||||
this.onStateChanged("saved");
|
||||
} catch (e) {
|
||||
this.changed = true;
|
||||
this.stateCallback?.("error");
|
||||
this.onStateChanged("error");
|
||||
logError(getErrorMessage(e));
|
||||
throw e;
|
||||
}
|
||||
@@ -76,13 +77,13 @@ export default class SpacedUpdate {
|
||||
}
|
||||
|
||||
if (Date.now() - this.lastUpdated > this.updateInterval) {
|
||||
this.stateCallback?.("saving");
|
||||
this.onStateChanged("saving");
|
||||
try {
|
||||
await this.updater();
|
||||
this.stateCallback?.("saved");
|
||||
this.onStateChanged("saved");
|
||||
this.changed = false;
|
||||
} catch (e) {
|
||||
this.stateCallback?.("error");
|
||||
this.onStateChanged("error");
|
||||
logError(getErrorMessage(e));
|
||||
}
|
||||
this.lastUpdated = Date.now();
|
||||
@@ -92,6 +93,13 @@ export default class SpacedUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
onStateChanged(state: SaveState) {
|
||||
if (state === this.lastState) return;
|
||||
|
||||
this.stateCallback?.(state);
|
||||
this.lastState = state;
|
||||
}
|
||||
|
||||
async allowUpdateWithoutChange(callback: Callback) {
|
||||
this.changeForbidden = true;
|
||||
|
||||
|
||||
@@ -892,33 +892,6 @@ table.promoted-attributes-in-tooltip th {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.algolia-autocomplete {
|
||||
width: calc(100% - 30px);
|
||||
z-index: 2000 !important;
|
||||
}
|
||||
|
||||
.algolia-autocomplete-container .aa-dropdown-menu {
|
||||
position: inherit !important;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.algolia-autocomplete .aa-input,
|
||||
.algolia-autocomplete .aa-hint {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.algolia-autocomplete .aa-dropdown-menu {
|
||||
width: 100%;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
z-index: 2000 !important;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion {
|
||||
cursor: pointer;
|
||||
padding: 6px 16px;
|
||||
@@ -960,6 +933,153 @@ table.promoted-attributes-in-tooltip th {
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
|
||||
|
||||
.aa-core-panel {
|
||||
z-index: 10000;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.aa-core-panel.aa-dropdown-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-panel--contained {
|
||||
position: static !important;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.aa-core-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-core-item {
|
||||
cursor: pointer;
|
||||
padding: 7px 16px;
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.aa-core-item--active {
|
||||
color: var(--active-item-text-color);
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
.aa-core-item .note-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-item .icon,
|
||||
.aa-core-item .command-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.aa-core-item .text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-primary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
font-size: 1.02em;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.65;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
padding-inline-start: 14px;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-shortcut,
|
||||
.aa-core-item kbd.command-shortcut {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-text-color);
|
||||
font-family: inherit !important;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aa-core-item .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.aa-core-item .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aa-core-item .command-name {
|
||||
font-weight: bold;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.aa-core-item .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title b,
|
||||
.aa-core-item .search-result-path b,
|
||||
.aa-core-item .search-result-attributes b,
|
||||
.aa-core-item .command-name b,
|
||||
.aa-core-item .command-description b {
|
||||
color: var(--admonition-warning-accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-separator {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-core-panel--contained {
|
||||
max-height: calc(80vh - 200px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
float: inline-end;
|
||||
background: none;
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* The search results list */
|
||||
.note-detail-empty span.aa-dropdown-menu {
|
||||
/* The headless autocomplete panel rendered into the empty-note results container */
|
||||
.note-detail-empty .aa-core-panel--contained {
|
||||
margin-top: 1em;
|
||||
border: unset;
|
||||
}
|
||||
|
||||
@@ -1535,7 +1535,8 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天"
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
|
||||
@@ -1488,20 +1488,21 @@
|
||||
"mermaid-diagram": "Mermaid Diagramm",
|
||||
"canvas": "Leinwand",
|
||||
"web-view": "Webansicht",
|
||||
"mind-map": "Mind Map",
|
||||
"mind-map": "Mindmap",
|
||||
"file": "Datei",
|
||||
"image": "Bild",
|
||||
"launcher": "Starter",
|
||||
"doc": "Dokument",
|
||||
"widget": "Widget",
|
||||
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"geo-map": "Geo-Karte",
|
||||
"beta-feature": "Beta",
|
||||
"book": "Sammlung",
|
||||
"ai-chat": "KI Chat",
|
||||
"ai-chat": "KI-Chat",
|
||||
"task-list": "Aufgabenliste",
|
||||
"new-feature": "Neu",
|
||||
"collections": "Sammlungen"
|
||||
"collections": "Sammlungen",
|
||||
"spreadsheet": "Tabelle"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Notiz schützen",
|
||||
|
||||
@@ -1582,7 +1582,8 @@
|
||||
"ai-chat": "AI Chat",
|
||||
"task-list": "Task List",
|
||||
"new-feature": "New",
|
||||
"collections": "Collections"
|
||||
"collections": "Collections",
|
||||
"spreadsheet": "Spreadsheet"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Protect the note",
|
||||
|
||||
@@ -1548,7 +1548,8 @@
|
||||
"task-list": "Lista de tareas",
|
||||
"book": "Colección",
|
||||
"new-feature": "Nuevo",
|
||||
"collections": "Colecciones"
|
||||
"collections": "Colecciones",
|
||||
"spreadsheet": "Hoja de cálculo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteger la nota",
|
||||
@@ -1650,7 +1651,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
|
||||
"search_now": "Buscar ahora"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurar barra de lanzamiento"
|
||||
|
||||
@@ -1571,7 +1571,8 @@
|
||||
"ai-chat": "Comhrá AI",
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin"
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"add_link": {
|
||||
"note": "नोट",
|
||||
"add_link": "लिंक जोड़ें",
|
||||
"add_link": "लिंक ऐड करें",
|
||||
"help_on_links": "लिंक्स पर मदद।",
|
||||
"search_note": "नोट को नाम से खोजें",
|
||||
"link_title_mirrors": "लिंक टाइटल नोट के करंट टाइटल के हिसाब से बदलता है",
|
||||
@@ -112,7 +112,7 @@
|
||||
"help_on_tree_prefix": "ट्री प्रीफ़िक्स पर मदद",
|
||||
"prefix": "प्रीफ़िक्स: ",
|
||||
"save": "सेव करें",
|
||||
"branch_prefix_saved": "ब्रांच प्रीफ़िक्स सेव कर दिया गया है।",
|
||||
"branch_prefix_saved": "ब्रांच प्रीफ़िक्स सेव हो चुका है।",
|
||||
"branch_prefix_saved_multiple": "{{count}} ब्रांचेस के लिए ब्रांच प्रीफ़िक्स सेव कर दिया गया है।",
|
||||
"affected_branches": "प्रभावित ब्रांचेस ({{count}}):"
|
||||
},
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -1717,7 +1717,8 @@
|
||||
"task-list": "Elenco delle attività",
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA"
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
|
||||
@@ -600,7 +600,8 @@
|
||||
"task-list": "タスクリスト",
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット"
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
"collapseExpand": "свернуть/развернуть узел",
|
||||
"notSet": "не установлено",
|
||||
"goBackForwards": "назад / вперед в истории",
|
||||
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
|
||||
"showJumpToNoteDialog": "Перейти к <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Перейти к\" окно</a>",
|
||||
"scrollToActiveNote": "прокрутка к активной заметке",
|
||||
"jumpToParentNote": "переход к родительской заметке",
|
||||
"collapseWholeTree": "свернуть все дерево заметок",
|
||||
@@ -471,7 +471,7 @@
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -594,7 +594,8 @@
|
||||
"display-week-numbers": "Отображать номера недель",
|
||||
"hide-weekends": "Скрыть выходные",
|
||||
"raster": "Растр",
|
||||
"show-scale": "Показать масштаб"
|
||||
"show-scale": "Показать масштаб",
|
||||
"show-labels": "Показать названия маркеров"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"note_completion_enabled": "Включить автодополнение",
|
||||
@@ -782,7 +783,13 @@
|
||||
"shared-indicator-tooltip": "Эта заметка опубликована",
|
||||
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
|
||||
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
|
||||
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
|
||||
"subtree-hidden-moved-title": "Добавлено в {{title}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
|
||||
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
|
||||
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
|
||||
},
|
||||
"quick-search": {
|
||||
"no-results": "Результаты не найдены",
|
||||
@@ -826,7 +833,9 @@
|
||||
"mind-map": "Mind Map",
|
||||
"geo-map": "Географическая карта",
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1153,7 +1162,8 @@
|
||||
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
|
||||
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
|
||||
"actions_executed": "Действия выполнены.",
|
||||
"view_options": "Просмотреть опции:"
|
||||
"view_options": "Просмотреть опции:",
|
||||
"option": "опция"
|
||||
},
|
||||
"ancestor": {
|
||||
"depth_label": "глубина",
|
||||
@@ -1403,7 +1413,8 @@
|
||||
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
|
||||
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
|
||||
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
|
||||
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
|
||||
},
|
||||
"sync_2": {
|
||||
"timeout_unit": "миллисекунд",
|
||||
@@ -1713,7 +1724,8 @@
|
||||
"delete_this_note": "Удалить эту заметку",
|
||||
"insert_child_note": "Вставить дочернюю заметку",
|
||||
"note_revisions": "История изменений",
|
||||
"content_language_switcher": "Язык содержимого: {{language}}"
|
||||
"content_language_switcher": "Язык содержимого: {{language}}",
|
||||
"backlinks": "Ссылки"
|
||||
},
|
||||
"svg_export_button": {
|
||||
"button_title": "Экспортировать диаграмму как SVG"
|
||||
@@ -1790,7 +1802,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
|
||||
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
|
||||
"search_not_executed": "Поиск ещё не выполнен.",
|
||||
"search_now": "Искать сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"search_placeholder": "поиск заметки по ее названию",
|
||||
@@ -1988,10 +2001,12 @@
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
|
||||
"print_report_error_title": "Не удалось напечатать",
|
||||
"print_report_stack_trace": "Трассировка стека"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
|
||||
"drag_locked_title": "Защищено от изменения",
|
||||
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
|
||||
},
|
||||
@@ -2007,7 +2022,9 @@
|
||||
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} заметок"
|
||||
"total_notes": "{{count}} заметок",
|
||||
"prev_page": "Предыдущая страница",
|
||||
"next_page": "Следующая страница"
|
||||
},
|
||||
"status_bar": {
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
@@ -2137,5 +2154,49 @@
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Доступно для {{platform}}"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
|
||||
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
|
||||
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
|
||||
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
|
||||
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
|
||||
"disabled_button_enable": "Включить заметки для рендера"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
|
||||
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
|
||||
"create_button": "Создать веб-просмотр",
|
||||
"invalid_url_title": "Неверный адрес",
|
||||
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
|
||||
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
|
||||
"disabled_button_enable": "Включить просмотр веб-страниц"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Набор иконок",
|
||||
"type_backend_script": "Бэкенд скрипт",
|
||||
"type_frontend_script": "Фронтенд скрипт",
|
||||
"type_widget": "Виджет",
|
||||
"type_app_css": "Пользовательский CSS",
|
||||
"type_render_note": "Заметка для рендера",
|
||||
"type_web_view": "Просмотр веб-страницы",
|
||||
"type_app_theme": "Пользовательская тема",
|
||||
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
|
||||
"menu_docs": "Открытая документация",
|
||||
"menu_execute_now": "Выполнить скрипт сейчас",
|
||||
"menu_run": "Выполнять автоматически",
|
||||
"menu_run_disabled": "Вручную",
|
||||
"menu_run_backend_startup": "При запуске бэкенда",
|
||||
"menu_run_hourly": "Ежечасно",
|
||||
"menu_run_daily": "Ежедневно",
|
||||
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
|
||||
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
|
||||
"menu_change_to_widget": "Изменить виджет",
|
||||
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
|
||||
"menu_theme_base": "Базовая тема"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Узнать больше"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
"title": "Om Trilium Notes",
|
||||
"homepage": "Hemsida:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:"
|
||||
"db_version": "DB version:",
|
||||
"sync_version": "Sync version:",
|
||||
"build_date": "Bygg datum:",
|
||||
"build_revision": "Bygg version:",
|
||||
"data_directory": "Data sökväg:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritiskt fel",
|
||||
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Misslyckades att starta widget",
|
||||
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
|
||||
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Misslyckades att starta ett anpassat skript",
|
||||
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Misslyckades att hämta widget-listan från servern"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Misslyckades att renderera en anpassad React-widget"
|
||||
},
|
||||
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
|
||||
"open-script-note": "Öppna skriptanteckning",
|
||||
"scripting-error": "Fel i anpassat skript: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Infoga länk",
|
||||
"help_on_links": "Hjälp om länkar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,15 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Özel bir betik yüklenemedi",
|
||||
"message": "ID'si \"{{id}}\" ve başlığı \"{{title}}\" olan nottan alınan komut dosyası şunun nedeniyle yürütülemedi:\n\n{{message}}"
|
||||
}
|
||||
"message": "Komut şu nedenle yürütülemedi:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Sunucudan widget listesi alınamadı"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Özel React widget'ı çizilirken sorun yaşandı"
|
||||
},
|
||||
"scripting-error": "Kullanıcı tanımlı betik hatası: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Bağlantı ekle",
|
||||
|
||||
@@ -1495,7 +1495,9 @@
|
||||
"beta-feature": "Beta",
|
||||
"task-list": "任務列表",
|
||||
"new-feature": "新增",
|
||||
"collections": "集合"
|
||||
"collections": "集合",
|
||||
"ai-chat": "AI 聊天",
|
||||
"spreadsheet": "試算表"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保護筆記",
|
||||
@@ -1594,7 +1596,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "沒有找到符合搜尋條件的筆記。",
|
||||
"search_not_executed": "尚未執行搜尋。請點擊上方的「搜尋」按鈕查看結果。"
|
||||
"search_not_executed": "尚未執行搜尋。",
|
||||
"search_now": "立即搜尋"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "設定啟動欄"
|
||||
@@ -2011,7 +2014,9 @@
|
||||
"app-restart-required": "(需要重啟程式以套用更改)"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} 筆記"
|
||||
"total_notes": "{{count}} 筆記",
|
||||
"prev_page": "上一頁",
|
||||
"next_page": "下一頁"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "發現錯誤,無法顯示內容。"
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
"show_help": "Показати Довідку",
|
||||
"logout": "Вийти",
|
||||
"show-cheatsheet": "Показати Шпаргалку",
|
||||
"toggle-zen-mode": "Дзен-режим"
|
||||
"toggle-zen-mode": "Дзен-режим",
|
||||
"new-version-available": "Доступне оновлення",
|
||||
"download-update": "Отримати версію {{latest Version}}",
|
||||
"search_notes": "Пошук нотаток"
|
||||
},
|
||||
"modal": {
|
||||
"help_title": "Показати більше інформації про це вікно",
|
||||
@@ -293,7 +296,8 @@
|
||||
},
|
||||
"import-status": "Статус Імпорту",
|
||||
"in-progress": "Триває Імпорт: {{progress}}",
|
||||
"successful": "Імпорт успішно завершено."
|
||||
"successful": "Імпорт успішно завершено.",
|
||||
"importZipRecommendation": "Під час імпорту ZIP-файлу ієрархія нотаток відображатиме структуру підкаталогів в архіві."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Запит(prompt)",
|
||||
@@ -355,7 +359,8 @@
|
||||
"info": {
|
||||
"modalTitle": "Інформаційне повідомлення",
|
||||
"closeButton": "Закрити",
|
||||
"okButton": "ОК"
|
||||
"okButton": "ОК",
|
||||
"copy_to_clipboard": "Копіювати в буфер обміну"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Пошук нотатки за її назвою або типом > для команд...",
|
||||
@@ -805,7 +810,14 @@
|
||||
"convert_into_attachment_failed": "Не вдалося конвертувати нотатку '{{title}}'.",
|
||||
"convert_into_attachment_successful": "Нотатку '{{title}}' перетворено на вкладення.",
|
||||
"convert_into_attachment_prompt": "Ви впевнені, що хочете перетворити нотатку '{{title}}' на вкладення батьківської нотатки?",
|
||||
"print_pdf": "Експортувати як PDF..."
|
||||
"print_pdf": "Експортувати як PDF...",
|
||||
"open_note_on_server": "Відкрити нотатку на сервері",
|
||||
"view_revisions": "Ревізії нотатки...",
|
||||
"advanced": "Розширені",
|
||||
"export_as_image": "Експортувати як зображення",
|
||||
"export_as_image_png": "PNG (растровий)",
|
||||
"export_as_image_svg": "SVG (векторний)",
|
||||
"note_map": "Карта нотатки"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Віджет кнопки '{{componentId}}' не має визначеного обробника кліків"
|
||||
@@ -858,7 +870,10 @@
|
||||
"insert_child_note": "Вставити дочірню нотатку",
|
||||
"delete_this_note": "Видалити цю нотатку",
|
||||
"error_cannot_get_branch_id": "Не вдається отримати branchId для notePath '{{notePath}}'",
|
||||
"error_unrecognized_command": "Нерозпізнана команда {{command}}"
|
||||
"error_unrecognized_command": "Нерозпізнана команда {{command}}",
|
||||
"note_revisions": "Ревізії нотатки",
|
||||
"backlinks": "Зворотні посилання",
|
||||
"content_language_switcher": "Мова контенту: {{language}}"
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Змінити значок нотатки",
|
||||
@@ -866,7 +881,13 @@
|
||||
"reset-default": "Скинути значок до стандартного значення",
|
||||
"search_placeholder_one": "Пошук {{number}} значка у {{count}} пакеті",
|
||||
"search_placeholder_few": "Пошук {{number}} значків у {{count}} пакетах",
|
||||
"search_placeholder_many": "Пошук {{number}} значків у {{count}} пакетах"
|
||||
"search_placeholder_many": "Пошук {{number}} значків у {{count}} пакетах",
|
||||
"search_placeholder_filtered": "Пошук {{number}} іконок у {{name}}",
|
||||
"filter": "Фільтр",
|
||||
"filter-none": "Всі іконки",
|
||||
"filter-default": "Іконки за замовчуванням",
|
||||
"icon_tooltip": "{{name}}\nПакет іконок: {{iconPack}}",
|
||||
"no_results": "Іконки не знайдено"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Тип нотатки",
|
||||
@@ -888,7 +909,13 @@
|
||||
"table": "Таблиця",
|
||||
"geo-map": "Географічна карта",
|
||||
"board": "Дошка",
|
||||
"include_archived_notes": "Показати архівовані нотатки"
|
||||
"include_archived_notes": "Показати архівовані нотатки",
|
||||
"expand_tooltip": "Розгортає безпосередні дочірні елементи цієї колекції (на один рівень у глибину). Щоб переглянути більше параметрів, натисніть стрілку праворуч.",
|
||||
"expand_first_level": "Розгорнути прямі дочірні елементи",
|
||||
"expand_nth_level": "Розгорнути {{depth}} рівнів",
|
||||
"expand_all_levels": "Розгорнути всі рівні",
|
||||
"presentation": "Презентація",
|
||||
"hide_child_notes": "Приховати дочірні нотатки в дереві"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "Цього дня ще немає редагованих нотаток...",
|
||||
@@ -921,7 +948,8 @@
|
||||
},
|
||||
"inherited_attribute_list": {
|
||||
"title": "Успадковані Атрибути",
|
||||
"no_inherited_attributes": "Немає успадкованих атрибутів."
|
||||
"no_inherited_attributes": "Немає успадкованих атрибутів.",
|
||||
"none": "пусто"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"note_id": "ID Нотатки",
|
||||
@@ -932,7 +960,9 @@
|
||||
"note_size_info": "Розмір нотатки надає приблизну оцінку вимог до зберігання для цієї нотатки. Він враховує вміст нотатки та вміст її версій.",
|
||||
"calculate": "обчислити",
|
||||
"subtree_size": "(розмір піддерева: {{size}} у {{count}} нотатках)",
|
||||
"title": "Інформація про нотатку"
|
||||
"title": "Інформація про нотатку",
|
||||
"mime": "Тип MIME",
|
||||
"show_similar_notes": "Показати схожі нотатки"
|
||||
},
|
||||
"note_map": {
|
||||
"open_full": "Розгорнути на повний розмір",
|
||||
@@ -995,7 +1025,9 @@
|
||||
"search_parameters": "Параметри пошуку",
|
||||
"unknown_search_option": "Невідомий параметр пошуку {{searchOptionName}}",
|
||||
"actions_executed": "Дії виконано.",
|
||||
"search_note_saved": "Нотатка з пошуку збережена у {{- notePathTitle}}"
|
||||
"search_note_saved": "Нотатка з пошуку збережена у {{- notePathTitle}}",
|
||||
"option": "опції",
|
||||
"view_options": "Опції перегляду:"
|
||||
},
|
||||
"similar_notes": {
|
||||
"title": "Схожі нотатки",
|
||||
@@ -1089,7 +1121,13 @@
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Введіть тут вміст вашої нотатки...",
|
||||
"auto-detect-language": "Автовизначено"
|
||||
"auto-detect-language": "Автовизначено",
|
||||
"editor_crashed_title": "Збій текстового редактора",
|
||||
"editor_crashed_content": "Ваш контент успішно відновлено, але деякі з ваших останніх змін могли бути не збережені.",
|
||||
"editor_crashed_details_button": "Переглянути більше деталей...",
|
||||
"editor_crashed_details_intro": "Якщо ви стикаєтеся з цією помилкою кілька разів, подумайте про те, щоб повідомити про неї на GitHub, вставивши наведену нижче інформацію.",
|
||||
"editor_crashed_details_title": "Технічна інформація",
|
||||
"keeps-crashing": "Компонент редагування постійно аварійно завершує роботу. Спробуйте перезапустити Trilium. Якщо проблема не зникає, спробуйте створити звіт про помилку."
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Відкрийте нотатку, ввівши її заголовок в поле нижче, або виберіть нотатку в дереві.",
|
||||
@@ -1955,5 +1993,11 @@
|
||||
"pages_one": "{{count}} сторінка",
|
||||
"pages_few": "{{count}} сторінки",
|
||||
"pages_many": "{{count}} сторінок"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Відображати власний HTML або Preact JSX у цій нотатці",
|
||||
"setup_create_sample_preact": "Створіть зразок нотатки за допомогою Preact",
|
||||
"setup_create_sample_html": "Створити зразок нотатки за допомогою HTML",
|
||||
"setup_sample_created": "Зразок нотатки було створено як дочірню нотатку."
|
||||
}
|
||||
}
|
||||
|
||||
28
apps/client/src/types.d.ts
vendored
28
apps/client/src/types.d.ts
vendored
@@ -6,7 +6,6 @@ import type { PrintReport } from "./print";
|
||||
import type { lint } from "./services/eslint";
|
||||
import type { Froca } from "./services/froca-interface";
|
||||
import { Library } from "./services/library_loader";
|
||||
import { Suggestion } from "./services/note_autocomplete";
|
||||
import server from "./services/server";
|
||||
import utils from "./services/utils";
|
||||
|
||||
@@ -83,34 +82,7 @@ declare global {
|
||||
"note-load-progress": CustomEvent<{ progress: number }>;
|
||||
}
|
||||
|
||||
interface AutoCompleteConfig {
|
||||
appendTo?: HTMLElement | null;
|
||||
hint?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
minLength?: number;
|
||||
tabAutocomplete?: boolean;
|
||||
autoselect?: boolean;
|
||||
dropdownMenuContainer?: HTMLElement;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
|
||||
|
||||
interface AutoCompleteArg {
|
||||
name?: string;
|
||||
value?: string;
|
||||
notePathTitle?: string;
|
||||
displayKey?: "name" | "value" | "notePathTitle";
|
||||
cache?: boolean;
|
||||
source?: (term: string, cb: AutoCompleteCallback) => void,
|
||||
templates?: {
|
||||
suggestion: (suggestion: Suggestion) => string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface JQuery {
|
||||
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
|
||||
|
||||
getSelectedNotePath(): string | undefined;
|
||||
getSelectedNoteId(): string | null;
|
||||
setSelectedNotePath(notePath: string | null | undefined);
|
||||
|
||||
@@ -40,6 +40,19 @@ export default function NoteDetail() {
|
||||
const widgetRequestId = useRef(0);
|
||||
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
|
||||
|
||||
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
|
||||
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => noteContext?.isActive() ?? false);
|
||||
useEffect(() => {
|
||||
if (!hasTabBeenActive && noteContext?.isActive()) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
}, [ noteContext, hasTabBeenActive ]);
|
||||
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId === ntxId && !hasTabBeenActive) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
});
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
@@ -49,7 +62,7 @@ export default function NoteDetail() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!type) return;
|
||||
if (!type || !hasTabBeenActive) return;
|
||||
const requestId = ++widgetRequestId.current;
|
||||
|
||||
if (!noteTypesToRender[type]) {
|
||||
@@ -68,7 +81,7 @@ export default function NoteDetail() {
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type, noteTypesToRender ]);
|
||||
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
@@ -247,9 +260,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setCachedProps(props);
|
||||
} else {
|
||||
// Do nothing, keep the old props.
|
||||
}
|
||||
// When not visible, keep the old props to avoid re-rendering in the background.
|
||||
}, [ props, isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
@@ -260,7 +272,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
height: isFullHeight ? "100%" : ""
|
||||
}}
|
||||
>
|
||||
{ <Element {...cachedProps} /> }
|
||||
<Element {...cachedProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
|
||||
import NoteContext from "../components/note_context";
|
||||
import FAttribute from "../entities/fattribute";
|
||||
import FNote from "../entities/fnote";
|
||||
import attributeAutocompleteService from "../services/attribute_autocomplete";
|
||||
import { Attribute } from "../services/attribute_parser";
|
||||
import attributes from "../services/attributes";
|
||||
import { t } from "../services/i18n";
|
||||
@@ -36,8 +37,7 @@ interface CellProps {
|
||||
setCellToFocus(cell: Cell): void;
|
||||
}
|
||||
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent;
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
const { note, componentId, noteContext } = useNoteContext();
|
||||
@@ -200,11 +200,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
}, [ cell, componentId, note, setCells ]);
|
||||
const extraInputProps: InputHTMLAttributes = {};
|
||||
|
||||
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
|
||||
if (e.currentTarget instanceof HTMLInputElement) {
|
||||
setDraft(e.currentTarget.value);
|
||||
}
|
||||
});
|
||||
useTextLabelAutocomplete(inputId, valueAttr, definition, setDraft);
|
||||
|
||||
// React to model changes.
|
||||
useEffect(() => {
|
||||
@@ -258,7 +254,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
className="open-external-link-button"
|
||||
icon="bx bx-window-open"
|
||||
title={t("promoted_attributes.open_external_link")}
|
||||
onClick={(e) => {
|
||||
onClick={() => {
|
||||
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
||||
const url = inputEl?.value;
|
||||
if (url) {
|
||||
@@ -413,55 +409,31 @@ function InputButton({ icon, className, title, onClick }: {
|
||||
);
|
||||
}
|
||||
|
||||
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
|
||||
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
|
||||
|
||||
// Obtain data.
|
||||
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
|
||||
useEffect(() => {
|
||||
if (definition.labelType !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => {
|
||||
setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute })));
|
||||
});
|
||||
}, [ definition.labelType, valueAttr.name ]);
|
||||
|
||||
// Initialize autocomplete.
|
||||
useEffect(() => {
|
||||
if (attributeValues?.length === 0) return;
|
||||
const el = document.getElementById(inputId) as HTMLInputElement | null;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $input = $(el);
|
||||
$input.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
autoselect: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
source (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: $input,
|
||||
open: false,
|
||||
nameCallback: () => valueAttr.name,
|
||||
onValueChange: (value) => {
|
||||
onValueChange(value);
|
||||
}
|
||||
});
|
||||
|
||||
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$input.off("autocomplete:selected");
|
||||
$input.on("autocomplete:selected", onChangeListener);
|
||||
|
||||
return () => $input.autocomplete("destroy");
|
||||
}, [ inputId, attributeValues, onChangeListener ]);
|
||||
return () => {
|
||||
attributeAutocompleteService.destroyAutocomplete($input);
|
||||
};
|
||||
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
|
||||
}
|
||||
|
||||
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attr-detail tn-tool-dialog">
|
||||
@@ -372,13 +373,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputName.on("change", () => this.userEditedAttribute());
|
||||
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
|
||||
this.$inputName.on("focus", () => {
|
||||
attributeAutocompleteService.initAttributeNameAutocomplete({
|
||||
$el: this.$inputName,
|
||||
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
||||
open: true
|
||||
open: true,
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputValue.on("change", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("focus", () => {
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: this.$inputValue,
|
||||
open: true,
|
||||
nameCallback: () => String(this.$inputName.val())
|
||||
nameCallback: () => String(this.$inputName.val()),
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -477,7 +478,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
|
||||
|
||||
$(window).on("mousedown", (e) => {
|
||||
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
|
||||
if (!$(e.target).closest(this.$widget[0]).length
|
||||
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
|
||||
&& !$(e.target).closest("#context-menu-container").length) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
overflow: visible;
|
||||
contain: none !important;
|
||||
clear: both;
|
||||
|
||||
&.full-height {
|
||||
overflow: auto;
|
||||
|
||||
@@ -54,6 +54,8 @@ export default function PopupEditor() {
|
||||
}
|
||||
});
|
||||
|
||||
// Events triggered at note context level (e.g. the save indicator) would not work since the note context has no parent component. Propagate events to parent component so that they can be handled properly.
|
||||
noteContext.triggerEvent = (name, data) => parentComponent?.handleEventInChildren(name, data);
|
||||
setNoteContext(noteContext);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import $ from "jquery";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
triliumEventHandlers,
|
||||
latestModalPropsRef,
|
||||
latestNoteAutocompletePropsRef,
|
||||
addLinkSpy,
|
||||
logErrorSpy,
|
||||
showRecentNotesSpy,
|
||||
setTextSpy
|
||||
} = vi.hoisted(() => ({
|
||||
triliumEventHandlers: new Map<string, (payload: any) => void>(),
|
||||
latestModalPropsRef: { current: null as any },
|
||||
latestNoteAutocompletePropsRef: { current: null as any },
|
||||
addLinkSpy: vi.fn(() => Promise.resolve()),
|
||||
logErrorSpy: vi.fn(),
|
||||
showRecentNotesSpy: vi.fn(),
|
||||
setTextSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.mock("../../services/tree", () => ({
|
||||
default: {
|
||||
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
|
||||
getNoteTitle: vi.fn(async () => "Target note")
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../../services/ws", () => ({
|
||||
logError: logErrorSpy
|
||||
}));
|
||||
|
||||
vi.mock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
showRecentNotes: showRecentNotesSpy,
|
||||
setText: setTextSpy
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/react_utils", () => ({
|
||||
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
|
||||
}));
|
||||
|
||||
vi.mock("../react/hooks", () => ({
|
||||
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
|
||||
triliumEventHandlers.set(name, handler);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/Modal", () => ({
|
||||
default: (props: any) => {
|
||||
latestModalPropsRef.current = props;
|
||||
|
||||
if (!props.show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit?.();
|
||||
}}>
|
||||
{props.children}
|
||||
{props.footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormGroup", () => ({
|
||||
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
vi.mock("../react/Button", () => ({
|
||||
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormRadioGroup", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
vi.mock("../react/NoteAutocomplete", () => ({
|
||||
default: (props: any) => {
|
||||
latestNoteAutocompletePropsRef.current = props;
|
||||
return <input ref={props.inputRef} />;
|
||||
}
|
||||
}));
|
||||
|
||||
import AddLinkDialog from "./add_link";
|
||||
|
||||
describe("AddLinkDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestModalPropsRef.current = null;
|
||||
latestNoteAutocompletePropsRef.current = null;
|
||||
triliumEventHandlers.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("submits the selected note when Enter picks an autocomplete suggestion", async () => {
|
||||
act(() => {
|
||||
render(<AddLinkDialog />, container);
|
||||
});
|
||||
|
||||
const showDialog = triliumEventHandlers.get("showAddLinkDialog");
|
||||
expect(showDialog).toBeTypeOf("function");
|
||||
|
||||
await act(async () => {
|
||||
showDialog?.({
|
||||
text: "",
|
||||
hasSelection: false,
|
||||
addLink: addLinkSpy
|
||||
});
|
||||
});
|
||||
|
||||
const suggestion = {
|
||||
notePath: "root/target-note",
|
||||
noteTitle: "Target note"
|
||||
};
|
||||
|
||||
act(() => {
|
||||
latestNoteAutocompletePropsRef.current.onKeyDownCapture({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
isComposing: false
|
||||
});
|
||||
latestNoteAutocompletePropsRef.current.onChange(suggestion);
|
||||
});
|
||||
|
||||
expect(latestModalPropsRef.current.show).toBe(false);
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
latestModalPropsRef.current.onHidden();
|
||||
});
|
||||
|
||||
expect(addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { JSX } from "preact";
|
||||
import { useEffect,useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import tree from "../../services/tree";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import { logError } from "../../services/ws";
|
||||
import Button from "../react/Button";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||
|
||||
@@ -26,6 +28,8 @@ export default function AddLinkDialog() {
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const suggestionRef = useRef<Suggestion | null>(null);
|
||||
const submitOnSelectionRef = useRef(false);
|
||||
|
||||
useTriliumEvent("showAddLinkDialog", opts => {
|
||||
setOpts(opts);
|
||||
@@ -85,15 +89,44 @@ export default function AddLinkDialog() {
|
||||
.trigger("select");
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
hasSubmittedRef.current = true;
|
||||
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
|
||||
submitOnSelectionRef.current = false;
|
||||
hasSubmittedRef.current = Boolean(selectedSuggestion);
|
||||
|
||||
if (suggestion) {
|
||||
// Insertion logic in onHidden because it needs focus.
|
||||
setShown(false);
|
||||
} else {
|
||||
if (!selectedSuggestion) {
|
||||
logError("No link to add.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Insertion logic in onHidden because it needs focus.
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
function onSuggestionChange(nextSuggestion: Suggestion | null) {
|
||||
suggestionRef.current = nextSuggestion;
|
||||
setSuggestion(nextSuggestion);
|
||||
|
||||
if (submitOnSelectionRef.current && nextSuggestion) {
|
||||
submitSelectedLink(nextSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitOnSelectionRef.current = true;
|
||||
}
|
||||
|
||||
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
submitOnSelectionRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
submitSelectedLink(suggestionRef.current);
|
||||
}
|
||||
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
@@ -109,19 +142,22 @@ export default function AddLinkDialog() {
|
||||
onSubmit={onSubmit}
|
||||
onShown={onShown}
|
||||
onHidden={() => {
|
||||
submitOnSelectionRef.current = false;
|
||||
|
||||
// Insert the link.
|
||||
if (hasSubmittedRef.current && suggestion && opts) {
|
||||
if (hasSubmittedRef.current && suggestionRef.current && opts) {
|
||||
hasSubmittedRef.current = false;
|
||||
|
||||
if (suggestion.notePath) {
|
||||
if (suggestionRef.current.notePath) {
|
||||
// Handle note link
|
||||
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestion.externalLink) {
|
||||
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestionRef.current.externalLink) {
|
||||
// Handle external link
|
||||
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
|
||||
}
|
||||
}
|
||||
|
||||
suggestionRef.current = null;
|
||||
setSuggestion(null);
|
||||
setShown(false);
|
||||
}}
|
||||
@@ -130,7 +166,9 @@ export default function AddLinkDialog() {
|
||||
<FormGroup label={t("add_link.note")} name="note">
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={setSuggestion}
|
||||
onChange={onSuggestionChange}
|
||||
onKeyDownCapture={onAutocompleteKeyDownCapture}
|
||||
onKeyUpCapture={onAutocompleteKeyUpCapture}
|
||||
opts={{
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import commandRegistry from "../../services/command_registry";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import shortcutService from "../../services/shortcuts";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
@@ -23,14 +24,14 @@ export default function JumpToNoteDialogComponent() {
|
||||
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
|
||||
const actualText = useRef<string>(initialText);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
let newMode: Mode;
|
||||
let initialText = "";
|
||||
|
||||
if (commandMode) {
|
||||
newMode = "commands";
|
||||
initialText = ">";
|
||||
initialText = ">";
|
||||
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
@@ -58,7 +59,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setShown(false);
|
||||
if (suggestion.notePath) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
@@ -83,7 +84,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
$autoComplete
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
|
||||
|
||||
// Add keyboard shortcut for full search
|
||||
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
|
||||
if (!isCommandMode) {
|
||||
@@ -91,7 +92,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function showInFullSearch() {
|
||||
try {
|
||||
setShown(false);
|
||||
@@ -126,18 +127,18 @@ export default function JumpToNoteDialogComponent() {
|
||||
setIsCommandMode(text.startsWith(">"));
|
||||
}}
|
||||
onChange={onItemSelected}
|
||||
/>}
|
||||
/>}
|
||||
onShown={onShown}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={!isCommandMode && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
footer={!isCommandMode && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
keyboardShortcut="Ctrl+Enter"
|
||||
onClick={showInFullSearch}
|
||||
/>}
|
||||
show={shown}
|
||||
>
|
||||
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
|
||||
<div className="jump-to-note-results" ref={containerRef} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
// JQuery here is maintained for compatibility with existing code.
|
||||
interface ShownCallbackData {
|
||||
@@ -40,7 +41,7 @@ export default function PromptDialog() {
|
||||
opts.current = newOpts;
|
||||
setValue(newOpts.defaultValue ?? "");
|
||||
setShown(true);
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -60,7 +61,7 @@ export default function PromptDialog() {
|
||||
answerRef.current?.select();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
submitValue.current = value;
|
||||
submitValue.current = answerRef.current?.value || value;
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => {
|
||||
|
||||
@@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
|
||||
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
|
||||
case "canvas":
|
||||
case "mindMap":
|
||||
case "mermaid": {
|
||||
case "mermaid":
|
||||
case "spreadsheet": {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
return <img
|
||||
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { t } from "../../services/i18n";
|
||||
import { goToLinkExt } from "../../services/link";
|
||||
import { Badge, BadgeWithDropdown } from "../react/Badge";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import { useGetContextData, useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { useGetContextDataFrom, useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { useShareState } from "../ribbon/BasicPropertiesTab";
|
||||
import { useShareInfo } from "../shared_info";
|
||||
import { ActiveContentBadges } from "./ActiveContentBadges";
|
||||
@@ -112,7 +112,8 @@ function ExecuteBadge() {
|
||||
}
|
||||
|
||||
export function SaveStatusBadge() {
|
||||
const saveState = useGetContextData("saveState");
|
||||
const { noteContext} = useNoteContext();
|
||||
const saveState = useGetContextDataFrom(noteContext, "saveState");
|
||||
if (!saveState) return;
|
||||
|
||||
const stateConfig = {
|
||||
|
||||
@@ -141,5 +141,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
view: () => import("./type_widgets/SqlConsole"),
|
||||
className: "sql-console-widget-container",
|
||||
isFullHeight: true
|
||||
},
|
||||
spreadsheet: {
|
||||
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
|
||||
className: "note-detail-spreadsheet",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
|
||||
#isFullWidthNote(note: FNote) {
|
||||
if (["code", "image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) {
|
||||
if (["code", "image", "mermaid", "book", "render", "canvas", "webView", "mindMap", "spreadsheet"].includes(note.type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
const COLLECTIONS_WITH_BACKGROUND_EFFECTS = [
|
||||
"grid",
|
||||
"list"
|
||||
]
|
||||
];
|
||||
|
||||
if (note.isOptions()) {
|
||||
return true;
|
||||
|
||||
135
apps/client/src/widgets/react/NoteAutocomplete.spec.tsx
Normal file
135
apps/client/src/widgets/react/NoteAutocomplete.spec.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import $ from "jquery";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
initNoteAutocompleteSpy,
|
||||
setTextSpy,
|
||||
clearTextSpy
|
||||
} = vi.hoisted(() => ({
|
||||
initNoteAutocompleteSpy: vi.fn(($el) => $el),
|
||||
setTextSpy: vi.fn(),
|
||||
clearTextSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.mock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
initNoteAutocomplete: initNoteAutocompleteSpy,
|
||||
setText: setTextSpy,
|
||||
clearText: clearTextSpy
|
||||
}
|
||||
}));
|
||||
|
||||
import NoteAutocomplete from "./NoteAutocomplete";
|
||||
|
||||
describe("NoteAutocomplete", () => {
|
||||
let container: HTMLDivElement;
|
||||
let setNoteSpy: ReturnType<typeof vi.fn>;
|
||||
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
setNoteSpy = vi.fn(() => Promise.resolve());
|
||||
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
|
||||
|
||||
($.fn as any).setNote = setNoteSpy;
|
||||
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("syncs text props through the headless helper functions", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="hello" />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="" />, container);
|
||||
});
|
||||
|
||||
expect(clearTextSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs noteId props through the jQuery setNote extension", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete noteId="note-123" />, container);
|
||||
});
|
||||
|
||||
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
|
||||
expect(clearTextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards autocomplete selection and clear events to consumers", () => {
|
||||
const onChange = vi.fn();
|
||||
const noteIdChanged = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
|
||||
|
||||
$input.trigger("autocomplete:noteselected", [suggestion]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(suggestion);
|
||||
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
|
||||
|
||||
input.value = "";
|
||||
$input.trigger("change");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("forwards onTextChange, onKeyDown and onBlur events", () => {
|
||||
const onTextChange = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<NoteAutocomplete
|
||||
onTextChange={onTextChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
/>,
|
||||
container
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
|
||||
input.value = "typed text";
|
||||
$input.trigger("input");
|
||||
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
|
||||
$input.trigger("blur");
|
||||
|
||||
expect(onTextChange).toHaveBeenCalledWith("typed text");
|
||||
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
|
||||
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import type { RefObject } from "preact";
|
||||
import type { JSX,RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
@@ -16,85 +17,111 @@ interface NoteAutocompleteProps {
|
||||
onChange?: (suggestion: Suggestion | null) => void;
|
||||
onTextChange?: (text: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onBlur?: (newValue: string) => void;
|
||||
noteIdChanged?: (noteId: string) => void;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
|
||||
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
// clear any event listener added in previous invocation of this function
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
|
||||
note_autocomplete.initNoteAutocomplete($autoComplete, {
|
||||
...opts,
|
||||
container: container?.current
|
||||
});
|
||||
if (onTextChange) {
|
||||
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
|
||||
}
|
||||
}, [opts, container?.current]);
|
||||
|
||||
// On change event handlers.
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
const inputListener = () => onTextChange?.($autoComplete[0].value);
|
||||
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
|
||||
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
|
||||
|
||||
if (onChange || noteIdChanged) {
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
$autoComplete
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
return () => {
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
if (onTextChange) {
|
||||
$autoComplete.on("input", inputListener);
|
||||
}
|
||||
}, [opts, container?.current, onChange, noteIdChanged])
|
||||
if (onKeyDown) {
|
||||
$autoComplete.on("keydown", keyDownListener);
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.on("blur", blurListener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (onTextChange) {
|
||||
$autoComplete.off("input", inputListener);
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.off("keydown", keyDownListener);
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.off("blur", blurListener);
|
||||
}
|
||||
};
|
||||
}, [onBlur, onKeyDown, onTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
if (!(onChange || noteIdChanged)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
|
||||
$autoComplete
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
|
||||
return () => {
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
}, [onChange, noteIdChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
if (noteId) {
|
||||
$autoComplete.setNote(noteId);
|
||||
} else if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
$autoComplete.setSelectedNotePath("");
|
||||
$autoComplete.autocomplete("val", "");
|
||||
ref.current.value = "";
|
||||
void $autoComplete.setNote(noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text !== undefined) {
|
||||
if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}, [text, noteId]);
|
||||
|
||||
return (
|
||||
@@ -103,6 +130,8 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="note-autocomplete form-control"
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUpCapture={onKeyUpCapture}
|
||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface SavedData {
|
||||
mime: string;
|
||||
content: string;
|
||||
position: number;
|
||||
encoding?: "base64";
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
const noteType = useNoteProperty(note, "type") ?? "";
|
||||
const [viewType] = useNoteLabel(note, "viewType");
|
||||
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
|
||||
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
|
||||
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
|
||||
const isContentAvailable = note.isContentAvailable();
|
||||
@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
);
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(noteType);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
|
||||
const isSearchOrBook = ["search", "book"].includes(noteType);
|
||||
const isHelpPage = note.noteId.startsWith("_help");
|
||||
const [syncServerHost] = useTriliumOption("syncServerHost");
|
||||
|
||||
@@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N
|
||||
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <NoteAction
|
||||
|
||||
@@ -50,9 +50,8 @@ body.desktop {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-top: 0;
|
||||
.note-detail-empty-results .aa-core-panel--contained {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search label {
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./MindMap.css";
|
||||
import nodeMenu from "@mind-elixir/node-menu";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { snapdom } from "@zumer/snapdom";
|
||||
import { default as VanillaMindElixir,MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME, DARK_THEME } from "mind-elixir";
|
||||
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
|
||||
import { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
@@ -154,6 +154,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const colorScheme = useColorScheme();
|
||||
const defaultColorScheme = useRef(colorScheme);
|
||||
|
||||
function reinitialize() {
|
||||
if (!containerRef.current) return;
|
||||
@@ -162,7 +163,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
el: containerRef.current,
|
||||
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
|
||||
editable,
|
||||
theme: LIGHT_THEME
|
||||
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
|
||||
});
|
||||
|
||||
if (editable) {
|
||||
@@ -188,7 +189,11 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
if (!apiRef.current) return;
|
||||
const newTheme = colorScheme === "dark" ? DARK_THEME : LIGHT_THEME;
|
||||
if (apiRef.current.theme === newTheme) return; // Avoid unnecessary theme changes, which can be expensive to render.
|
||||
apiRef.current.changeTheme(newTheme);
|
||||
try {
|
||||
apiRef.current.changeTheme(newTheme);
|
||||
} catch (e) {
|
||||
console.warn("Failed to change mind map theme:", e);
|
||||
}
|
||||
}, [ colorScheme ]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
<PdfViewer
|
||||
iframeRef={iframeRef}
|
||||
tabIndex={300}
|
||||
pdfUrl={`../../api/notes/${note.noteId}/open`}
|
||||
pdfUrl={new URL(`${window.glob.baseApiUrl}notes/${note.noteId}/open`, window.location.href).pathname}
|
||||
onLoad={() => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (win) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
interface FontDefinition {
|
||||
name: string;
|
||||
@@ -10,11 +11,11 @@ interface FontDefinition {
|
||||
|
||||
const FONTS: FontDefinition[] = [
|
||||
{name: "Inter", url: Inter},
|
||||
]
|
||||
];
|
||||
|
||||
interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabIndex"> {
|
||||
iframeRef?: RefObject<HTMLIFrameElement>;
|
||||
/** Note: URLs are relative to /pdfjs/web. */
|
||||
/** Note: URLs are relative to /pdfjs/web, ideally use absolute paths (but without domain name) to avoid issues with some proxies. */
|
||||
pdfUrl: string;
|
||||
onLoad?(): void;
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
|
||||
ref={iframeRef}
|
||||
class="pdf-preview"
|
||||
style={{width: "100%", height: "100%"}}
|
||||
src={`pdfjs/web/viewer.html?file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
onLoad={() => {
|
||||
injectStyles();
|
||||
onLoad?.();
|
||||
@@ -63,7 +64,7 @@ function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
const fontStyles = doc.createElement("style");
|
||||
fontStyles.textContent = FONTS.map(injectFont).join("\n");
|
||||
doc.head.appendChild(fontStyles);
|
||||
|
||||
|
||||
}, [ iframeRef ]);
|
||||
|
||||
// React to changes.
|
||||
@@ -107,4 +108,4 @@ function injectFont(font: FontDefinition) {
|
||||
src: url('${font.url}');
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.note-detail-spreadsheet > .spreadsheet {
|
||||
height: 100%;
|
||||
}
|
||||
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import "./Spreadsheet.css";
|
||||
import "@univerjs/preset-sheets-core/lib/index.css";
|
||||
import "@univerjs/preset-sheets-sort/lib/index.css";
|
||||
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
|
||||
import "@univerjs/preset-sheets-find-replace/lib/index.css";
|
||||
import "@univerjs/preset-sheets-note/lib/index.css";
|
||||
import "@univerjs/preset-sheets-filter/lib/index.css";
|
||||
import "@univerjs/preset-sheets-data-validation/lib/index.css";
|
||||
|
||||
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
|
||||
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
||||
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
|
||||
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
|
||||
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
|
||||
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
|
||||
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
|
||||
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
|
||||
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
|
||||
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
|
||||
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
|
||||
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
|
||||
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
|
||||
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import usePersistence from "./persistence";
|
||||
|
||||
export default function Spreadsheet(props: TypeWidgetProps) {
|
||||
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
|
||||
|
||||
// Use readOnly as key to force full remount (and data reload) when it changes.
|
||||
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
|
||||
}
|
||||
|
||||
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<FUniver>();
|
||||
|
||||
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
|
||||
useDarkMode(apiRef);
|
||||
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
|
||||
useSearchIntegration(apiRef);
|
||||
|
||||
// Focus the spreadsheet when the note is focused.
|
||||
useTriliumEvent("focusOnDetail", () => {
|
||||
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
|
||||
if (focusable instanceof HTMLElement) {
|
||||
focusable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="spreadsheet" />;
|
||||
}
|
||||
|
||||
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { univerAPI } = createUniver({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(
|
||||
sheetsCoreEnUS,
|
||||
sheetsFindReplaceEnUS,
|
||||
sheetsNoteEnUS,
|
||||
UniverPresetSheetsFilterEnUS,
|
||||
UniverPresetSheetsSortEnUS,
|
||||
UniverPresetSheetsDataValidationEnUS,
|
||||
UniverPresetSheetsConditionalFormattingEnUS,
|
||||
),
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container: containerRef.current,
|
||||
toolbar: !readOnly,
|
||||
contextMenu: !readOnly,
|
||||
formulaBar: !readOnly,
|
||||
footer: readOnly ? false : undefined,
|
||||
menu: {
|
||||
"sheet.contextMenu.permission": { hidden: true },
|
||||
"sheet-permission.operation.openPanel": { hidden: true },
|
||||
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
|
||||
},
|
||||
}),
|
||||
UniverSheetsFindReplacePreset(),
|
||||
UniverSheetsNotePreset(),
|
||||
UniverSheetsFilterPreset(),
|
||||
UniverSheetsSortPreset(),
|
||||
UniverSheetsDataValidationPreset(),
|
||||
UniverSheetsConditionalFormattingPreset()
|
||||
]
|
||||
});
|
||||
apiRef.current = univerAPI;
|
||||
return () => univerAPI.dispose();
|
||||
}, [ apiRef, containerRef, readOnly ]);
|
||||
}
|
||||
|
||||
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// React to dark mode.
|
||||
useEffect(() => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
univerAPI.toggleDarkMode(colorScheme === 'dark');
|
||||
}, [ colorScheme, apiRef ]);
|
||||
}
|
||||
|
||||
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
|
||||
useTriliumEvent("findInText", () => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
// Open find/replace panel and populate the search term.
|
||||
univerAPI.executeCommand("ui.operation.open-find-dialog");
|
||||
});
|
||||
}
|
||||
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../../components/note_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: Parameters<FUniver["createWorkbook"]>[0];
|
||||
}
|
||||
|
||||
interface SpreadsheetViewState {
|
||||
activeSheetId?: string;
|
||||
cursorRow?: number;
|
||||
cursorCol?: number;
|
||||
scrollRow?: number;
|
||||
scrollCol?: number;
|
||||
}
|
||||
|
||||
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
|
||||
const changeListener = useRef<IDisposable>(null);
|
||||
const pendingContent = useRef<string | null>(null);
|
||||
|
||||
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
|
||||
const state: SpreadsheetViewState = {};
|
||||
try {
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return state;
|
||||
|
||||
const activeSheet = workbook.getActiveSheet();
|
||||
state.activeSheetId = activeSheet?.getSheetId();
|
||||
|
||||
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
|
||||
if (currentCell) {
|
||||
state.cursorRow = currentCell.actualRow;
|
||||
state.cursorCol = currentCell.actualColumn;
|
||||
}
|
||||
|
||||
const scrollState = activeSheet?.getScrollState?.();
|
||||
if (scrollState) {
|
||||
state.scrollRow = scrollState.sheetViewStartRow;
|
||||
state.scrollCol = scrollState.sheetViewStartColumn;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when reading state from a workbook being disposed.
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
|
||||
try {
|
||||
if (state.activeSheetId) {
|
||||
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
|
||||
if (targetSheet) {
|
||||
workbook.setActiveSheet(targetSheet);
|
||||
}
|
||||
}
|
||||
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
|
||||
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
|
||||
}
|
||||
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
|
||||
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when restoring state (e.g. sheet no longer exists).
|
||||
}
|
||||
}
|
||||
|
||||
function applyContent(univerAPI: FUniver, newContent: string) {
|
||||
const viewState = saveViewState(univerAPI);
|
||||
|
||||
// Dispose the existing workbook.
|
||||
const existingWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (existingWorkbook) {
|
||||
univerAPI.disposeUnit(existingWorkbook.getId());
|
||||
}
|
||||
|
||||
let workbookData: Partial<IWorkbookData> = {};
|
||||
if (newContent) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(newContent) as unknown;
|
||||
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
|
||||
const persistedData = parsedContent as PersistedData;
|
||||
workbookData = persistedData.workbook;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse spreadsheet content", e);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = univerAPI.createWorkbook(workbookData);
|
||||
if (readOnly) {
|
||||
workbook.disableSelection();
|
||||
const permission = workbook.getPermission();
|
||||
permission.setWorkbookEditPermission(workbook.getId(), false);
|
||||
permission.setPermissionDialogVisible(false);
|
||||
}
|
||||
|
||||
restoreViewState(workbook, viewState);
|
||||
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
}
|
||||
changeListener.current = workbook.onCommandExecuted(command => {
|
||||
if (command.type !== CommandType.MUTATION) return;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
function isContainerVisible() {
|
||||
const el = containerRef.current;
|
||||
if (!el) return false;
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0;
|
||||
}
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
noteType: "spreadsheet",
|
||||
note,
|
||||
noteContext,
|
||||
async getData() {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return undefined;
|
||||
const content = {
|
||||
version: 1,
|
||||
workbook: workbook.save()
|
||||
};
|
||||
|
||||
const attachments: SavedData["attachments"] = [];
|
||||
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
|
||||
if (canvasEl) {
|
||||
const dataUrl = canvasEl.toDataURL("image/png");
|
||||
const base64 = dataUrl.split(",")[1];
|
||||
attachments.push({
|
||||
role: "image",
|
||||
title: "spreadsheet-export.png",
|
||||
mime: "image/png",
|
||||
content: base64,
|
||||
position: 0,
|
||||
encoding: "base64"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(content),
|
||||
attachments
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
|
||||
// Defer content application if the container is hidden (zero size),
|
||||
// since the spreadsheet library cannot calculate layout in that state.
|
||||
if (!isContainerVisible()) {
|
||||
pendingContent.current = newContent;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, newContent);
|
||||
},
|
||||
});
|
||||
|
||||
// Apply pending content once the container becomes visible (non-zero size).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pendingContent.current === null || !isContainerVisible()) return;
|
||||
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
const content = pendingContent.current;
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, content);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
|
||||
}, [ containerRef ]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
changeListener.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -120,7 +120,11 @@ export default defineConfig(() => ({
|
||||
environment: "happy-dom",
|
||||
setupFiles: [
|
||||
"./src/test/setup.ts"
|
||||
]
|
||||
],
|
||||
reporters: [
|
||||
"verbose",
|
||||
["html", { outputFile: "./test-output/vitest/html/index.html" }]
|
||||
],
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "src/main.ts",
|
||||
@@ -34,8 +34,8 @@
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.6.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.8.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/edit-docs",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"private": true,
|
||||
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
|
||||
"dependencies": {
|
||||
@@ -11,9 +11,9 @@
|
||||
"@triliumnext/client": "workspace:*",
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.6.1",
|
||||
"fs-extra": "11.3.3"
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.8.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { expect,test } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
const TEXT_NOTE_TITLE = "Text notes";
|
||||
@@ -32,8 +33,7 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
|
||||
await noteContent.focus();
|
||||
|
||||
// Click the search result in the second split.
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
|
||||
.nth(1).click();
|
||||
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
|
||||
|
||||
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
||||
});
|
||||
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await expect(autocomplete).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export default class App {
|
||||
readonly currentNoteSplitContent: Locator;
|
||||
readonly sidebar: Locator;
|
||||
private isMobile: boolean = false;
|
||||
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
|
||||
|
||||
constructor(page: Page, context: BrowserContext) {
|
||||
this.page = page;
|
||||
@@ -76,12 +77,19 @@ export default class App {
|
||||
|
||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(noteTitle);
|
||||
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
|
||||
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
|
||||
const suggestionSelector = resultsSelector
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
await expect(suggestionSelector).toContainText(noteTitle);
|
||||
await suggestionSelector.click();
|
||||
}
|
||||
|
||||
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
|
||||
return resultsContainer
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.page.locator(".launcher-button.bx-cog").click();
|
||||
}
|
||||
|
||||
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Nginx Proxy Manager (for testing reverse proxy setups)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start Trilium on the host (default port 8080):
|
||||
```bash
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
2. Start Nginx Proxy Manager:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
|
||||
- Email: `admin@example.com`
|
||||
- Password: `changeme`
|
||||
(You'll be asked to change these on first login.)
|
||||
|
||||
4. Add a proxy host:
|
||||
- **Domain Names**: `localhost`
|
||||
- **Scheme**: `http`
|
||||
- **Forward Hostname / IP**: `host.docker.internal`
|
||||
- **Forward Port**: `8080`
|
||||
- Enable **Websockets Support** (required for Trilium sync)
|
||||
|
||||
5. Access Trilium through NPM at **http://localhost:8090**.
|
||||
|
||||
## With a subpath
|
||||
|
||||
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
|
||||
|
||||
```nginx
|
||||
location /trilium/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://host.docker.internal:8080/;
|
||||
proxy_cookie_path / /trilium/;
|
||||
proxy_read_timeout 90;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
nginx-proxy-manager:
|
||||
image: "jc21/nginx-proxy-manager:latest"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Public HTTP port
|
||||
- "8090:80"
|
||||
# Admin panel
|
||||
- "8081:81"
|
||||
volumes:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
# Use host network mode so NPM can reach Trilium on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
npm_letsencrypt:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"main": "./src/main.ts",
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.6.2",
|
||||
"html-to-text": "9.0.5",
|
||||
"node-html-parser": "7.0.2",
|
||||
"node-html-parser": "7.1.0",
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,9 +55,9 @@
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -81,24 +81,24 @@
|
||||
"csrf-csrf": "3.2.2",
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "4.0.1",
|
||||
"electron": "40.6.1",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "40.8.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.3",
|
||||
"fs-extra": "11.3.4",
|
||||
"helmet": "8.1.0",
|
||||
"html": "1.0.0",
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.14",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -106,15 +106,15 @@
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.0",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.17.1",
|
||||
"sax": "1.4.4",
|
||||
"sax": "1.5.0",
|
||||
"serve-favicon": "2.5.1",
|
||||
"stream-throttle": "0.1.3",
|
||||
"strip-bom": "5.0.0",
|
||||
|
||||
@@ -86,8 +86,9 @@ export default async function buildApp() {
|
||||
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));
|
||||
app.use(`/icon.png`, express.static(path.join(publicAssetsDir, "icon.png")));
|
||||
|
||||
const sessionParser = (await import("./routes/session_parser.js")).default;
|
||||
const { default: sessionParser, startSessionCleanup } = await import("./routes/session_parser.js");
|
||||
app.use(sessionParser);
|
||||
startSessionCleanup();
|
||||
app.use(favicon(path.join(assetsDir, isDev ? "icon-dev.ico" : "icon.ico")));
|
||||
|
||||
if (openID.isOpenIDEnabled())
|
||||
@@ -98,16 +99,16 @@ export default async function buildApp() {
|
||||
custom.register(app);
|
||||
error_handlers.register(app);
|
||||
|
||||
// triggers sync timer
|
||||
await import("./services/sync.js");
|
||||
const { startSyncTimer } = await import("./services/sync.js");
|
||||
startSyncTimer();
|
||||
|
||||
// triggers backup timer
|
||||
await import("./services/backup.js");
|
||||
|
||||
// trigger consistency checks timer
|
||||
await import("./services/consistency_checks.js");
|
||||
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
|
||||
startConsistencyChecks();
|
||||
|
||||
await import("./services/scheduler.js");
|
||||
const { startScheduler } = await import("./services/scheduler.js");
|
||||
startScheduler();
|
||||
|
||||
startScheduledCleanup();
|
||||
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"go-to-next-note-title": "К следующей заметке",
|
||||
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
|
||||
"zen-mode": "Режим \"Дзен\"",
|
||||
"command-palette": "Открыть панель команд"
|
||||
"command-palette": "Открыть панель команд",
|
||||
"tab-switcher-title": "Переключатель вкладок"
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Закладки",
|
||||
@@ -313,7 +314,7 @@
|
||||
"title": "Настройка",
|
||||
"heading": "Настройка Trilium",
|
||||
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
|
||||
"sync-from-desktop": "У меня уже есть приложение ПК, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
|
||||
"init-in-progress": "Идет инициализация документа",
|
||||
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
|
||||
@@ -397,8 +398,8 @@
|
||||
"clipped-from": "Эта заметка изначально была вырезана из {{- url}}"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронизация с приложения ПК",
|
||||
"description": "Эту настройку необходимо инициировать из приложения для ПК:",
|
||||
"heading": "Синхронизация с настольной версией",
|
||||
"description": "Это настройку нужно выполнить с помощью настольной версии:",
|
||||
"step1": "Откройте приложение Trilium Notes на ПК.",
|
||||
"step2": "В меню Trilium выберите «Параметры».",
|
||||
"step3": "Нажмите на категорию «Синхронизация».",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Gå till föregående anteckning i historiken",
|
||||
"forward-in-note-history": "Gå till nästa anteckning i historiken",
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog",
|
||||
"open-command-palette": "Öppna kommandomenyn",
|
||||
"quick-search": "Öppna snabbsökning",
|
||||
"search-in-subtree": "Sök anteckningar nedåt i anteckningshierarkin",
|
||||
"expand-subtree": "Expandera hierarkin under denna anteckning",
|
||||
"collapse-tree": "Stänger anteckningshierarkin",
|
||||
"collapse-subtree": "Stänger hierarkin under aktuell anteckning",
|
||||
"sort-child-notes": "Sortera underordnade anteckningar",
|
||||
"creating-and-moving-notes": "Skapa och flytta anteckningar",
|
||||
"create-note-after": "Skapa ny anteckning efter aktiv anteckning",
|
||||
"create-note-into": "Skapa ny anteckning underordnad aktiv anteckning",
|
||||
"create-note-into-inbox": "Skapa en anteckning i inboxen (om angiven) eller som daganteckning",
|
||||
"delete-note": "Radera anteckning",
|
||||
"move-note-up": "Flytta anteckning uppåt",
|
||||
"move-note-down": "Flytta anteckning nedåt",
|
||||
"scroll-to-active-note": "Bläddra i anteckningshierarkin till aktiv anteckning",
|
||||
"move-note-up-in-hierarchy": "Flytta anteckning uppåt i hierarkin",
|
||||
"move-note-down-in-hierarchy": "Flytta anteckning neråt i hierarkin",
|
||||
"edit-note-title": "Hoppa från träd till anteckning och redigera titel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"create-note-into": "Aktif nota bağlı alt not oluştur",
|
||||
"create-note-after": "Aktif nottan sonra yeni bir not oluştur",
|
||||
"delete-note": "Notu sil",
|
||||
"move-note-down": "Notu aşağıya kaydır"
|
||||
"move-note-down": "Notu aşağıya kaydır",
|
||||
"create-note-into-inbox": "Eğer tanımlandıysa gelen kutusunda bir not veya günlük not oluşturun",
|
||||
"move-note-up-in-hierarchy": "Notu hiyerarşide yukarı taşı",
|
||||
"move-note-down-in-hierarchy": "Notu hiyerarşide aşağı taşı"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type { Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import mappers from "./mappers.js";
|
||||
import v from "./validators.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { Router } from "express";
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const attachments = note.getAttachments();
|
||||
res.json(attachments.map((attachment) => mappers.mapAttachmentToPojo(attachment)));
|
||||
@@ -41,7 +42,7 @@ function register(router: Router) {
|
||||
}
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
eu.route<{ attachmentId: string }>(router, "get", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
|
||||
|
||||
res.json(mappers.mapAttachmentToPojo(attachment));
|
||||
@@ -54,7 +55,7 @@ function register(router: Router) {
|
||||
position: [v.notNull, v.isInteger]
|
||||
};
|
||||
|
||||
eu.route(router, "patch", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
eu.route<{ attachmentId: string }>(router, "patch", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
|
||||
|
||||
if (attachment.isProtected) {
|
||||
@@ -67,7 +68,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapAttachmentToPojo(attachment));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
|
||||
eu.route<{ attachmentId: string }>(router, "get", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
|
||||
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
|
||||
|
||||
if (attachment.isProtected) {
|
||||
@@ -84,7 +85,7 @@ function register(router: Router) {
|
||||
res.send(attachment.getContent());
|
||||
});
|
||||
|
||||
eu.route(router, "put", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
|
||||
eu.route<{ attachmentId: string }>(router, "put", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
|
||||
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
|
||||
|
||||
if (attachment.isProtected) {
|
||||
@@ -96,7 +97,7 @@ function register(router: Router) {
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
eu.route(router, "delete", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
eu.route<{ attachmentId: string }>(router, "delete", "/etapi/attachments/:attachmentId", (req, res, next) => {
|
||||
const attachment = becca.getAttachment(req.params.attachmentId);
|
||||
|
||||
if (!attachment) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import becca from "../becca/becca.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import v from "./validators.js";
|
||||
import type { Router } from "express";
|
||||
import type { AttributeRow } from "@triliumnext/commons";
|
||||
import type { Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import mappers from "./mappers.js";
|
||||
import v from "./validators.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
eu.route<{ attributeId: string }>(router, "get", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
|
||||
|
||||
res.json(mappers.mapAttributeToPojo(attribute));
|
||||
@@ -51,7 +52,7 @@ function register(router: Router) {
|
||||
position: [v.notNull, v.isInteger]
|
||||
};
|
||||
|
||||
eu.route(router, "patch", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
eu.route<{ attributeId: string }>(router, "patch", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
|
||||
|
||||
if (attribute.type === "label") {
|
||||
@@ -67,7 +68,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapAttributeToPojo(attribute));
|
||||
});
|
||||
|
||||
eu.route(router, "delete", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
eu.route<{ attributeId: string }>(router, "delete", "/etapi/attributes/:attributeId", (req, res, next) => {
|
||||
const attribute = becca.getAttribute(req.params.attributeId);
|
||||
|
||||
if (!attribute) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Router } from "express";
|
||||
|
||||
import eu from "./etapi_utils.js";
|
||||
import backupService from "../services/backup.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
|
||||
eu.route<{ backupName: string }>(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
|
||||
backupService.backupNow(req.params.backupName)
|
||||
.then(() => res.sendStatus(204))
|
||||
.catch(() => res.sendStatus(500));
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
import type { Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import entityChangesService from "../services/entity_changes.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import v from "./validators.js";
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
eu.route<{ branchId: string }>(router, "get", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
const branch = eu.getAndCheckBranch(req.params.branchId);
|
||||
|
||||
res.json(mappers.mapBranchToPojo(branch));
|
||||
@@ -37,15 +37,15 @@ function register(router: Router) {
|
||||
existing.save();
|
||||
|
||||
return res.status(200).json(mappers.mapBranchToPojo(existing));
|
||||
} else {
|
||||
try {
|
||||
const branch = new BBranch(params).save();
|
||||
}
|
||||
try {
|
||||
const branch = new BBranch(params).save();
|
||||
|
||||
res.status(201).json(mappers.mapBranchToPojo(branch));
|
||||
} catch (e: any) {
|
||||
throw new eu.EtapiError(400, eu.GENERIC_CODE, e.message);
|
||||
}
|
||||
res.status(201).json(mappers.mapBranchToPojo(branch));
|
||||
} catch (e: any) {
|
||||
throw new eu.EtapiError(400, eu.GENERIC_CODE, e.message);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
const ALLOWED_PROPERTIES_FOR_PATCH = {
|
||||
@@ -54,7 +54,7 @@ function register(router: Router) {
|
||||
isExpanded: [v.notNull, v.isBoolean]
|
||||
};
|
||||
|
||||
eu.route(router, "patch", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
eu.route<{ branchId: string }>(router, "patch", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
const branch = eu.getAndCheckBranch(req.params.branchId);
|
||||
|
||||
eu.validateAndPatch(branch, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
|
||||
@@ -63,7 +63,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapBranchToPojo(branch));
|
||||
});
|
||||
|
||||
eu.route(router, "delete", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
eu.route<{ branchId: string }>(router, "delete", "/etapi/branches/:branchId", (req, res, next) => {
|
||||
const branch = becca.getBranch(req.params.branchId);
|
||||
|
||||
if (!branch) {
|
||||
@@ -75,7 +75,7 @@ function register(router: Router) {
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/refresh-note-ordering/:parentNoteId", (req, res, next) => {
|
||||
eu.route<{ parentNoteId: string }>(router, "post", "/etapi/refresh-note-ordering/:parentNoteId", (req, res, next) => {
|
||||
eu.getAndCheckNote(req.params.parentNoteId);
|
||||
|
||||
entityChangesService.putNoteReorderingEntityChange(req.params.parentNoteId, "etapi");
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
import log from "../services/log.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import etapiTokenService from "../services/etapi_tokens.js";
|
||||
import config from "../services/config.js";
|
||||
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ParamsDictionary } from "express-serve-static-core";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
|
||||
import cls from "../services/cls.js";
|
||||
import config from "../services/config.js";
|
||||
import etapiTokenService from "../services/etapi_tokens.js";
|
||||
import log from "../services/log.js";
|
||||
import sql from "../services/sql.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
const GENERIC_CODE = "GENERIC";
|
||||
|
||||
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
||||
@@ -35,8 +37,8 @@ function sendError(res: Response, statusCode: number, code: string, message: str
|
||||
.send(
|
||||
JSON.stringify({
|
||||
status: statusCode,
|
||||
code: code,
|
||||
message: message
|
||||
code,
|
||||
message
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -49,7 +51,7 @@ function checkEtapiAuth(req: Request, res: Response, next: NextFunction) {
|
||||
}
|
||||
}
|
||||
|
||||
function processRequest(req: Request, res: Response, routeHandler: ApiRequestHandler, next: NextFunction, method: string, path: string) {
|
||||
function processRequest<P extends ParamsDictionary>(req: Request<P>, res: Response, routeHandler: ApiRequestHandler<P>, next: NextFunction, method: string, path: string) {
|
||||
try {
|
||||
cls.namespace.bindEmitter(req);
|
||||
cls.namespace.bindEmitter(res);
|
||||
@@ -73,12 +75,12 @@ function processRequest(req: Request, res: Response, routeHandler: ApiRequestHan
|
||||
}
|
||||
}
|
||||
|
||||
function route(router: Router, method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
|
||||
router[method](path, checkEtapiAuth, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
|
||||
function route<P extends ParamsDictionary>(router: Router, method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler<P>) {
|
||||
router[method](path, checkEtapiAuth, (req: Request<P>, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
|
||||
}
|
||||
|
||||
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: SyncRouteRequestHandler) {
|
||||
router[method](path, ...middleware, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
|
||||
function NOT_AUTHENTICATED_ROUTE<P extends ParamsDictionary>(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: SyncRouteRequestHandler<P>) {
|
||||
router[method](path, ...middleware, (req: Request<P>, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
|
||||
}
|
||||
|
||||
function getAndCheckNote(noteId: string) {
|
||||
@@ -86,9 +88,8 @@ function getAndCheckNote(noteId: string) {
|
||||
|
||||
if (note) {
|
||||
return note;
|
||||
} else {
|
||||
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
|
||||
}
|
||||
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
|
||||
}
|
||||
|
||||
function getAndCheckAttachment(attachmentId: string) {
|
||||
@@ -96,9 +97,8 @@ function getAndCheckAttachment(attachmentId: string) {
|
||||
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
} else {
|
||||
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
|
||||
}
|
||||
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
|
||||
}
|
||||
|
||||
function getAndCheckBranch(branchId: string) {
|
||||
@@ -106,9 +106,8 @@ function getAndCheckBranch(branchId: string) {
|
||||
|
||||
if (branch) {
|
||||
return branch;
|
||||
} else {
|
||||
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
|
||||
}
|
||||
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
|
||||
}
|
||||
|
||||
function getAndCheckAttribute(attributeId: string) {
|
||||
@@ -116,9 +115,8 @@ function getAndCheckAttribute(attributeId: string) {
|
||||
|
||||
if (attribute) {
|
||||
return attribute;
|
||||
} else {
|
||||
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
|
||||
}
|
||||
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
|
||||
}
|
||||
|
||||
function getAndCheckRevision(revisionId: string) {
|
||||
@@ -126,9 +124,8 @@ function getAndCheckRevision(revisionId: string) {
|
||||
|
||||
if (revision) {
|
||||
return revision;
|
||||
} else {
|
||||
throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`);
|
||||
}
|
||||
throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`);
|
||||
}
|
||||
|
||||
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import becca from "../becca/becca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import v from "./validators.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import zipExportService from "../services/export/zip.js";
|
||||
import zipImportService from "../services/import/zip.js";
|
||||
import type { Request, Router } from "express";
|
||||
import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import zipExportService from "../services/export/zip.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
import zipImportService from "../services/import/zip.js";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import utils from "../services/utils.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import mappers from "./mappers.js";
|
||||
import v from "./validators.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
@@ -41,7 +42,7 @@ function register(router: Router) {
|
||||
res.json(resp);
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
@@ -86,7 +87,7 @@ function register(router: Router) {
|
||||
utcDateCreated: [v.notNull, v.isString, v.isUtcDateTime]
|
||||
};
|
||||
|
||||
eu.route(router, "patch", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "patch", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
if (note.isProtected) {
|
||||
@@ -100,7 +101,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "delete", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "delete", "/etapi/notes/:noteId", (req, res, next) => {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const note = becca.getNote(noteId);
|
||||
@@ -114,7 +115,7 @@ function register(router: Router) {
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/content", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/content", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
if (note.isProtected) {
|
||||
@@ -131,7 +132,7 @@ function register(router: Router) {
|
||||
res.send(note.getContent());
|
||||
});
|
||||
|
||||
eu.route(router, "put", "/etapi/notes/:noteId/content", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "put", "/etapi/notes/:noteId/content", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
if (note.isProtected) {
|
||||
@@ -146,7 +147,7 @@ function register(router: Router) {
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/export", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/export", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const format = req.query.format || "html";
|
||||
|
||||
@@ -163,7 +164,7 @@ function register(router: Router) {
|
||||
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "importNotes", null);
|
||||
|
||||
@@ -175,7 +176,7 @@ function register(router: Router) {
|
||||
}); // we need better error handling here, async errors won't be properly processed.
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
note.saveRevision();
|
||||
@@ -183,7 +184,7 @@ function register(router: Router) {
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const attachments = note.getAttachments();
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { NoteRow, RecentChangeRow } from "@triliumnext/commons";
|
||||
import type { Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import sql from "../services/sql.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import utils from "../services/utils.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { Router } from "express";
|
||||
import type { NoteRow, RecentChangeRow } from "@triliumnext/commons";
|
||||
|
||||
function register(router: Router) {
|
||||
// GET /etapi/notes/history - must be registered before /etapi/notes/:noteId routes
|
||||
@@ -130,7 +131,7 @@ function register(router: Router) {
|
||||
});
|
||||
|
||||
// GET /etapi/notes/:noteId/revisions - List all revisions for a note
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
|
||||
const revisions = becca.getRevisionsFromQuery(
|
||||
@@ -146,7 +147,7 @@ function register(router: Router) {
|
||||
});
|
||||
|
||||
// POST /etapi/notes/:noteId/undelete - Restore a deleted note
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => {
|
||||
eu.route<{ noteId: string }>(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const noteRow = sql.getRow<NoteRow | null>("SELECT * FROM notes WHERE noteId = ?", [noteId]);
|
||||
@@ -172,7 +173,7 @@ function register(router: Router) {
|
||||
});
|
||||
|
||||
// GET /etapi/revisions/:revisionId - Get revision metadata
|
||||
eu.route(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => {
|
||||
eu.route<{ revisionId: string }>(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => {
|
||||
const revision = eu.getAndCheckRevision(req.params.revisionId);
|
||||
|
||||
if (revision.isProtected) {
|
||||
@@ -183,7 +184,7 @@ function register(router: Router) {
|
||||
});
|
||||
|
||||
// GET /etapi/revisions/:revisionId/content - Get revision content
|
||||
eu.route(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => {
|
||||
eu.route<{ revisionId: string }>(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => {
|
||||
const revision = eu.getAndCheckRevision(req.params.revisionId);
|
||||
|
||||
if (revision.isProtected) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import specialNotesService from "../services/special_notes.js";
|
||||
import type { Router } from "express";
|
||||
|
||||
import dateNotesService from "../services/date_notes.js";
|
||||
import specialNotesService from "../services/special_notes.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import mappers from "./mappers.js";
|
||||
import type { Router } from "express";
|
||||
|
||||
const getDateInvalidError = (date: string) => new eu.EtapiError(400, "DATE_INVALID", `Date "${date}" is not valid.`);
|
||||
const getWeekInvalidError = (week: string) => new eu.EtapiError(400, "WEEK_INVALID", `Week "${week}" is not valid.`);
|
||||
@@ -15,7 +16,7 @@ function isValidDate(date: string) {
|
||||
}
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/inbox/:date", (req, res, next) => {
|
||||
eu.route<{ date: string }>(router, "get", "/etapi/inbox/:date", (req, res, next) => {
|
||||
const { date } = req.params;
|
||||
|
||||
if (!isValidDate(date)) {
|
||||
@@ -25,7 +26,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/calendar/days/:date", (req, res, next) => {
|
||||
eu.route<{ date: string }>(router, "get", "/etapi/calendar/days/:date", (req, res, next) => {
|
||||
const { date } = req.params;
|
||||
|
||||
if (!isValidDate(date)) {
|
||||
@@ -36,7 +37,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", (req, res, next) => {
|
||||
eu.route<{ date: string }>(router, "get", "/etapi/calendar/week-first-day/:date", (req, res, next) => {
|
||||
const { date } = req.params;
|
||||
|
||||
if (!isValidDate(date)) {
|
||||
@@ -47,7 +48,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/calendar/weeks/:week", (req, res, next) => {
|
||||
eu.route<{ week: string }>(router, "get", "/etapi/calendar/weeks/:week", (req, res, next) => {
|
||||
const { week } = req.params;
|
||||
|
||||
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
|
||||
@@ -63,7 +64,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/calendar/months/:month", (req, res, next) => {
|
||||
eu.route<{ month: string }>(router, "get", "/etapi/calendar/months/:month", (req, res, next) => {
|
||||
const { month } = req.params;
|
||||
|
||||
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
|
||||
@@ -74,7 +75,7 @@ function register(router: Router) {
|
||||
res.json(mappers.mapNoteToPojo(note));
|
||||
});
|
||||
|
||||
eu.route(router, "get", "/etapi/calendar/years/:year", (req, res, next) => {
|
||||
eu.route<{ year: string }>(router, "get", "/etapi/calendar/years/:year", (req, res, next) => {
|
||||
const { year } = req.params;
|
||||
|
||||
if (!/[0-9]{4}/.test(year)) {
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import becca from "../../becca/becca.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import type { Request } from "express";
|
||||
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
function getAttachmentBlob(req: Request) {
|
||||
import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import imageService from "../../services/image.js";
|
||||
|
||||
function getAttachmentBlob(req: Request<{ attachmentId: string }>) {
|
||||
const preview = req.query.preview === "true";
|
||||
|
||||
return blobService.getBlobPojo("attachments", req.params.attachmentId, { preview });
|
||||
}
|
||||
|
||||
function getAttachments(req: Request) {
|
||||
function getAttachments(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
return note.getAttachments();
|
||||
}
|
||||
|
||||
function getAttachment(req: Request) {
|
||||
function getAttachment(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
|
||||
return becca.getAttachmentOrThrow(attachmentId);
|
||||
}
|
||||
|
||||
function getAllAttachments(req: Request) {
|
||||
function getAllAttachments(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
// one particular attachment is requested, but return all note's attachments
|
||||
|
||||
@@ -31,10 +32,10 @@ function getAllAttachments(req: Request) {
|
||||
return attachment.getNote()?.getAttachments() || [];
|
||||
}
|
||||
|
||||
function saveAttachment(req: Request) {
|
||||
function saveAttachment(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const { attachmentId, role, mime, title, content } = req.body;
|
||||
const matchByQuery = req.query.matchBy
|
||||
const matchByQuery = req.query.matchBy;
|
||||
const isValidMatchBy = (typeof matchByQuery === "string") && (matchByQuery === "attachmentId" || matchByQuery === "title");
|
||||
const matchBy = isValidMatchBy ? matchByQuery : undefined;
|
||||
|
||||
@@ -42,7 +43,7 @@ function saveAttachment(req: Request) {
|
||||
note.saveAttachment({ attachmentId, role, mime, title, content }, matchBy);
|
||||
}
|
||||
|
||||
function uploadAttachment(req: Request) {
|
||||
function uploadAttachment(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const { file } = req;
|
||||
|
||||
@@ -76,7 +77,7 @@ function uploadAttachment(req: Request) {
|
||||
};
|
||||
}
|
||||
|
||||
function renameAttachment(req: Request) {
|
||||
function renameAttachment(req: Request<{ attachmentId: string }>) {
|
||||
const { title } = req.body;
|
||||
const { attachmentId } = req.params;
|
||||
|
||||
@@ -90,7 +91,7 @@ function renameAttachment(req: Request) {
|
||||
attachment.save();
|
||||
}
|
||||
|
||||
function deleteAttachment(req: Request) {
|
||||
function deleteAttachment(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
@@ -100,7 +101,7 @@ function deleteAttachment(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function convertAttachmentToNote(req: Request) {
|
||||
function convertAttachmentToNote(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
|
||||
const attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
import sql from "../../services/sql.js";
|
||||
import log from "../../services/log.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import BAttribute from "../../becca/entities/battribute.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
function getEffectiveNoteAttributes(req: Request) {
|
||||
import becca from "../../becca/becca.js";
|
||||
import BAttribute from "../../becca/entities/battribute.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
|
||||
function getEffectiveNoteAttributes(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNote(req.params.noteId);
|
||||
|
||||
return note?.getAttributes();
|
||||
}
|
||||
|
||||
function updateNoteAttribute(req: Request) {
|
||||
function updateNoteAttribute(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const body = req.body;
|
||||
|
||||
@@ -47,7 +48,7 @@ function updateNoteAttribute(req: Request) {
|
||||
}
|
||||
|
||||
attribute = new BAttribute({
|
||||
noteId: noteId,
|
||||
noteId,
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
isInheritable: body.isInheritable
|
||||
@@ -96,7 +97,7 @@ function addNoteAttribute(req: Request) {
|
||||
new BAttribute({ ...body, noteId }).save();
|
||||
}
|
||||
|
||||
function deleteNoteAttribute(req: Request) {
|
||||
function deleteNoteAttribute(req: Request<{ noteId: string; attributeId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const attributeId = req.params.attributeId;
|
||||
|
||||
@@ -111,7 +112,7 @@ function deleteNoteAttribute(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNoteAttributes(req: Request) {
|
||||
function updateNoteAttributes(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const incomingAttributes = req.body;
|
||||
|
||||
@@ -193,7 +194,7 @@ function getValuesForAttribute(req: Request) {
|
||||
return sql.getColumn("SELECT DISTINCT value FROM attributes WHERE isDeleted = 0 AND name = ? AND type = 'label' AND value != '' ORDER BY value", [attributeName]);
|
||||
}
|
||||
|
||||
function createRelation(req: Request) {
|
||||
function createRelation(req: Request<{ noteId: string; targetNoteId: string; name: string }>) {
|
||||
const sourceNoteId = req.params.noteId;
|
||||
const targetNoteId = req.params.targetNoteId;
|
||||
const name = req.params.name;
|
||||
@@ -208,7 +209,7 @@ function createRelation(req: Request) {
|
||||
if (!attribute) {
|
||||
attribute = new BAttribute({
|
||||
noteId: sourceNoteId,
|
||||
name: name,
|
||||
name,
|
||||
type: "relation",
|
||||
value: targetNoteId
|
||||
}).save();
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use strict";
|
||||
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import log from "../../services/log.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
/**
|
||||
* Code in this file deals with moving and cloning branches. The relationship between note and parent note is unique
|
||||
* for not deleted branches. There may be multiple deleted note-parent note relationships.
|
||||
*/
|
||||
|
||||
function moveBranchToParent(req: Request) {
|
||||
function moveBranchToParent(req: Request<{ branchId: string, parentBranchId: string }>) {
|
||||
const { branchId, parentBranchId } = req.params;
|
||||
|
||||
const branchToMove = becca.getBranch(branchId);
|
||||
@@ -31,7 +30,7 @@ function moveBranchToParent(req: Request) {
|
||||
return branchService.moveBranchToBranch(branchToMove, targetParentBranch, branchId);
|
||||
}
|
||||
|
||||
function moveBranchBeforeNote(req: Request) {
|
||||
function moveBranchBeforeNote(req: Request<{ branchId: string, beforeBranchId: string }>) {
|
||||
const { branchId, beforeBranchId } = req.params;
|
||||
|
||||
const branchToMove = becca.getBranchOrThrow(branchId);
|
||||
@@ -79,7 +78,7 @@ function moveBranchBeforeNote(req: Request) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function moveBranchAfterNote(req: Request) {
|
||||
function moveBranchAfterNote(req: Request<{ branchId: string, afterBranchId: string }>) {
|
||||
const { branchId, afterBranchId } = req.params;
|
||||
|
||||
const branchToMove = becca.getBranchOrThrow(branchId);
|
||||
@@ -128,7 +127,7 @@ function moveBranchAfterNote(req: Request) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function setExpanded(req: Request) {
|
||||
function setExpanded(req: Request<{ branchId: string, expanded: string }>) {
|
||||
const { branchId } = req.params;
|
||||
const expanded = parseInt(req.params.expanded);
|
||||
|
||||
@@ -150,7 +149,7 @@ function setExpanded(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function setExpandedForSubtree(req: Request) {
|
||||
function setExpandedForSubtree(req: Request<{ branchId: string, expanded: string }>) {
|
||||
const { branchId } = req.params;
|
||||
const expanded = parseInt(req.params.expanded);
|
||||
|
||||
@@ -232,7 +231,7 @@ function setExpandedForSubtree(req: Request) {
|
||||
* - session: []
|
||||
* tags: ["data"]
|
||||
*/
|
||||
function deleteBranch(req: Request) {
|
||||
function deleteBranch(req: Request<{ branchId: string }>) {
|
||||
const last = req.query.last === "true";
|
||||
const eraseNotes = req.query.eraseNotes === "true";
|
||||
const branch = becca.getBranchOrThrow(req.params.branchId);
|
||||
@@ -256,11 +255,11 @@ function deleteBranch(req: Request) {
|
||||
}
|
||||
|
||||
return {
|
||||
noteDeleted: noteDeleted
|
||||
noteDeleted
|
||||
};
|
||||
}
|
||||
|
||||
function setPrefix(req: Request) {
|
||||
function setPrefix(req: Request<{ branchId: string }>) {
|
||||
const branchId = req.params.branchId;
|
||||
//TriliumNextTODO: req.body arrives as string, so req.body.prefix will be undefined – did the code below ever even work?
|
||||
const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
|
||||
@@ -272,7 +271,7 @@ function setPrefix(req: Request) {
|
||||
|
||||
function setPrefixBatch(req: Request) {
|
||||
const { branchIds, prefix } = req.body;
|
||||
|
||||
|
||||
if (!Array.isArray(branchIds)) {
|
||||
throw new ValidationError("branchIds must be an array");
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ async function addClipping(req: Request) {
|
||||
if (!clippingNote) {
|
||||
clippingNote = noteService.createNewNote({
|
||||
parentNoteId: clipperInbox.noteId,
|
||||
title: title,
|
||||
title,
|
||||
content: "",
|
||||
type: "text"
|
||||
}).note;
|
||||
@@ -188,7 +188,7 @@ export function processContent(images: Image[], note: BNote, content: string) {
|
||||
return rewrittenContent;
|
||||
}
|
||||
|
||||
function openNote(req: Request) {
|
||||
function openNote(req: Request<{ noteId: string }>) {
|
||||
if (utils.isElectron) {
|
||||
ws.sendMessageToAllClients({
|
||||
type: "openNote",
|
||||
@@ -198,11 +198,11 @@ function openNote(req: Request) {
|
||||
return {
|
||||
result: "ok"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
result: "open-in-browser"
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: "open-in-browser"
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function handshake() {
|
||||
@@ -212,7 +212,7 @@ function handshake() {
|
||||
};
|
||||
}
|
||||
|
||||
async function findNotesByUrl(req: Request) {
|
||||
async function findNotesByUrl(req: Request<{ noteUrl: string }>) {
|
||||
const pageUrl = req.params.noteUrl;
|
||||
const clipperInbox = await getClipperInboxNote();
|
||||
const foundPage = findClippingNote(clipperInbox, pageUrl, null);
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use strict";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import cloningService from "../../services/cloning.js";
|
||||
|
||||
function cloneNoteToBranch(req: Request) {
|
||||
function cloneNoteToBranch(req: Request<{ noteId: string; parentBranchId: string }>) {
|
||||
const { noteId, parentBranchId } = req.params;
|
||||
const { prefix } = req.body;
|
||||
|
||||
return cloningService.cloneNoteToBranch(noteId, parentBranchId, prefix);
|
||||
}
|
||||
|
||||
function cloneNoteToParentNote(req: Request) {
|
||||
function cloneNoteToParentNote(req: Request<{ noteId: string; parentNoteId: string }>) {
|
||||
const { noteId, parentNoteId } = req.params;
|
||||
const { prefix } = req.body;
|
||||
|
||||
return cloningService.cloneNoteToParentNote(noteId, parentNoteId, prefix);
|
||||
}
|
||||
|
||||
function cloneNoteAfter(req: Request) {
|
||||
function cloneNoteAfter(req: Request<{ noteId: string; afterBranchId: string }>) {
|
||||
const { noteId, afterBranchId } = req.params;
|
||||
|
||||
return cloningService.cloneNoteAfter(noteId, afterBranchId);
|
||||
}
|
||||
|
||||
function toggleNoteInParent(req: Request) {
|
||||
function toggleNoteInParent(req: Request<{ noteId: string; parentNoteId: string; present: string }>) {
|
||||
const { noteId, parentNoteId, present } = req.params;
|
||||
|
||||
return cloningService.toggleNoteInParent(present === "true", noteId, parentNoteId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request } from "express";
|
||||
import etapiTokenService from "../../services/etapi_tokens.js";
|
||||
import { EtapiToken, PostTokensResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
import etapiTokenService from "../../services/etapi_tokens.js";
|
||||
|
||||
function getTokens() {
|
||||
const tokens = etapiTokenService.getTokens();
|
||||
@@ -14,11 +15,11 @@ function createToken(req: Request) {
|
||||
return etapiTokenService.createToken(req.body.tokenName) satisfies PostTokensResponse;
|
||||
}
|
||||
|
||||
function patchToken(req: Request) {
|
||||
function patchToken(req: Request<{ etapiTokenId: string }>) {
|
||||
etapiTokenService.renameToken(req.params.etapiTokenId, req.body.name);
|
||||
}
|
||||
|
||||
function deleteToken(req: Request) {
|
||||
function deleteToken(req: Request<{ etapiTokenId: string }>) {
|
||||
etapiTokenService.deleteToken(req.params.etapiTokenId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
import zipExportService from "../../services/export/zip.js";
|
||||
import singleExportService from "../../services/export/single.js";
|
||||
import opmlExportService from "../../services/export/opml.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import log from "../../services/log.js";
|
||||
import NotFoundError from "../../errors/not_found_error.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import NotFoundError from "../../errors/not_found_error.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import opmlExportService from "../../services/export/opml.js";
|
||||
import singleExportService from "../../services/export/single.js";
|
||||
import zipExportService from "../../services/export/zip.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
function exportBranch(req: Request, res: Response) {
|
||||
function exportBranch(req: Request<{ branchId: string; type: string; format: string; version: string; taskId: string }>, res: Response) {
|
||||
const { branchId, type, format, version, taskId } = req.params;
|
||||
const branch = becca.getBranch(branchId);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import chokidar from "chokidar";
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs";
|
||||
@@ -17,7 +15,7 @@ import protectedSessionService from "../../services/protected_session.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import ws from "../../services/ws.js";
|
||||
|
||||
function updateFile(req: Request) {
|
||||
function updateFile(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const file = req.file;
|
||||
@@ -46,7 +44,7 @@ function updateFile(req: Request) {
|
||||
};
|
||||
}
|
||||
|
||||
function updateAttachment(req: Request) {
|
||||
function updateAttachment(req: Request<{ attachmentId: string }>) {
|
||||
const attachment = becca.getAttachmentOrThrow(req.params.attachmentId);
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
@@ -103,20 +101,20 @@ function downloadAttachmentInt(attachmentId: string, res: Response, contentDispo
|
||||
return downloadData(attachment, res, contentDisposition);
|
||||
}
|
||||
|
||||
const downloadFile = (req: Request, res: Response) => downloadNoteInt(req.params.noteId, res, true);
|
||||
const openFile = (req: Request, res: Response) => downloadNoteInt(req.params.noteId, res, false);
|
||||
const downloadFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, true);
|
||||
const openFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, false);
|
||||
|
||||
const downloadAttachment = (req: Request, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, true);
|
||||
const openAttachment = (req: Request, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, false);
|
||||
const downloadAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, true);
|
||||
const openAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, false);
|
||||
|
||||
function fileContentProvider(req: Request) {
|
||||
function fileContentProvider(req: Request<{ noteId: string }>) {
|
||||
// Read the file name from route params.
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
return streamContent(note.getContent(), note.getFileName(), note.mime);
|
||||
}
|
||||
|
||||
function attachmentContentProvider(req: Request) {
|
||||
function attachmentContentProvider(req: Request<{ attachmentId: string }>) {
|
||||
// Read the file name from route params.
|
||||
const attachment = becca.getAttachmentOrThrow(req.params.attachmentId);
|
||||
|
||||
@@ -149,7 +147,7 @@ async function streamContent(content: string | Buffer, fileName: string, mimeTyp
|
||||
};
|
||||
}
|
||||
|
||||
function saveNoteToTmpDir(req: Request) {
|
||||
function saveNoteToTmpDir(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const fileName = note.getFileName();
|
||||
const content = note.getContent();
|
||||
@@ -157,7 +155,7 @@ function saveNoteToTmpDir(req: Request) {
|
||||
return saveToTmpDir(fileName, content, "notes", note.noteId);
|
||||
}
|
||||
|
||||
function saveAttachmentToTmpDir(req: Request) {
|
||||
function saveAttachmentToTmpDir(req: Request<{ attachmentId: string }>) {
|
||||
const attachment = becca.getAttachmentOrThrow(req.params.attachmentId);
|
||||
const fileName = attachment.getFileName();
|
||||
const content = attachment.getContent();
|
||||
@@ -205,7 +203,7 @@ function saveToTmpDir(fileName: string, content: string | Buffer, entityType: st
|
||||
};
|
||||
}
|
||||
|
||||
function uploadModifiedFileToNote(req: Request) {
|
||||
function uploadModifiedFileToNote(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const { filePath } = req.body;
|
||||
|
||||
@@ -228,7 +226,7 @@ function uploadModifiedFileToNote(req: Request) {
|
||||
note.setContent(fileContent);
|
||||
}
|
||||
|
||||
function uploadModifiedFileToAttachment(req: Request) {
|
||||
function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>) {
|
||||
const { attachmentId } = req.params;
|
||||
const { filePath } = req.body;
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
import imageService from "../../services/image.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import fs from "fs";
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import { RESOURCE_DIR } from "../../services/resource_dir.js";
|
||||
|
||||
function returnImageFromNote(req: Request, res: Response) {
|
||||
function returnImageFromNote(req: Request<{ noteId: string }>, res: Response) {
|
||||
const image = becca.getNote(req.params.noteId);
|
||||
|
||||
return returnImageInt(image, res);
|
||||
}
|
||||
|
||||
function returnImageFromRevision(req: Request, res: Response) {
|
||||
function returnImageFromRevision(req: Request<{ revisionId: string }>, res: Response) {
|
||||
const image = becca.getRevision(req.params.revisionId);
|
||||
|
||||
return returnImageInt(image, res);
|
||||
@@ -24,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
if (!image) {
|
||||
res.set("Content-Type", "image/png");
|
||||
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
@@ -34,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
renderSvgAttachment(image, res, "mermaid-export.svg");
|
||||
} else if (image.type === "mindMap") {
|
||||
renderSvgAttachment(image, res, "mindmap-export.svg");
|
||||
} else if (image.type === "spreadsheet") {
|
||||
renderPngAttachment(image, res, "spreadsheet-export.png");
|
||||
} else {
|
||||
res.set("Content-Type", image.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
@@ -61,7 +62,19 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request, res: Response) {
|
||||
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
|
||||
if (attachment) {
|
||||
res.set("Content-Type", "image/png");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(attachment.getContent());
|
||||
} else {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
|
||||
const attachment = becca.getAttachment(req.params.attachmentId);
|
||||
|
||||
if (!attachment) {
|
||||
@@ -78,7 +91,7 @@ function returnAttachedImage(req: Request, res: Response) {
|
||||
res.send(attachment.getContent());
|
||||
}
|
||||
|
||||
function updateImage(req: Request) {
|
||||
function updateImage(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const { file } = req;
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
import enexImportService from "../../services/import/enex.js";
|
||||
import opmlImportService from "../../services/import/opml.js";
|
||||
import zipImportService from "../../services/import/zip.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import type { Request } from "express";
|
||||
import path from "path";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import beccaLoader from "../../becca/becca_loader.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import enexImportService from "../../services/import/enex.js";
|
||||
import opmlImportService from "../../services/import/opml.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import zipImportService from "../../services/import/zip.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
async function importNotesToBranch(req: Request) {
|
||||
async function importNotesToBranch(req: Request<{ parentNoteId: string }>) {
|
||||
const { parentNoteId } = req.params;
|
||||
const { taskId, last } = req.body;
|
||||
|
||||
@@ -88,7 +87,7 @@ async function importNotesToBranch(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId: parentNoteId,
|
||||
parentNoteId,
|
||||
importedNoteId: note?.noteId
|
||||
}),
|
||||
1000
|
||||
@@ -101,7 +100,7 @@ async function importNotesToBranch(req: Request) {
|
||||
return note.getPojo();
|
||||
}
|
||||
|
||||
function importAttachmentsToNote(req: Request) {
|
||||
function importAttachmentsToNote(req: Request<{ parentNoteId: string }>) {
|
||||
const { parentNoteId } = req.params;
|
||||
const { taskId, last } = req.body;
|
||||
|
||||
@@ -138,7 +137,7 @@ function importAttachmentsToNote(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId: parentNoteId
|
||||
parentNoteId
|
||||
}),
|
||||
1000
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BAttribute from "../../becca/entities/battribute.js";
|
||||
|
||||
import { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
import { HTMLElement, parse, TextNode } from "node-html-parser";
|
||||
import { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BAttribute from "../../becca/entities/battribute.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
|
||||
interface TreeLink {
|
||||
sourceNoteId: string;
|
||||
@@ -97,7 +98,7 @@ function getNeighbors(note: BNote, depth: number): string[] {
|
||||
return retNoteIds;
|
||||
}
|
||||
|
||||
function getLinkMap(req: Request) {
|
||||
function getLinkMap(req: Request<{ noteId: string }>) {
|
||||
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
// if the map root itself has "excludeFromNoteMap" attribute (journal typically) then there wouldn't be anything
|
||||
@@ -156,9 +157,9 @@ function getLinkMap(req: Request) {
|
||||
return false;
|
||||
} else if (excludeRelations.has(rel.name)) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
})
|
||||
.map((rel) => ({
|
||||
id: `${rel.noteId}-${rel.name}-${rel.value}`,
|
||||
@@ -168,13 +169,13 @@ function getLinkMap(req: Request) {
|
||||
}));
|
||||
|
||||
return {
|
||||
notes: notes,
|
||||
notes,
|
||||
noteIdToDescendantCountMap: buildDescendantCountMap(noteIdsArray),
|
||||
links: links
|
||||
links
|
||||
};
|
||||
}
|
||||
|
||||
function getTreeMap(req: Request) {
|
||||
function getTreeMap(req: Request<{ noteId: string }>) {
|
||||
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
|
||||
// if the map root itself has "excludeFromNoteMap" (journal typically) then there wouldn't be anything to display,
|
||||
// so we'll just ignore it
|
||||
@@ -223,9 +224,9 @@ function getTreeMap(req: Request) {
|
||||
updateDescendantCountMapForSearch(noteIdToDescendantCountMap, subtree.relationships);
|
||||
|
||||
return {
|
||||
notes: notes,
|
||||
noteIdToDescendantCountMap: noteIdToDescendantCountMap,
|
||||
links: links
|
||||
notes,
|
||||
noteIdToDescendantCountMap,
|
||||
links
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +351,7 @@ function getFilteredBacklinks(note: BNote): BAttribute[] {
|
||||
);
|
||||
}
|
||||
|
||||
function getBacklinkCount(req: Request) {
|
||||
function getBacklinkCount(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
@@ -360,7 +361,7 @@ function getBacklinkCount(req: Request) {
|
||||
} satisfies BacklinkCountResponse;
|
||||
}
|
||||
|
||||
function getBacklinks(req: Request): BacklinksResponse {
|
||||
function getBacklinks(req: Request<{ noteId: string }>): BacklinksResponse {
|
||||
const { noteId } = req.params;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
import noteService from "../../services/notes.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
|
||||
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import type { Request } from "express";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -39,7 +40,7 @@ import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, Metadata
|
||||
* - session: []
|
||||
* tags: ["data"]
|
||||
*/
|
||||
function getNote(req: Request) {
|
||||
function getNote(req: Request<{ noteId: string }>) {
|
||||
return becca.getNoteOrThrow(req.params.noteId);
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ function getNote(req: Request) {
|
||||
* - session: []
|
||||
* tags: ["data"]
|
||||
*/
|
||||
function getNoteBlob(req: Request) {
|
||||
function getNoteBlob(req: Request<{ noteId: string }>) {
|
||||
return blobService.getBlobPojo("notes", req.params.noteId);
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ function getNoteBlob(req: Request) {
|
||||
* - session: []
|
||||
* tags: ["data"]
|
||||
*/
|
||||
function getNoteMetadata(req: Request) {
|
||||
function getNoteMetadata(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
return {
|
||||
@@ -126,7 +127,7 @@ function createNote(req: Request) {
|
||||
} satisfies CreateChildrenResponse;
|
||||
}
|
||||
|
||||
function updateNoteData(req: Request) {
|
||||
function updateNoteData(req: Request<{ noteId: string }>) {
|
||||
const { content, attachments } = req.body;
|
||||
const { noteId } = req.params;
|
||||
|
||||
@@ -170,7 +171,7 @@ function updateNoteData(req: Request) {
|
||||
* - session: []
|
||||
* tags: ["data"]
|
||||
*/
|
||||
function deleteNote(req: Request) {
|
||||
function deleteNote(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const taskId = req.query.taskId;
|
||||
const eraseNotes = req.query.eraseNotes === "true";
|
||||
@@ -197,7 +198,7 @@ function deleteNote(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function undeleteNote(req: Request) {
|
||||
function undeleteNote(req: Request<{ noteId: string }>) {
|
||||
const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes", null);
|
||||
|
||||
noteService.undeleteNote(req.params.noteId, taskContext);
|
||||
@@ -205,7 +206,7 @@ function undeleteNote(req: Request) {
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
function sortChildNotes(req: Request) {
|
||||
function sortChildNotes(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const { sortBy, sortDirection, foldersFirst, sortNatural, sortLocale } = req.body;
|
||||
|
||||
@@ -216,7 +217,7 @@ function sortChildNotes(req: Request) {
|
||||
treeService.sortNotes(noteId, sortBy, reverse, foldersFirst, sortNatural, sortLocale);
|
||||
}
|
||||
|
||||
function protectNote(req: Request) {
|
||||
function protectNote(req: Request<{ noteId: string; isProtected: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const note = becca.notes[noteId];
|
||||
const protect = !!parseInt(req.params.isProtected);
|
||||
@@ -229,7 +230,7 @@ function protectNote(req: Request) {
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
function setNoteTypeMime(req: Request) {
|
||||
function setNoteTypeMime(req: Request<{ noteId: string }>) {
|
||||
// can't use [] destructuring because req.params is not iterable
|
||||
const { noteId } = req.params;
|
||||
const { type, mime } = req.body;
|
||||
@@ -240,7 +241,7 @@ function setNoteTypeMime(req: Request) {
|
||||
note.save();
|
||||
}
|
||||
|
||||
function changeTitle(req: Request) {
|
||||
function changeTitle(req: Request<{ noteId: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const title = req.body.title;
|
||||
|
||||
@@ -267,7 +268,7 @@ function changeTitle(req: Request) {
|
||||
return note;
|
||||
}
|
||||
|
||||
function duplicateSubtree(req: Request) {
|
||||
function duplicateSubtree(req: Request<{ noteId: string; parentNoteId: string }>) {
|
||||
const { noteId, parentNoteId } = req.params;
|
||||
|
||||
return noteService.duplicateSubtree(noteId, parentNoteId);
|
||||
@@ -342,7 +343,7 @@ function getDeleteNotesPreview(req: Request) {
|
||||
} satisfies DeleteNotesPreview;
|
||||
}
|
||||
|
||||
function forceSaveRevision(req: Request) {
|
||||
function forceSaveRevision(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
@@ -353,7 +354,7 @@ function forceSaveRevision(req: Request) {
|
||||
note.saveRevision();
|
||||
}
|
||||
|
||||
function convertNoteToAttachment(req: Request) {
|
||||
function convertNoteToAttachment(req: Request<{ noteId: string }>) {
|
||||
const { noteId } = req.params;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ function getOptions() {
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
function updateOption(req: Request) {
|
||||
function updateOption(req: Request<{ name: string; value: string }>) {
|
||||
const { name, value } = req.params;
|
||||
|
||||
if (!update(name, value)) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
import sql from "../../services/sql.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import type { Request } from "express";
|
||||
import type { RecentChangeRow } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
|
||||
function getRecentChanges(req: Request) {
|
||||
import becca from "../../becca/becca.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import sql from "../../services/sql.js";
|
||||
|
||||
function getRecentChanges(req: Request<{ ancestorNoteId: string }>) {
|
||||
const { ancestorNoteId } = req.params;
|
||||
|
||||
let recentChanges: RecentChangeRow[] = [];
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import path from "path";
|
||||
import becca from "../../becca/becca.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import type { Request, Response } from "express";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { NotePojo } from "../../becca/becca-interface.js";
|
||||
|
||||
import { EditedNotesResponse, RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons";
|
||||
import type { Request, Response } from "express";
|
||||
import path from "path";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import type { NotePojo } from "../../becca/becca-interface.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
interface NotePath {
|
||||
noteId: string;
|
||||
@@ -26,13 +27,13 @@ interface NotePojoWithNotePath extends NotePojo {
|
||||
notePath?: string[] | null;
|
||||
}
|
||||
|
||||
function getRevisionBlob(req: Request) {
|
||||
function getRevisionBlob(req: Request<{ revisionId: string }>) {
|
||||
const preview = req.query.preview === "true";
|
||||
|
||||
return blobService.getBlobPojo("revisions", req.params.revisionId, { preview });
|
||||
}
|
||||
|
||||
function getRevisions(req: Request) {
|
||||
function getRevisions(req: Request<{ noteId: string }>) {
|
||||
return becca.getRevisionsFromQuery(
|
||||
`
|
||||
SELECT revisions.*,
|
||||
@@ -45,7 +46,7 @@ function getRevisions(req: Request) {
|
||||
) satisfies RevisionItem[];
|
||||
}
|
||||
|
||||
function getRevision(req: Request) {
|
||||
function getRevision(req: Request<{ revisionId: string }>) {
|
||||
const revision = becca.getRevisionOrThrow(req.params.revisionId);
|
||||
|
||||
if (revision.type === "file") {
|
||||
@@ -85,7 +86,7 @@ function getRevisionFilename(revision: BRevision) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
function downloadRevision(req: Request, res: Response) {
|
||||
function downloadRevision(req: Request<{ revisionId: string }>, res: Response) {
|
||||
const revision = becca.getRevisionOrThrow(req.params.revisionId);
|
||||
|
||||
if (!revision.isContentAvailable()) {
|
||||
@@ -100,13 +101,13 @@ function downloadRevision(req: Request, res: Response) {
|
||||
res.send(revision.getContent());
|
||||
}
|
||||
|
||||
function eraseAllRevisions(req: Request) {
|
||||
function eraseAllRevisions(req: Request<{ noteId: string }>) {
|
||||
const revisionIdsToErase = sql.getColumn<string>("SELECT revisionId FROM revisions WHERE noteId = ?", [req.params.noteId]);
|
||||
|
||||
eraseService.eraseRevisions(revisionIdsToErase);
|
||||
}
|
||||
|
||||
function eraseRevision(req: Request) {
|
||||
function eraseRevision(req: Request<{ revisionId: string }>) {
|
||||
eraseService.eraseRevisions([req.params.revisionId]);
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ function eraseAllExcessRevisions() {
|
||||
});
|
||||
}
|
||||
|
||||
function restoreRevision(req: Request) {
|
||||
function restoreRevision(req: Request<{ revisionId: string }>) {
|
||||
const revision = becca.getRevision(req.params.revisionId);
|
||||
|
||||
if (revision) {
|
||||
@@ -166,7 +167,7 @@ function getEditedNotesOnDate(req: Request) {
|
||||
)
|
||||
ORDER BY isDeleted
|
||||
LIMIT 50`,
|
||||
{ date: `${req.params.date}%` }
|
||||
{ date: `${req.params.date}%` }
|
||||
);
|
||||
|
||||
let notes = becca.getNotes(noteIds, true);
|
||||
@@ -204,7 +205,7 @@ function getNotePathData(note: BNote): NotePath | undefined {
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
branchId: branchId,
|
||||
branchId,
|
||||
title: noteTitle,
|
||||
notePath: retPath,
|
||||
path: retPath.join("/")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
import scriptService, { type Bundle } from "../../services/script.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import syncService from "../../services/sync.js";
|
||||
import sql from "../../services/sql.js";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import scriptService, { type Bundle } from "../../services/script.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import syncService from "../../services/sync.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
interface ScriptBody {
|
||||
@@ -43,7 +44,7 @@ async function exec(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function run(req: Request) {
|
||||
function run(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const result = scriptService.executeNote(note, { originEntity: note });
|
||||
@@ -71,23 +72,23 @@ function getStartupBundles(req: Request) {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
if (req.query.mobile === "true") {
|
||||
return getBundlesWithLabel("run", "mobileStartup");
|
||||
} else {
|
||||
return getBundlesWithLabel("run", "frontendStartup");
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return getBundlesWithLabel("run", "frontendStartup");
|
||||
|
||||
}
|
||||
return [];
|
||||
|
||||
}
|
||||
|
||||
function getWidgetBundles() {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
return getBundlesWithLabel("widget");
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
}
|
||||
|
||||
function getRelationBundles(req: Request) {
|
||||
function getRelationBundles(req: Request<{ noteId: string, relationName: string }>) {
|
||||
const noteId = req.params.noteId;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
const relationName = req.params.relationName;
|
||||
@@ -116,7 +117,7 @@ function getRelationBundles(req: Request) {
|
||||
return bundles;
|
||||
}
|
||||
|
||||
function getBundle(req: Request) {
|
||||
function getBundle(req: Request<{ noteId: string }>) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const { script, params } = req.body ?? {};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user