mirror of
https://github.com/zadam/trilium.git
synced 2025-11-08 22:35:50 +01:00
Compare commits
249 Commits
d992a5e4a2
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71eb1edf07 | ||
|
|
f3e6ba8f37 | ||
|
|
5b7e9d4c12 | ||
|
|
bee2fdb22f | ||
|
|
5c46a0dfa8 | ||
|
|
69b262040a | ||
|
|
8731fa6c31 | ||
|
|
f4e8fc4d83 | ||
|
|
dd5b3a3c1c | ||
|
|
17319d25e8 | ||
|
|
2f189b6961 | ||
|
|
b1f8d44576 | ||
|
|
7f22532a0a | ||
|
|
c7beb87980 | ||
|
|
5cd1fd53d4 | ||
|
|
2eadbe3f01 | ||
|
|
4e7493f648 | ||
|
|
b9d54a44f6 | ||
|
|
a1ad8be02b | ||
|
|
b02514f395 | ||
|
|
dcef3f2be5 | ||
|
|
585fdabd27 | ||
|
|
71fcb77a22 | ||
|
|
33ecf6aa6d | ||
|
|
1f75de83c6 | ||
|
|
31b52f72d2 | ||
|
|
01aaf81196 | ||
|
|
3ecfdd62e8 | ||
|
|
3c74d0714a | ||
|
|
f58d9adff2 | ||
|
|
0eecf5b132 | ||
|
|
9e3cca333a | ||
|
|
81c233463e | ||
|
|
87946e7e85 | ||
|
|
c3768a051d | ||
|
|
c579cd3ce7 | ||
|
|
945e2625d3 | ||
|
|
ff36414a55 | ||
|
|
8f184c5b10 | ||
|
|
c027a2bbfa | ||
|
|
91adc2258d | ||
|
|
6701e83927 | ||
|
|
3f54e589d8 | ||
|
|
f65be73f71 | ||
|
|
346e9282bd | ||
|
|
8f8ea7adc3 | ||
|
|
4affd3a955 | ||
|
|
bcce05cc4d | ||
|
|
ac16c42e23 | ||
|
|
5025329e92 | ||
|
|
507910b0ce | ||
|
|
b59fab9dba | ||
|
|
ac7e4580f6 | ||
|
|
27d1044ba8 | ||
|
|
96c949b2fc | ||
|
|
927cd0255e | ||
|
|
c2c8417c42 | ||
|
|
3bb224e682 | ||
|
|
6f126ea17b | ||
|
|
61a5cf1452 | ||
|
|
14b8d0a47e | ||
|
|
12df6a0d6e | ||
|
|
21d243eec1 | ||
|
|
161238ca11 | ||
|
|
4d5267e18b | ||
|
|
0fa52907b3 | ||
|
|
c4f57f3d15 | ||
|
|
6bde264156 | ||
|
|
4f72f81a95 | ||
|
|
c212c5d6ff | ||
|
|
f24880d42c | ||
|
|
ee9bf1d47b | ||
|
|
b069fab82f | ||
|
|
d5ce01a65b | ||
|
|
dbfa94a9ee | ||
|
|
86aaa97809 | ||
|
|
c4c8fe23a9 | ||
|
|
715fe77db3 | ||
|
|
40f5abd6e3 | ||
|
|
f3f7e5900b | ||
|
|
f4402a6d81 | ||
|
|
6966efd374 | ||
|
|
cd3e025fdc | ||
|
|
a224b774d3 | ||
|
|
f20078f3b0 | ||
|
|
56019e5449 | ||
|
|
7dd517d8f7 | ||
|
|
b2f1b3c910 | ||
|
|
2197fae700 | ||
|
|
3661733f07 | ||
|
|
52a6f2597e | ||
|
|
d8e9cad23d | ||
|
|
6ed333d222 | ||
|
|
ba26c478d6 | ||
|
|
055fcb7b2a | ||
|
|
f4468706ef | ||
|
|
212956201a | ||
|
|
1182592fc5 | ||
|
|
d534db29c9 | ||
|
|
40edd42740 | ||
|
|
d2c7011735 | ||
|
|
a050d1741b | ||
|
|
18982865da | ||
|
|
3aa810fed7 | ||
|
|
c5ecc22c67 | ||
|
|
252f8ccb1f | ||
|
|
e1bb704383 | ||
|
|
dce0d9400b | ||
|
|
615c783fe3 | ||
|
|
f29411baf7 | ||
|
|
be5e70130c | ||
|
|
9ba1e9d732 | ||
|
|
e1dc4d1433 | ||
|
|
d0d268496c | ||
|
|
8a6950c945 | ||
|
|
477592d176 | ||
|
|
7e5c2ed79d | ||
|
|
bc580f2a88 | ||
|
|
71cd92e0b5 | ||
|
|
a4d92e12be | ||
|
|
c40279b480 | ||
|
|
4c7e7c157c | ||
|
|
c08386450a | ||
|
|
eb93762ecc | ||
|
|
2697f9a25d | ||
|
|
9515e2099b | ||
|
|
966c08da87 | ||
|
|
ea04446e81 | ||
|
|
e4f806ed14 | ||
|
|
49cf7ae1a3 | ||
|
|
1a6f5a027f | ||
|
|
f4796f0f9e | ||
|
|
30480b2c23 | ||
|
|
b7b1d17817 | ||
|
|
c4e5494c14 | ||
|
|
b0f63c02c9 | ||
|
|
2480509811 | ||
|
|
7872193ed0 | ||
|
|
1a68bdfe02 | ||
|
|
14e06c4555 | ||
|
|
b8e17959ae | ||
|
|
c16a135efc | ||
|
|
cbc756ba06 | ||
|
|
64daeb0826 | ||
|
|
e15839db47 | ||
|
|
dcdffed003 | ||
|
|
48e85fad43 | ||
|
|
189071deb8 | ||
|
|
354f1d65c1 | ||
|
|
b78893b106 | ||
|
|
9310315c6a | ||
|
|
1794f8546d | ||
|
|
b3bc0572e5 | ||
|
|
253ce1f223 | ||
|
|
2f3bf94b47 | ||
|
|
d802caa03b | ||
|
|
e69751a8b3 | ||
|
|
0760ea22fb | ||
|
|
8a8f407e99 | ||
|
|
e030dd96da | ||
|
|
01abfc2528 | ||
|
|
042b929dc5 | ||
|
|
ab1d5e31fb | ||
|
|
d073e4c37f | ||
|
|
d60d965a42 | ||
|
|
1c87cfbbd9 | ||
|
|
fee333512a | ||
|
|
38a3f46506 | ||
|
|
bf7506fcd8 | ||
|
|
6fbba426de | ||
|
|
d5bdec13b5 | ||
|
|
cc1b6eb42d | ||
|
|
8baf496f96 | ||
|
|
23a20c4490 | ||
|
|
c8b98f2db6 | ||
|
|
3f36f515db | ||
|
|
892eb5b95d | ||
|
|
62a69a0da0 | ||
|
|
3588e38543 | ||
|
|
41450ab85a | ||
|
|
0526d99560 | ||
|
|
557d576b85 | ||
|
|
041c961cfa | ||
|
|
dcc35bd507 | ||
|
|
09c3e5b56e | ||
|
|
950793377d | ||
|
|
7dac61dc26 | ||
|
|
42dcb8f141 | ||
|
|
43dc8a4b87 | ||
|
|
35316a4c45 | ||
|
|
1366489f99 | ||
|
|
0c399a676a | ||
|
|
31ee78b1aa | ||
|
|
808ba75ee0 | ||
|
|
ac1399a139 | ||
|
|
1e4793351a | ||
|
|
f502fe41c7 | ||
|
|
0ec0091357 | ||
|
|
0e2196f872 | ||
|
|
32dee254cd | ||
|
|
d4a6a297f4 | ||
|
|
a64d8cd8e2 | ||
|
|
bf4cfb9c02 | ||
|
|
a99dfecf43 | ||
|
|
1530d96eca | ||
|
|
5dc066f4c6 | ||
|
|
395f33cd5b | ||
|
|
21b20cf575 | ||
|
|
e3dd25b591 | ||
|
|
b9a4e7ab11 | ||
|
|
6ae67c410c | ||
|
|
4ef7667484 | ||
|
|
3660e2f127 | ||
|
|
357d294f2d | ||
|
|
bb636128b0 | ||
|
|
aa102ab393 | ||
|
|
ea53665e64 | ||
|
|
9cf7fa1997 | ||
|
|
fded714f18 | ||
|
|
06de06b501 | ||
|
|
9abdbbbc5b | ||
|
|
3ebfee8bd2 | ||
|
|
6d446c5b27 | ||
|
|
3a55490bbf | ||
|
|
bc4643fed2 | ||
|
|
a2110ca631 | ||
|
|
413137ac64 | ||
|
|
9bc966491d | ||
|
|
61dbc15fc6 | ||
|
|
b475037127 | ||
|
|
35622a2122 | ||
|
|
77e4c3d0ec | ||
|
|
8523050ab2 | ||
|
|
0efdf65202 | ||
|
|
acb0991d05 | ||
|
|
a9f68f5487 | ||
|
|
55bb2fdb9b | ||
|
|
e529633b8b | ||
|
|
dfd575b6eb | ||
|
|
c5196721d4 | ||
|
|
968c75b618 | ||
|
|
01beebf660 | ||
|
|
d3115e834a | ||
|
|
01a552ceb5 | ||
|
|
d8958adea5 | ||
|
|
4d5e866db6 | ||
|
|
f189deb415 | ||
|
|
9c460dbc87 | ||
|
|
2c6ba9ba2c |
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -12,7 +12,7 @@ runs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '24'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
# Install Node.js dependencies for the TypeScript script
|
# Install Node.js dependencies for the TypeScript script
|
||||||
|
|||||||
2
.github/workflows/dev.yml
vendored
2
.github/workflows/dev.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|||||||
18
.github/workflows/main-docker.yml
vendored
18
.github/workflows/main-docker.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
@@ -86,12 +86,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Playwright trace
|
- name: Upload Playwright trace
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: Playwright trace (${{ matrix.dockerfile }})
|
name: Playwright trace (${{ matrix.dockerfile }})
|
||||||
path: test-output/playwright/output
|
path: test-output/playwright/output
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v5
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: Playwright report (${{ matrix.dockerfile }})
|
name: Playwright report (${{ matrix.dockerfile }})
|
||||||
@@ -116,12 +116,6 @@ jobs:
|
|||||||
- dockerfile: Dockerfile
|
- dockerfile: Dockerfile
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
image: ubuntu-24.04-arm
|
image: ubuntu-24.04-arm
|
||||||
- dockerfile: Dockerfile
|
|
||||||
platform: linux/arm/v7
|
|
||||||
image: ubuntu-24.04-arm
|
|
||||||
- dockerfile: Dockerfile
|
|
||||||
platform: linux/arm/v8
|
|
||||||
image: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.image }}
|
runs-on: ${{ matrix.image }}
|
||||||
needs:
|
needs:
|
||||||
- test_docker
|
- test_docker
|
||||||
@@ -146,7 +140,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -209,7 +203,7 @@ jobs:
|
|||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
@@ -223,7 +217,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
name: Nightly Build
|
name: Nightly Build
|
||||||
|
|
||||||
- name: Publish artifacts
|
- name: Publish artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
||||||
|
|||||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test report
|
- name: Upload test report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: e2e report
|
name: e2e report
|
||||||
path: apps/server-e2e/test-output
|
path: apps/server-e2e/test-output
|
||||||
|
|||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||||
|
|
||||||
- name: Upload the artifact
|
- name: Upload the artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
||||||
path: apps/desktop/upload/*.*
|
path: apps/desktop/upload/*.*
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
arch: ${{ matrix.arch }}
|
arch: ${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload the artifact
|
- name: Upload the artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: release-server-linux-${{ matrix.arch }}
|
name: release-server-linux-${{ matrix.arch }}
|
||||||
path: upload/*.*
|
path: upload/*.*
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
docs/Release Notes
|
docs/Release Notes
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
|||||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@stylistic/eslint-plugin": "5.5.0",
|
"@stylistic/eslint-plugin": "5.5.0",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.5",
|
||||||
"@types/node": "22.18.12",
|
"@types/node": "24.9.1",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.34",
|
||||||
"@vitest/coverage-v8": "3.2.4",
|
"@vitest/coverage-v8": "3.2.4",
|
||||||
"eslint": "9.38.0",
|
"eslint": "9.38.0",
|
||||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||||
|
|||||||
@@ -54,12 +54,12 @@
|
|||||||
"leaflet-gpx": "2.2.0",
|
"leaflet-gpx": "2.2.0",
|
||||||
"mark.js": "8.11.1",
|
"mark.js": "8.11.1",
|
||||||
"marked": "16.4.1",
|
"marked": "16.4.1",
|
||||||
"mermaid": "11.12.0",
|
"mermaid": "11.12.1",
|
||||||
"mind-elixir": "5.3.3",
|
"mind-elixir": "5.3.4",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"panzoom": "9.4.3",
|
"panzoom": "9.4.3",
|
||||||
"preact": "10.27.2",
|
"preact": "10.27.2",
|
||||||
"react-i18next": "16.1.2",
|
"react-i18next": "16.2.1",
|
||||||
"reveal.js": "5.2.1",
|
"reveal.js": "5.2.1",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"tabulator-tables": "6.3.1",
|
"tabulator-tables": "6.3.1",
|
||||||
@@ -74,9 +74,9 @@
|
|||||||
"@types/leaflet-gpx": "1.3.8",
|
"@types/leaflet-gpx": "1.3.8",
|
||||||
"@types/mark.js": "8.11.12",
|
"@types/mark.js": "8.11.12",
|
||||||
"@types/reveal.js": "5.2.1",
|
"@types/reveal.js": "5.2.1",
|
||||||
"@types/tabulator-tables": "6.2.11",
|
"@types/tabulator-tables": "6.3.0",
|
||||||
"copy-webpack-plugin": "13.0.1",
|
"copy-webpack-plugin": "13.0.1",
|
||||||
"happy-dom": "20.0.7",
|
"happy-dom": "20.0.8",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"vite-plugin-static-copy": "3.1.4"
|
"vite-plugin-static-copy": "3.1.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,12 +218,12 @@ export type CommandMappings = {
|
|||||||
/** Works only in the electron context menu. */
|
/** Works only in the electron context menu. */
|
||||||
replaceMisspelling: CommandData;
|
replaceMisspelling: CommandData;
|
||||||
|
|
||||||
importMarkdownInline: CommandData;
|
|
||||||
showPasswordNotSet: CommandData;
|
showPasswordNotSet: CommandData;
|
||||||
showProtectedSessionPasswordDialog: CommandData;
|
showProtectedSessionPasswordDialog: CommandData;
|
||||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||||
|
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||||
closeProtectedSessionPasswordDialog: CommandData;
|
closeProtectedSessionPasswordDialog: CommandData;
|
||||||
copyImageReferenceToClipboard: CommandData;
|
copyImageReferenceToClipboard: CommandData;
|
||||||
copyImageToClipboard: CommandData;
|
copyImageToClipboard: CommandData;
|
||||||
|
|||||||
@@ -56,7 +56,20 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
|
|||||||
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
|
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
|
||||||
}
|
}
|
||||||
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
|
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
|
||||||
containerRef.current?.replaceChildren(...$renderedContent);
|
const container = containerRef.current!;
|
||||||
|
container.replaceChildren(...$renderedContent);
|
||||||
|
|
||||||
|
// Wait for all images to load.
|
||||||
|
const images = Array.from(container.querySelectorAll("img"));
|
||||||
|
await Promise.all(
|
||||||
|
images.map(img => {
|
||||||
|
if (img.complete) return Promise.resolve();
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
img.addEventListener("load", () => resolve(), { once: true });
|
||||||
|
img.addEventListener("error", () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
load().then(() => requestAnimationFrame(onReady))
|
load().then(() => requestAnimationFrame(onReady))
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ function setupGlobs() {
|
|||||||
window.glob.froca = froca;
|
window.glob.froca = froca;
|
||||||
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
|
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
|
||||||
|
|
||||||
// for CKEditor integration (button on block toolbar)
|
|
||||||
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
|
|
||||||
|
|
||||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||||
const string = String(msg).toLowerCase();
|
const string = String(msg).toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,6 @@ async function ensureJQuery() {
|
|||||||
(window as any).$ = $;
|
(window as any).$ = $;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyMath() {
|
|
||||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
|
||||||
if (!anyMathBlock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
|
|
||||||
renderMathInElement(document.getElementById("content"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatCodeBlocks() {
|
async function formatCodeBlocks() {
|
||||||
const anyCodeBlock = document.querySelector("#content pre");
|
const anyCodeBlock = document.querySelector("#content pre");
|
||||||
if (!anyCodeBlock) {
|
if (!anyCodeBlock) {
|
||||||
@@ -31,54 +21,4 @@ async function formatCodeBlocks() {
|
|||||||
|
|
||||||
async function setupTextNote() {
|
async function setupTextNote() {
|
||||||
formatCodeBlocks();
|
formatCodeBlocks();
|
||||||
applyMath();
|
|
||||||
|
|
||||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
|
||||||
setupMermaid();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch note with given ID from backend
|
|
||||||
*
|
|
||||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
|
||||||
*/
|
|
||||||
async function fetchNote(noteId: string | null = null) {
|
|
||||||
if (!noteId) {
|
|
||||||
noteId = document.body.getAttribute("data-note-id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await fetch(`api/notes/${noteId}`);
|
|
||||||
|
|
||||||
return await resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"DOMContentLoaded",
|
|
||||||
() => {
|
|
||||||
const noteType = determineNoteType();
|
|
||||||
|
|
||||||
if (noteType === "text") {
|
|
||||||
setupTextNote();
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
|
||||||
const layout = document.getElementById("layout");
|
|
||||||
|
|
||||||
if (toggleMenuButton && layout) {
|
|
||||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
function determineNoteType() {
|
|
||||||
const bodyClass = document.body.className;
|
|
||||||
const match = bodyClass.match(/type-([^\s]+)/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
|
||||||
// add fetchNote as property to the window object
|
|
||||||
Object.defineProperty(window, "fetchNote", {
|
|
||||||
value: fetchNote
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2034,9 +2034,9 @@ body.zen #right-pane,
|
|||||||
body.zen #mobile-sidebar-wrapper,
|
body.zen #mobile-sidebar-wrapper,
|
||||||
body.zen .tab-row-container,
|
body.zen .tab-row-container,
|
||||||
body.zen .tab-row-widget,
|
body.zen .tab-row-widget,
|
||||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
|
body.zen .ribbon-container:not(:has(.classic-toolbar-widget)),
|
||||||
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
|
body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row,
|
||||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
|
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)),
|
||||||
body.zen .note-icon-widget,
|
body.zen .note-icon-widget,
|
||||||
body.zen .title-row .icon-action,
|
body.zen .title-row .icon-action,
|
||||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"critical-error": {
|
"critical-error": {
|
||||||
"title": "خطأ فادح"
|
"title": "خطأ فادح"
|
||||||
|
},
|
||||||
|
"widget-error": {
|
||||||
|
"title": "فشل في البدء بعنصر الواجهة"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"add_link": {
|
"add_link": {
|
||||||
@@ -26,7 +29,8 @@
|
|||||||
"edit_branch_prefix": "تعديل بادئة الفرع",
|
"edit_branch_prefix": "تعديل بادئة الفرع",
|
||||||
"prefix": "البادئة: ",
|
"prefix": "البادئة: ",
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة"
|
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة",
|
||||||
|
"branch_prefix_saved": "تم حفظ بادئة الفرع."
|
||||||
},
|
},
|
||||||
"bulk_actions": {
|
"bulk_actions": {
|
||||||
"bulk_actions": "اجراءات جماعية",
|
"bulk_actions": "اجراءات جماعية",
|
||||||
@@ -83,7 +87,8 @@
|
|||||||
"workspace_calendar_root": "تحديد جذر التقويم لكل مساحة عمل",
|
"workspace_calendar_root": "تحديد جذر التقويم لكل مساحة عمل",
|
||||||
"hide_highlight_widget": "اخفاء عنصر واجهة قائمة التمييزات",
|
"hide_highlight_widget": "اخفاء عنصر واجهة قائمة التمييزات",
|
||||||
"is_owned_by_note": "تخص الملاحظة",
|
"is_owned_by_note": "تخص الملاحظة",
|
||||||
"and_more": "... و {{count}}مرات اكثر."
|
"and_more": "... و {{count}}مرات اكثر.",
|
||||||
|
"related_notes_title": "ملاحظات اخرى بنفس التسمية"
|
||||||
},
|
},
|
||||||
"rename_label": {
|
"rename_label": {
|
||||||
"to": "الى",
|
"to": "الى",
|
||||||
@@ -127,7 +132,9 @@
|
|||||||
"delete_attachment": "حذف المرفق",
|
"delete_attachment": "حذف المرفق",
|
||||||
"upload_new_revision": "رفع مراجعة جديدة",
|
"upload_new_revision": "رفع مراجعة جديدة",
|
||||||
"copy_link_to_clipboard": "نسخ الرابط الى الحافظة",
|
"copy_link_to_clipboard": "نسخ الرابط الى الحافظة",
|
||||||
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة"
|
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة",
|
||||||
|
"delete_success": "تم حذف المرفق \"{{title}}\" .",
|
||||||
|
"enter_new_name": "ادخل اسم مرفق جديد"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"week": "أسبوع",
|
"week": "أسبوع",
|
||||||
@@ -259,7 +266,8 @@
|
|||||||
"note_paths": {
|
"note_paths": {
|
||||||
"search": "بحث",
|
"search": "بحث",
|
||||||
"archived": "مؤرشف",
|
"archived": "مؤرشف",
|
||||||
"title": "مسارات الملاحظة"
|
"title": "مسارات الملاحظة",
|
||||||
|
"clone_button": "جار نسخ الملاحظة الى مكان جديد..."
|
||||||
},
|
},
|
||||||
"script_executor": {
|
"script_executor": {
|
||||||
"query": "استعلام",
|
"query": "استعلام",
|
||||||
@@ -372,7 +380,8 @@
|
|||||||
"export_note_title": "تصدير الملاحظة",
|
"export_note_title": "تصدير الملاحظة",
|
||||||
"export_status": "حالة التصدير",
|
"export_status": "حالة التصدير",
|
||||||
"export_finished_successfully": "اكتمل التصدير بنجاح.",
|
"export_finished_successfully": "اكتمل التصدير بنجاح.",
|
||||||
"export_in_progress": "جار التصدير: {{progressCount}}"
|
"export_in_progress": "جار التصدير: {{progressCount}}",
|
||||||
|
"choose_export_type": "اختر نوع التصدير اولا من فضلك"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
|
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
|
||||||
@@ -402,7 +411,10 @@
|
|||||||
"movingCloningNotes": "نقل/ استنساخ الملاحظات",
|
"movingCloningNotes": "نقل/ استنساخ الملاحظات",
|
||||||
"deleteNotes": "حذف الملاحظة/ الشجرة الفرعية",
|
"deleteNotes": "حذف الملاحظة/ الشجرة الفرعية",
|
||||||
"collapseWholeTree": "طي شجرة الملاحظة باكملها",
|
"collapseWholeTree": "طي شجرة الملاحظة باكملها",
|
||||||
"followLink": "اتبع تلرابط تحت المؤشر"
|
"followLink": "اتبع تلرابط تحت المؤشر",
|
||||||
|
"onlyInDesktop": "في سطح المكتب فقط(Electron build)",
|
||||||
|
"createEditLink": "انشاء/ تحرير رابط خارجي",
|
||||||
|
"quickSearch": "الانتقال الى مربع البحث السريع"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"options": "خيارات",
|
"options": "خيارات",
|
||||||
@@ -465,7 +477,13 @@
|
|||||||
"delete_all_button": "حذف كل المراجعات",
|
"delete_all_button": "حذف كل المراجعات",
|
||||||
"settings": "اعدادات مراجعة الملاحظة",
|
"settings": "اعدادات مراجعة الملاحظة",
|
||||||
"diff_not_available": "المقارنة غير متوفرة.",
|
"diff_not_available": "المقارنة غير متوفرة.",
|
||||||
"help_title": "مساعدة حول مراجعات الملاحظة"
|
"help_title": "مساعدة حول مراجعات الملاحظة",
|
||||||
|
"diff_off_hint": "انقر لعرض محتويات الملاحظة",
|
||||||
|
"revisions_deleted": "تم حذف جميع نسخ المراجعات للملاحظة.",
|
||||||
|
"revision_restored": "تم استعادة نسخ المراجعة للملاحظة.",
|
||||||
|
"revision_deleted": "تم حذف مراجعة الملاحظة.",
|
||||||
|
"snapshot_interval": "فاصل زمني لحفظ لقطات اصدارات المراجعة: {{seconds}}",
|
||||||
|
"maximum_revisions": "حد عدد لقطات اصدارات الملاحظة: {{number}}"
|
||||||
},
|
},
|
||||||
"sort_child_notes": {
|
"sort_child_notes": {
|
||||||
"title": "عنوان",
|
"title": "عنوان",
|
||||||
@@ -479,13 +497,15 @@
|
|||||||
"sorting_direction": "اتجاه الترتيب",
|
"sorting_direction": "اتجاه الترتيب",
|
||||||
"natural_sort": "الترتيب الطبيعي",
|
"natural_sort": "الترتيب الطبيعي",
|
||||||
"natural_sort_language": "لغات الترتيب الطبيعي",
|
"natural_sort_language": "لغات الترتيب الطبيعي",
|
||||||
"sort_children_by": "ترتيب العناصر الفرعية حسب..."
|
"sort_children_by": "ترتيب العناصر الفرعية حسب...",
|
||||||
|
"sort_folders_at_top": "ترتيب المجلدات في الاعلى"
|
||||||
},
|
},
|
||||||
"recent_changes": {
|
"recent_changes": {
|
||||||
"undelete_link": "الغاء الحذف",
|
"undelete_link": "الغاء الحذف",
|
||||||
"title": "التغيرات الاخيرة",
|
"title": "التغيرات الاخيرة",
|
||||||
"no_changes_message": "لايوجد تغيير لحد الان...",
|
"no_changes_message": "لايوجد تغيير لحد الان...",
|
||||||
"erase_notes_button": "مسح الملاحظات المحذوفة الان"
|
"erase_notes_button": "مسح الملاحظات المحذوفة الان",
|
||||||
|
"deleted_notes_message": "تم حذف الملاحظات نهائيا."
|
||||||
},
|
},
|
||||||
"edited_notes": {
|
"edited_notes": {
|
||||||
"deleted": "(حذف)",
|
"deleted": "(حذف)",
|
||||||
@@ -705,7 +725,9 @@
|
|||||||
"default_token_name": "رمز جديد",
|
"default_token_name": "رمز جديد",
|
||||||
"rename_token_title": "اعادة تسمية الرمز",
|
"rename_token_title": "اعادة تسمية الرمز",
|
||||||
"rename_token": "اعادة تسمية هذا الرمز",
|
"rename_token": "اعادة تسمية هذا الرمز",
|
||||||
"create_token": "انشاء رمز PEAPI جديد"
|
"create_token": "انشاء رمز PEAPI جديد",
|
||||||
|
"new_token_title": "رمز ETAPI جديد",
|
||||||
|
"token_created_title": "انشاء رمز ETAPI"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"heading": "كلمة المرور",
|
"heading": "كلمة المرور",
|
||||||
@@ -811,7 +833,8 @@
|
|||||||
"help_on_links": "مساعدة حول الارتباطات التشعبية",
|
"help_on_links": "مساعدة حول الارتباطات التشعبية",
|
||||||
"notes_to_clone": "ملاحظات للنسخ",
|
"notes_to_clone": "ملاحظات للنسخ",
|
||||||
"target_parent_note": "الملاحظة الاصلية الهدف",
|
"target_parent_note": "الملاحظة الاصلية الهدف",
|
||||||
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة"
|
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة",
|
||||||
|
"no_path_to_clone_to": "لايوجد مسار لنسخ المحتوى الية."
|
||||||
},
|
},
|
||||||
"table_of_contents": {
|
"table_of_contents": {
|
||||||
"unit": "عناوين",
|
"unit": "عناوين",
|
||||||
@@ -1029,7 +1052,8 @@
|
|||||||
},
|
},
|
||||||
"delete_note": {
|
"delete_note": {
|
||||||
"delete_note": "حذف الملاحظة",
|
"delete_note": "حذف الملاحظة",
|
||||||
"delete_matched_notes": "حف الملاحظات المطابقة"
|
"delete_matched_notes": "حف الملاحظات المطابقة",
|
||||||
|
"delete_matched_notes_description": "سوف يؤدي هذا الى حذف الملاحظات المطابقة."
|
||||||
},
|
},
|
||||||
"rename_note": {
|
"rename_note": {
|
||||||
"rename_note": "اعادة تسمية الملاحظة",
|
"rename_note": "اعادة تسمية الملاحظة",
|
||||||
@@ -1312,7 +1336,8 @@
|
|||||||
"notes_to_move": "الملاحظات المراد نقلها",
|
"notes_to_move": "الملاحظات المراد نقلها",
|
||||||
"target_parent_note": "ملاحظة الاصل الهدف",
|
"target_parent_note": "ملاحظة الاصل الهدف",
|
||||||
"dialog_title": "انقل الملاحظات الى...",
|
"dialog_title": "انقل الملاحظات الى...",
|
||||||
"move_button": "نقل الىالملاحظة المحددة"
|
"move_button": "نقل الىالملاحظة المحددة",
|
||||||
|
"error_no_path": "لايوجد مسار لنقل العنصر الية."
|
||||||
},
|
},
|
||||||
"delete_revisions": {
|
"delete_revisions": {
|
||||||
"delete_note_revisions": "حذف مراجعات الملاحظة"
|
"delete_note_revisions": "حذف مراجعات الملاحظة"
|
||||||
@@ -1363,7 +1388,8 @@
|
|||||||
"save_attributes": "حفظ السمات <enter>",
|
"save_attributes": "حفظ السمات <enter>",
|
||||||
"add_a_new_attribute": "اضافة سمة جديدة",
|
"add_a_new_attribute": "اضافة سمة جديدة",
|
||||||
"add_new_label_definition": "اضافة تعريف لتسمية جديدة",
|
"add_new_label_definition": "اضافة تعريف لتسمية جديدة",
|
||||||
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة"
|
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة",
|
||||||
|
"add_new_relation": "اضافة علاقة جديدة <kbd data-command=\"addNewRelation\">"
|
||||||
},
|
},
|
||||||
"zen_mode": {
|
"zen_mode": {
|
||||||
"button_exit": "الخروج من وضع Zen"
|
"button_exit": "الخروج من وضع Zen"
|
||||||
@@ -1434,5 +1460,8 @@
|
|||||||
},
|
},
|
||||||
"png_export_button": {
|
"png_export_button": {
|
||||||
"button_title": "تصدير المخطط كملف PNG"
|
"button_title": "تصدير المخطط كملف PNG"
|
||||||
|
},
|
||||||
|
"protected_session_status": {
|
||||||
|
"inactive": "انقر للدخول الى جلسة محمية"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,6 @@
|
|||||||
"delete_all_revisions": "删除此笔记的所有修订版本",
|
"delete_all_revisions": "删除此笔记的所有修订版本",
|
||||||
"delete_all_button": "删除所有修订版本",
|
"delete_all_button": "删除所有修订版本",
|
||||||
"help_title": "关于笔记修订版本的帮助",
|
"help_title": "关于笔记修订版本的帮助",
|
||||||
"revision_last_edited": "此修订版本上次编辑于 {{date}}",
|
|
||||||
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
|
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
|
||||||
"no_revisions": "此笔记暂无修订版本...",
|
"no_revisions": "此笔记暂无修订版本...",
|
||||||
"restore_button": "恢复",
|
"restore_button": "恢复",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"homepage": "Startseite:",
|
"homepage": "Startseite:",
|
||||||
"app_version": "App-Version:",
|
"app_version": "App-Version:",
|
||||||
"db_version": "DB-Version:",
|
"db_version": "DB-Version:",
|
||||||
"sync_version": "Synch-version:",
|
"sync_version": "Sync-Version:",
|
||||||
"build_date": "Build-Datum:",
|
"build_date": "Build-Datum:",
|
||||||
"build_revision": "Build-Revision:",
|
"build_revision": "Build-Revision:",
|
||||||
"data_directory": "Datenverzeichnis:"
|
"data_directory": "Datenverzeichnis:"
|
||||||
@@ -184,7 +184,8 @@
|
|||||||
},
|
},
|
||||||
"import-status": "Importstatus",
|
"import-status": "Importstatus",
|
||||||
"in-progress": "Import läuft: {{progress}}",
|
"in-progress": "Import läuft: {{progress}}",
|
||||||
"successful": "Import erfolgreich abgeschlossen."
|
"successful": "Import erfolgreich abgeschlossen.",
|
||||||
|
"importZipRecommendation": "Beim Import einer ZIP-Datei wird die Notizhierarchie aus der Ordnerstruktur im Archiv übernommen."
|
||||||
},
|
},
|
||||||
"include_note": {
|
"include_note": {
|
||||||
"dialog_title": "Notiz beifügen",
|
"dialog_title": "Notiz beifügen",
|
||||||
@@ -259,7 +260,6 @@
|
|||||||
"delete_all_revisions": "Lösche alle Revisionen dieser Notiz",
|
"delete_all_revisions": "Lösche alle Revisionen dieser Notiz",
|
||||||
"delete_all_button": "Alle Revisionen löschen",
|
"delete_all_button": "Alle Revisionen löschen",
|
||||||
"help_title": "Hilfe zu Notizrevisionen",
|
"help_title": "Hilfe zu Notizrevisionen",
|
||||||
"revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet",
|
|
||||||
"confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?",
|
"confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?",
|
||||||
"no_revisions": "Für diese Notiz gibt es noch keine Revisionen...",
|
"no_revisions": "Für diese Notiz gibt es noch keine Revisionen...",
|
||||||
"confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.",
|
"confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.",
|
||||||
@@ -647,7 +647,8 @@
|
|||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"show-cheatsheet": "Cheatsheet anzeigen",
|
"show-cheatsheet": "Cheatsheet anzeigen",
|
||||||
"toggle-zen-mode": "Zen Modus",
|
"toggle-zen-mode": "Zen Modus",
|
||||||
"new-version-available": "Neues Update verfügbar"
|
"new-version-available": "Neues Update verfügbar",
|
||||||
|
"download-update": "Version {{latestVersion}} herunterladen"
|
||||||
},
|
},
|
||||||
"sync_status": {
|
"sync_status": {
|
||||||
"unknown": "<p>Der Synchronisations-Status wird bekannt, sobald der nächste Synchronisierungsversuch gestartet wird.</p><p>Klicke, um eine Synchronisierung jetzt auszulösen.</p>",
|
"unknown": "<p>Der Synchronisations-Status wird bekannt, sobald der nächste Synchronisierungsversuch gestartet wird.</p><p>Klicke, um eine Synchronisierung jetzt auszulösen.</p>",
|
||||||
@@ -989,7 +990,7 @@
|
|||||||
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
||||||
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
||||||
"started": "Geschützte Sitzung gestartet.",
|
"started": "Geschützte Sitzung gestartet.",
|
||||||
"wrong_password": "Passwort flasch.",
|
"wrong_password": "Passwort falsch.",
|
||||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||||
"unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.",
|
"unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.",
|
||||||
"protecting-in-progress": "Schützen läuft: {{count}}",
|
"protecting-in-progress": "Schützen läuft: {{count}}",
|
||||||
@@ -1521,7 +1522,9 @@
|
|||||||
"window-on-top": "Dieses Fenster immer oben halten"
|
"window-on-top": "Dieses Fenster immer oben halten"
|
||||||
},
|
},
|
||||||
"note_detail": {
|
"note_detail": {
|
||||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden"
|
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden",
|
||||||
|
"printing": "Druckvorgang läuft…",
|
||||||
|
"printing_pdf": "PDF-Export läuft…"
|
||||||
},
|
},
|
||||||
"note_title": {
|
"note_title": {
|
||||||
"placeholder": "Titel der Notiz hier eingeben…"
|
"placeholder": "Titel der Notiz hier eingeben…"
|
||||||
@@ -1654,7 +1657,7 @@
|
|||||||
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
|
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
|
||||||
"cut": "Ausschneiden",
|
"cut": "Ausschneiden",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"copy-link": "Link opieren",
|
"copy-link": "Link kopieren",
|
||||||
"paste": "Einfügen",
|
"paste": "Einfügen",
|
||||||
"paste-as-plain-text": "Als unformatierten Text einfügen",
|
"paste-as-plain-text": "Als unformatierten Text einfügen",
|
||||||
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
|
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
|
||||||
@@ -2079,6 +2082,7 @@
|
|||||||
},
|
},
|
||||||
"presentation_view": {
|
"presentation_view": {
|
||||||
"edit-slide": "Folie bearbeiten",
|
"edit-slide": "Folie bearbeiten",
|
||||||
"start-presentation": "Präsentation starten"
|
"start-presentation": "Präsentation starten",
|
||||||
|
"slide-overview": "Übersicht der Folien ein-/ausblenden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@
|
|||||||
"export_status": "Export status",
|
"export_status": "Export status",
|
||||||
"export_in_progress": "Export in progress: {{progressCount}}",
|
"export_in_progress": "Export in progress: {{progressCount}}",
|
||||||
"export_finished_successfully": "Export finished successfully.",
|
"export_finished_successfully": "Export finished successfully.",
|
||||||
"format_pdf": "PDF - for printing or sharing purposes."
|
"format_pdf": "PDF - for printing or sharing purposes.",
|
||||||
|
"share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Cheatsheet",
|
"title": "Cheatsheet",
|
||||||
@@ -260,7 +261,6 @@
|
|||||||
"delete_all_revisions": "Delete all revisions of this note",
|
"delete_all_revisions": "Delete all revisions of this note",
|
||||||
"delete_all_button": "Delete all revisions",
|
"delete_all_button": "Delete all revisions",
|
||||||
"help_title": "Help on Note Revisions",
|
"help_title": "Help on Note Revisions",
|
||||||
"revision_last_edited": "This revision was last edited on {{date}}",
|
|
||||||
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
||||||
"no_revisions": "No revisions for this note yet...",
|
"no_revisions": "No revisions for this note yet...",
|
||||||
"restore_button": "Restore",
|
"restore_button": "Restore",
|
||||||
|
|||||||
@@ -259,7 +259,6 @@
|
|||||||
"delete_all_revisions": "Eliminar todas las revisiones de esta nota",
|
"delete_all_revisions": "Eliminar todas las revisiones de esta nota",
|
||||||
"delete_all_button": "Eliminar todas las revisiones",
|
"delete_all_button": "Eliminar todas las revisiones",
|
||||||
"help_title": "Ayuda sobre revisiones de notas",
|
"help_title": "Ayuda sobre revisiones de notas",
|
||||||
"revision_last_edited": "Esta revisión se editó por última vez en {{date}}",
|
|
||||||
"confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?",
|
"confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?",
|
||||||
"no_revisions": "Aún no hay revisiones para esta nota...",
|
"no_revisions": "Aún no hay revisiones para esta nota...",
|
||||||
"restore_button": "Restaurar",
|
"restore_button": "Restaurar",
|
||||||
|
|||||||
@@ -260,7 +260,6 @@
|
|||||||
"delete_all_revisions": "Supprimer toutes les versions de cette note",
|
"delete_all_revisions": "Supprimer toutes les versions de cette note",
|
||||||
"delete_all_button": "Supprimer toutes les versions",
|
"delete_all_button": "Supprimer toutes les versions",
|
||||||
"help_title": "Aide sur les versions de notes",
|
"help_title": "Aide sur les versions de notes",
|
||||||
"revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}",
|
|
||||||
"confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?",
|
"confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?",
|
||||||
"no_revisions": "Aucune version pour cette note pour l'instant...",
|
"no_revisions": "Aucune version pour cette note pour l'instant...",
|
||||||
"confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.",
|
"confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.",
|
||||||
|
|||||||
5
apps/client/src/translations/hi/translation.json
Normal file
5
apps/client/src/translations/hi/translation.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"about": {
|
||||||
|
"title": "ट्रिलियम नोट्स के बारें में"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,50 @@
|
|||||||
{}
|
{
|
||||||
|
"about": {
|
||||||
|
"title": "A Trilium Notes-ról",
|
||||||
|
"homepage": "Kezdőlap:",
|
||||||
|
"app_version": "Alkalmazás verziója:",
|
||||||
|
"db_version": "Adatbázis verzió:",
|
||||||
|
"sync_version": "Verzió szinkronizálás :",
|
||||||
|
"build_revision": "Build revízió:",
|
||||||
|
"data_directory": "Adatkönyvtár:",
|
||||||
|
"build_date": "Build dátum:"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"critical-error": {
|
||||||
|
"title": "Kritikus hiba",
|
||||||
|
"message": "Kritikus hiba történt, amely megakadályozza a kliensalkalmazás indítását:\n\n{{message}}\n\nEzt valószínűleg egy váratlan szkripthiba okozza. Próbálja meg biztonságos módban elindítani az alkalmazást, és hárítsa el a problémát."
|
||||||
|
},
|
||||||
|
"widget-error": {
|
||||||
|
"title": "Nem sikerült inicializálni egy widgetet",
|
||||||
|
"message-custom": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó egyéni widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}",
|
||||||
|
"message-unknown": "Ismeretlen widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}"
|
||||||
|
},
|
||||||
|
"bundle-error": {
|
||||||
|
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||||
|
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"add_link": {
|
||||||
|
"add_link": "Link hozzáadása",
|
||||||
|
"help_on_links": "Segítség a linkekhez",
|
||||||
|
"note": "Jegyzet",
|
||||||
|
"search_note": "név szerinti jegyzetkeresés",
|
||||||
|
"link_title_mirrors": "A link cím tükrözi a jegyzet aktuális címét",
|
||||||
|
"link_title_arbitrary": "link cím önkényesen módosítható",
|
||||||
|
"link_title": "Link cím",
|
||||||
|
"button_add_link": "Link hozzáadása"
|
||||||
|
},
|
||||||
|
"branch_prefix": {
|
||||||
|
"edit_branch_prefix": "Az elágazás előtagjának szerkesztése",
|
||||||
|
"help_on_tree_prefix": "Segítség a fa előtagján",
|
||||||
|
"prefix": "Az előtag: ",
|
||||||
|
"save": "Mentés"
|
||||||
|
},
|
||||||
|
"bulk_actions": {
|
||||||
|
"bulk_actions": "Tömeges akciók",
|
||||||
|
"affected_notes": "Érintett jegyzetek",
|
||||||
|
"labels": "Címkék",
|
||||||
|
"relations": "Kapcsolatok",
|
||||||
|
"notes": "Jegyzetek"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -867,7 +867,6 @@
|
|||||||
"delete_all_revisions": "Elimina tutte le revisioni di questa nota",
|
"delete_all_revisions": "Elimina tutte le revisioni di questa nota",
|
||||||
"delete_all_button": "Elimina tutte le revisioni",
|
"delete_all_button": "Elimina tutte le revisioni",
|
||||||
"help_title": "Aiuto sulle revisioni delle note",
|
"help_title": "Aiuto sulle revisioni delle note",
|
||||||
"revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}",
|
|
||||||
"confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
|
"confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
|
||||||
"no_revisions": "Ancora nessuna revisione per questa nota...",
|
"no_revisions": "Ancora nessuna revisione per questa nota...",
|
||||||
"restore_button": "Ripristina",
|
"restore_button": "Ripristina",
|
||||||
|
|||||||
@@ -610,7 +610,6 @@
|
|||||||
"delete_all_revisions": "このノートの変更履歴をすべて削除",
|
"delete_all_revisions": "このノートの変更履歴をすべて削除",
|
||||||
"delete_all_button": "変更履歴をすべて削除",
|
"delete_all_button": "変更履歴をすべて削除",
|
||||||
"help_title": "変更履歴のヘルプ",
|
"help_title": "変更履歴のヘルプ",
|
||||||
"revision_last_edited": "この変更は{{date}}に行われました",
|
|
||||||
"confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
|
"confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
|
||||||
"no_revisions": "このノートに変更履歴はまだありません...",
|
"no_revisions": "このノートに変更履歴はまだありません...",
|
||||||
"restore_button": "復元",
|
"restore_button": "復元",
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
"critical-error": {
|
"critical-error": {
|
||||||
"title": "Kritische Error",
|
"title": "Kritische Error",
|
||||||
"message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken."
|
"message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken."
|
||||||
|
},
|
||||||
|
"widget-error": {
|
||||||
|
"title": "Starten widget mislukt",
|
||||||
|
"message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}"
|
||||||
|
},
|
||||||
|
"bundle-error": {
|
||||||
|
"title": "Custom script laden mislukt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"add_link": {
|
"add_link": {
|
||||||
|
|||||||
@@ -912,7 +912,6 @@
|
|||||||
"delete_all_revisions": "Usuń wszystkie wersje tej notatki",
|
"delete_all_revisions": "Usuń wszystkie wersje tej notatki",
|
||||||
"delete_all_button": "Usuń wszystkie wersje",
|
"delete_all_button": "Usuń wszystkie wersje",
|
||||||
"help_title": "Pomoc dotycząca wersji notatki",
|
"help_title": "Pomoc dotycząca wersji notatki",
|
||||||
"revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}",
|
|
||||||
"confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?",
|
"confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?",
|
||||||
"no_revisions": "Brak wersji dla tej notatki...",
|
"no_revisions": "Brak wersji dla tej notatki...",
|
||||||
"restore_button": "Przywróć",
|
"restore_button": "Przywróć",
|
||||||
|
|||||||
@@ -259,7 +259,6 @@
|
|||||||
"delete_all_revisions": "Apagar todas as versões desta nota",
|
"delete_all_revisions": "Apagar todas as versões desta nota",
|
||||||
"delete_all_button": "Apagar todas as versões",
|
"delete_all_button": "Apagar todas as versões",
|
||||||
"help_title": "Ajuda sobre as versões da nota",
|
"help_title": "Ajuda sobre as versões da nota",
|
||||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
|
||||||
"confirm_delete_all": "Quer apagar todas as versões desta nota?",
|
"confirm_delete_all": "Quer apagar todas as versões desta nota?",
|
||||||
"no_revisions": "Ainda não há versões para esta nota...",
|
"no_revisions": "Ainda não há versões para esta nota...",
|
||||||
"restore_button": "Recuperar",
|
"restore_button": "Recuperar",
|
||||||
|
|||||||
@@ -415,7 +415,6 @@
|
|||||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||||
"delete_all_button": "Excluir todas as versões",
|
"delete_all_button": "Excluir todas as versões",
|
||||||
"help_title": "Ajuda sobre as versões da nota",
|
"help_title": "Ajuda sobre as versões da nota",
|
||||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
|
||||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||||
"no_revisions": "Ainda não há versões para esta nota...",
|
"no_revisions": "Ainda não há versões para esta nota...",
|
||||||
"restore_button": "Recuperar",
|
"restore_button": "Recuperar",
|
||||||
|
|||||||
@@ -1090,7 +1090,6 @@
|
|||||||
"preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
|
"preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
|
||||||
"restore_button": "Restaurează",
|
"restore_button": "Restaurează",
|
||||||
"revision_deleted": "Revizia notiței a fost ștearsă.",
|
"revision_deleted": "Revizia notiței a fost ștearsă.",
|
||||||
"revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}",
|
|
||||||
"revision_restored": "Revizia notiței a fost restaurată.",
|
"revision_restored": "Revizia notiței a fost restaurată.",
|
||||||
"revisions_deleted": "Notița reviziei a fost ștearsă.",
|
"revisions_deleted": "Notița reviziei a fost ștearsă.",
|
||||||
"maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.",
|
"maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.",
|
||||||
|
|||||||
@@ -320,7 +320,8 @@
|
|||||||
"explodeArchivesTooltip": "Если этот флажок установлен, Trilium будет читать файлы <code>.zip</code>, <code>.enex</code> и <code>.opml</code> и создавать заметки из файлов внутри этих архивов. Если флажок не установлен, Trilium будет прикреплять сами архивы к заметке.",
|
"explodeArchivesTooltip": "Если этот флажок установлен, Trilium будет читать файлы <code>.zip</code>, <code>.enex</code> и <code>.opml</code> и создавать заметки из файлов внутри этих архивов. Если флажок не установлен, Trilium будет прикреплять сами архивы к заметке.",
|
||||||
"explodeArchives": "Прочитать содержимое архивов <code>.zip</code>, <code>.enex</code> и <code>.opml</code>.",
|
"explodeArchives": "Прочитать содержимое архивов <code>.zip</code>, <code>.enex</code> и <code>.opml</code>.",
|
||||||
"shrinkImagesTooltip": "<p>Если этот параметр включен, Trilium попытается уменьшить размер импортируемых изображений путём масштабирования и оптимизации, что может повлиять на воспринимаемое качество изображения. Если этот параметр не установлен, изображения будут импортированы без изменений.</p><p>Это не относится к импорту файлов <code>.zip</code> с метаданными, поскольку предполагается, что эти файлы уже оптимизированы.</p>",
|
"shrinkImagesTooltip": "<p>Если этот параметр включен, Trilium попытается уменьшить размер импортируемых изображений путём масштабирования и оптимизации, что может повлиять на воспринимаемое качество изображения. Если этот параметр не установлен, изображения будут импортированы без изменений.</p><p>Это не относится к импорту файлов <code>.zip</code> с метаданными, поскольку предполагается, что эти файлы уже оптимизированы.</p>",
|
||||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных"
|
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных",
|
||||||
|
"importZipRecommendation": "При импорте ZIP файла иерархия заметок будет отражена в структуре папок внутри архива."
|
||||||
},
|
},
|
||||||
"markdown_import": {
|
"markdown_import": {
|
||||||
"dialog_title": "Импорт Markdown",
|
"dialog_title": "Импорт Markdown",
|
||||||
@@ -365,7 +366,6 @@
|
|||||||
"delete_all_button": "Удалить все версии",
|
"delete_all_button": "Удалить все версии",
|
||||||
"help_title": "Помощь по версиям заметок",
|
"help_title": "Помощь по версиям заметок",
|
||||||
"confirm_delete_all": "Вы хотите удалить все версии этой заметки?",
|
"confirm_delete_all": "Вы хотите удалить все версии этой заметки?",
|
||||||
"revision_last_edited": "Эта версия последний раз редактировалась {{date}}",
|
|
||||||
"confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.",
|
"confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.",
|
||||||
"confirm_delete": "Вы хотите удалить эту версию?",
|
"confirm_delete": "Вы хотите удалить эту версию?",
|
||||||
"revisions_deleted": "Версии заметки были удалены.",
|
"revisions_deleted": "Версии заметки были удалены.",
|
||||||
@@ -980,7 +980,8 @@
|
|||||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||||
"switch_to_desktop_version": "Переключиться на версию для ПК"
|
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||||
|
"new-version-available": "Доступно обновление"
|
||||||
},
|
},
|
||||||
"zpetne_odkazy": {
|
"zpetne_odkazy": {
|
||||||
"backlink": "{{count}} ссылки",
|
"backlink": "{{count}} ссылки",
|
||||||
|
|||||||
@@ -256,7 +256,6 @@
|
|||||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||||
"delete_all_button": "Obriši sve revizije",
|
"delete_all_button": "Obriši sve revizije",
|
||||||
"help_title": "Pomoć za Revizije beleški",
|
"help_title": "Pomoć za Revizije beleški",
|
||||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
|
||||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||||
"restore_button": "Vrati",
|
"restore_button": "Vrati",
|
||||||
|
|||||||
@@ -260,7 +260,6 @@
|
|||||||
"delete_all_revisions": "刪除此筆記的所有歷史版本",
|
"delete_all_revisions": "刪除此筆記的所有歷史版本",
|
||||||
"delete_all_button": "刪除所有歷史版本",
|
"delete_all_button": "刪除所有歷史版本",
|
||||||
"help_title": "關於筆記歷史版本的說明",
|
"help_title": "關於筆記歷史版本的說明",
|
||||||
"revision_last_edited": "此歷史版本上次於 {{date}} 編輯",
|
|
||||||
"confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?",
|
"confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?",
|
||||||
"no_revisions": "此筆記暫無歷史版本…",
|
"no_revisions": "此筆記暫無歷史版本…",
|
||||||
"confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。",
|
"confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。",
|
||||||
|
|||||||
@@ -309,7 +309,6 @@
|
|||||||
"delete_all_revisions": "Видалити всі версії цієї нотатки",
|
"delete_all_revisions": "Видалити всі версії цієї нотатки",
|
||||||
"delete_all_button": "Видалити всі версії",
|
"delete_all_button": "Видалити всі версії",
|
||||||
"help_title": "Довідка щодо Версій нотаток",
|
"help_title": "Довідка щодо Версій нотаток",
|
||||||
"revision_last_edited": "Цю версію востаннє редагували {{date}}",
|
|
||||||
"confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
|
"confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
|
||||||
"no_revisions": "Поки що немає версій цієї нотатки...",
|
"no_revisions": "Поки що немає версій цієї нотатки...",
|
||||||
"restore_button": "Відновити",
|
"restore_button": "Відновити",
|
||||||
|
|||||||
1
apps/client/src/types.d.ts
vendored
1
apps/client/src/types.d.ts
vendored
@@ -26,7 +26,6 @@ interface CustomGlobals {
|
|||||||
appContext: AppContext;
|
appContext: AppContext;
|
||||||
froca: Froca;
|
froca: Froca;
|
||||||
treeCache: Froca;
|
treeCache: Froca;
|
||||||
importMarkdownInline: () => Promise<unknown>;
|
|
||||||
SEARCH_HELP_TEXT: string;
|
SEARCH_HELP_TEXT: string;
|
||||||
activeDialog: JQuery<HTMLElement> | null;
|
activeDialog: JQuery<HTMLElement> | null;
|
||||||
componentId: string;
|
componentId: string;
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export default function ExportDialog() {
|
|||||||
values={[
|
values={[
|
||||||
{ value: "html", label: t("export.format_html_zip") },
|
{ value: "html", label: t("export.format_html_zip") },
|
||||||
{ value: "markdown", label: t("export.format_markdown") },
|
{ value: "markdown", label: t("export.format_markdown") },
|
||||||
{ value: "opml", label: t("export.format_opml") }
|
{ value: "opml", label: t("export.format_opml") },
|
||||||
|
{ value: "share", label: t("export.share-format") }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import utils from "../../services/utils";
|
|||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
import Button from "../react/Button";
|
import Button from "../react/Button";
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||||
|
|
||||||
interface RenderMarkdownResponse {
|
interface RenderMarkdownResponse {
|
||||||
htmlContent: string;
|
htmlContent: string;
|
||||||
@@ -14,39 +15,34 @@ interface RenderMarkdownResponse {
|
|||||||
|
|
||||||
export default function MarkdownImportDialog() {
|
export default function MarkdownImportDialog() {
|
||||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||||
const [ text, setText ] = useState("");
|
const [ text, setText ] = useState("");
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
|
|
||||||
const triggerImport = useCallback(() => {
|
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
||||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
setTextTypeWidget(textTypeWidget);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (utils.isElectron()) {
|
if (utils.isElectron()) {
|
||||||
const { clipboard } = utils.dynamicRequire("electron");
|
const { clipboard } = utils.dynamicRequire("electron");
|
||||||
const text = clipboard.readText();
|
const text = clipboard.readText();
|
||||||
|
|
||||||
convertMarkdownToHtml(text);
|
convertMarkdownToHtml(text, textTypeWidget);
|
||||||
} else {
|
} else {
|
||||||
setShown(true);
|
setShown(true);
|
||||||
}
|
}
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
useTriliumEvent("importMarkdownInline", triggerImport);
|
|
||||||
useTriliumEvent("pasteMarkdownIntoText", triggerImport);
|
|
||||||
|
|
||||||
async function sendForm() {
|
|
||||||
await convertMarkdownToHtml(text);
|
|
||||||
setText("");
|
|
||||||
setShown(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
|
className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
|
||||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />}
|
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
||||||
onShown={() => markdownImportTextArea.current?.focus()}
|
onShown={() => markdownImportTextArea.current?.focus()}
|
||||||
onHidden={() => setShown(false) }
|
onHidden={async () => {
|
||||||
|
if (textTypeWidget) {
|
||||||
|
await convertMarkdownToHtml(text, textTypeWidget);
|
||||||
|
}
|
||||||
|
setShown(false);
|
||||||
|
setText("");
|
||||||
|
}}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<p>{t("markdown_import.modal_body_text")}</p>
|
<p>{t("markdown_import.modal_body_text")}</p>
|
||||||
@@ -56,26 +52,17 @@ export default function MarkdownImportDialog() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && e.ctrlKey) {
|
if (e.key === "Enter" && e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendForm();
|
setShown(false);
|
||||||
}
|
}
|
||||||
}}></textarea>
|
}}></textarea>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertMarkdownToHtml(markdownContent: string) {
|
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
||||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||||
|
|
||||||
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
|
await textTypeWidget.addHtmlToEditor(htmlContent);
|
||||||
if (!textEditor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewFragment = textEditor.data.processor.toView(htmlContent);
|
|
||||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
|
||||||
|
|
||||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
|
||||||
textEditor.editing.view.focus();
|
|
||||||
|
|
||||||
toast.showMessage(t("markdown_import.import_success"));
|
toast.showMessage(t("markdown_import.import_success"));
|
||||||
}
|
}
|
||||||
@@ -155,6 +155,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid not showing recent notes when creating a new empty tab.
|
||||||
|
if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
return super.handleEventInChildren(name, data);
|
return super.handleEventInChildren(name, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,11 +140,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
|
|||||||
<FormList onSelect={onSelect} fullHeight>
|
<FormList onSelect={onSelect} fullHeight>
|
||||||
{revisions.map((item) =>
|
{revisions.map((item) =>
|
||||||
<FormListItem
|
<FormListItem
|
||||||
title={t("revisions.revision_last_edited", { date: item.dateLastEdited })}
|
|
||||||
value={item.revisionId}
|
value={item.revisionId}
|
||||||
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
||||||
>
|
>
|
||||||
{item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||||
</FormListItem>
|
</FormListItem>
|
||||||
)}
|
)}
|
||||||
</FormList>);
|
</FormList>);
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ const categories: Category[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const icons: Icon[] = [
|
const icons: Icon[] = [
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
slug: "empty",
|
||||||
|
category_id: 113,
|
||||||
|
type_of_icon: "REGULAR"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "child",
|
name: "child",
|
||||||
slug: "child-regular",
|
slug: "child-regular",
|
||||||
|
|||||||
@@ -56,4 +56,16 @@
|
|||||||
|
|
||||||
.note-icon-widget .icon-list span:hover {
|
.note-icon-widget .icon-list span:hover {
|
||||||
border: 1px solid var(--main-border-color);
|
border: 1px solid var(--main-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-icon-widget .icon-list span.bx-empty {
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-icon-widget .icon-list span.bx-empty::before {
|
||||||
|
display: inline-block;
|
||||||
|
content: "";
|
||||||
|
border: 1px dashed var(--muted-text-color);
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
}
|
}
|
||||||
@@ -264,7 +264,6 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-end: 5px;
|
inset-inline-end: 5px;
|
||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.style-resolver {
|
.style-resolver {
|
||||||
|
|||||||
@@ -329,6 +329,30 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addHtmlToEditor(html: string) {
|
||||||
|
await this.initialized;
|
||||||
|
|
||||||
|
const editor = this.watchdog.editor;
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
editor.model.change((writer) => {
|
||||||
|
const viewFragment = editor.data.processor.toView(html);
|
||||||
|
const modelFragment = editor.data.toModel(viewFragment);
|
||||||
|
const insertPosition = editor.model.document.selection.getLastPosition();
|
||||||
|
|
||||||
|
if (insertPosition) {
|
||||||
|
const range = editor.model.insertContent(modelFragment, insertPosition);
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
writer.setSelection(range.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.editing.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) {
|
addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) {
|
||||||
if (!this.isActive()) {
|
if (!this.isActive()) {
|
||||||
return;
|
return;
|
||||||
@@ -385,6 +409,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText });
|
this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pasteMarkdownIntoTextCommand() {
|
||||||
|
this.triggerCommand("showPasteMarkdownDialog", { textTypeWidget: this });
|
||||||
|
}
|
||||||
|
|
||||||
getSelectedText() {
|
getSelectedText() {
|
||||||
const range = this.watchdog.editor?.model.document.selection.getFirstRange();
|
const range = this.watchdog.editor?.model.document.selection.getFirstRange();
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
|||||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||||
|
|
||||||
:POWERSHELL
|
:POWERSHELL
|
||||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
||||||
GOTO END
|
GOTO END
|
||||||
|
|
||||||
:BATCH
|
:BATCH
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
|||||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||||
|
|
||||||
:POWERSHELL
|
:POWERSHELL
|
||||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
||||||
GOTO END
|
GOTO END
|
||||||
|
|
||||||
:BATCH
|
:BATCH
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
|||||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||||
|
|
||||||
:POWERSHELL
|
:POWERSHELL
|
||||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
||||||
GOTO END
|
GOTO END
|
||||||
|
|
||||||
:BATCH
|
:BATCH
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"@triliumnext/commons": "workspace:*",
|
"@triliumnext/commons": "workspace:*",
|
||||||
"@triliumnext/server": "workspace:*",
|
"@triliumnext/server": "workspace:*",
|
||||||
"copy-webpack-plugin": "13.0.1",
|
"copy-webpack-plugin": "13.0.1",
|
||||||
"electron": "38.3.0",
|
"electron": "38.4.0",
|
||||||
"@electron-forge/cli": "7.10.2",
|
"@electron-forge/cli": "7.10.2",
|
||||||
"@electron-forge/maker-deb": "7.10.2",
|
"@electron-forge/maker-deb": "7.10.2",
|
||||||
"@electron-forge/maker-dmg": "7.10.2",
|
"@electron-forge/maker-dmg": "7.10.2",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ async function main() {
|
|||||||
// Copy assets.
|
// Copy assets.
|
||||||
build.copy("src/assets", "assets/");
|
build.copy("src/assets", "assets/");
|
||||||
build.copy("/apps/server/src/assets", "assets/");
|
build.copy("/apps/server/src/assets", "assets/");
|
||||||
|
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||||
|
|
||||||
// Copy node modules dependencies
|
// Copy node modules dependencies
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/yargs": "17.0.33"
|
"@types/yargs": "17.0.34"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/main.ts",
|
"dev": "tsx src/main.ts",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"@triliumnext/desktop": "workspace:*",
|
"@triliumnext/desktop": "workspace:*",
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
"copy-webpack-plugin": "13.0.1",
|
"copy-webpack-plugin": "13.0.1",
|
||||||
"electron": "38.3.0",
|
"electron": "38.4.0",
|
||||||
"fs-extra": "11.3.2"
|
"fs-extra": "11.3.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js
|
|||||||
import debounce from "@triliumnext/client/src/services/debounce.js";
|
import debounce from "@triliumnext/client/src/services/debounce.js";
|
||||||
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
|
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
|
||||||
import cls from "@triliumnext/server/src/services/cls.js";
|
import cls from "@triliumnext/server/src/services/cls.js";
|
||||||
import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js";
|
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js";
|
||||||
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
|
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
|
||||||
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
|
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ async function setOptions() {
|
|||||||
optionsService.setOption("compressImages", "false");
|
optionsService.setOption("compressImages", "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set<string>) {
|
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {
|
||||||
const zipFilePath = "output.zip";
|
const zipFilePath = "output.zip";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.20.0-bullseye-slim AS builder
|
FROM node:24.10.0-bullseye-slim AS builder
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# Install native dependencies since we might be building cross-platform.
|
# Install native dependencies since we might be building cross-platform.
|
||||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
|||||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||||
|
|
||||||
FROM node:22.20.0-bullseye-slim
|
FROM node:24.10.0-bullseye-slim
|
||||||
# Install only runtime dependencies
|
# Install only runtime dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.20.0-alpine AS builder
|
FROM node:24.10.0-alpine AS builder
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# Install native dependencies since we might be building cross-platform.
|
# Install native dependencies since we might be building cross-platform.
|
||||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
|||||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||||
|
|
||||||
FROM node:22.20.0-alpine
|
FROM node:24.10.0-alpine
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache su-exec shadow
|
RUN apk add --no-cache su-exec shadow
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.20.0-alpine AS builder
|
FROM node:24.10.0-alpine AS builder
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# Install native dependencies since we might be building cross-platform.
|
# Install native dependencies since we might be building cross-platform.
|
||||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
|||||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||||
|
|
||||||
FROM node:22.20.0-alpine
|
FROM node:24.10.0-alpine
|
||||||
# Create a non-root user with configurable UID/GID
|
# Create a non-root user with configurable UID/GID
|
||||||
ARG USER=trilium
|
ARG USER=trilium
|
||||||
ARG UID=1001
|
ARG UID=1001
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.20.0-bullseye-slim AS builder
|
FROM node:24.10.0-bullseye-slim AS builder
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# Install native dependencies since we might be building cross-platform.
|
# Install native dependencies since we might be building cross-platform.
|
||||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
|||||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||||
|
|
||||||
FROM node:22.20.0-bullseye-slim
|
FROM node:24.10.0-bullseye-slim
|
||||||
# Create a non-root user with configurable UID/GID
|
# Create a non-root user with configurable UID/GID
|
||||||
ARG USER=trilium
|
ARG USER=trilium
|
||||||
ARG UID=1001
|
ARG UID=1001
|
||||||
|
|||||||
@@ -36,11 +36,12 @@
|
|||||||
"@triliumnext/commons": "workspace:*",
|
"@triliumnext/commons": "workspace:*",
|
||||||
"@triliumnext/express-partial-content": "workspace:*",
|
"@triliumnext/express-partial-content": "workspace:*",
|
||||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||||
"@types/archiver": "6.0.3",
|
"@triliumnext/highlightjs": "workspace:*",
|
||||||
|
"@types/archiver": "7.0.0",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cls-hooked": "4.3.9",
|
"@types/cls-hooked": "4.3.9",
|
||||||
"@types/compression": "1.8.1",
|
"@types/compression": "1.8.1",
|
||||||
"@types/cookie-parser": "1.4.9",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/debounce": "1.2.4",
|
"@types/debounce": "1.2.4",
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/escape-html": "1.0.4",
|
"@types/escape-html": "1.0.4",
|
||||||
@@ -56,18 +57,17 @@
|
|||||||
"@types/sanitize-html": "2.16.0",
|
"@types/sanitize-html": "2.16.0",
|
||||||
"@types/sax": "1.2.7",
|
"@types/sax": "1.2.7",
|
||||||
"@types/serve-favicon": "2.5.7",
|
"@types/serve-favicon": "2.5.7",
|
||||||
"@types/serve-static": "1.15.9",
|
"@types/serve-static": "2.2.0",
|
||||||
"@types/session-file-store": "1.2.5",
|
|
||||||
"@types/stream-throttle": "0.1.4",
|
"@types/stream-throttle": "0.1.4",
|
||||||
"@types/supertest": "6.0.3",
|
"@types/supertest": "6.0.3",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/tmp": "0.2.6",
|
"@types/tmp": "0.2.6",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.6",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/xml2js": "0.4.14",
|
"@types/xml2js": "0.4.14",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"async-mutex": "0.5.0",
|
"async-mutex": "0.5.0",
|
||||||
"axios": "1.12.2",
|
"axios": "1.13.0",
|
||||||
"bindings": "1.5.0",
|
"bindings": "1.5.0",
|
||||||
"bootstrap": "5.3.8",
|
"bootstrap": "5.3.8",
|
||||||
"chardet": "2.1.0",
|
"chardet": "2.1.0",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"debounce": "2.2.0",
|
"debounce": "2.2.0",
|
||||||
"debug": "4.4.3",
|
"debug": "4.4.3",
|
||||||
"ejs": "3.1.10",
|
"ejs": "3.1.10",
|
||||||
"electron": "38.3.0",
|
"electron": "38.4.0",
|
||||||
"electron-debug": "4.1.0",
|
"electron-debug": "4.1.0",
|
||||||
"electron-window-state": "5.0.3",
|
"electron-window-state": "5.0.3",
|
||||||
"escape-html": "1.0.3",
|
"escape-html": "1.0.3",
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"i18next": "25.6.0",
|
"i18next": "25.6.0",
|
||||||
"i18next-fs-backend": "2.6.0",
|
"i18next-fs-backend": "2.6.0",
|
||||||
"image-type": "6.0.0",
|
"image-type": "6.0.0",
|
||||||
"ini": "5.0.0",
|
"ini": "6.0.0",
|
||||||
"is-animated": "2.0.2",
|
"is-animated": "2.0.2",
|
||||||
"is-svg": "6.1.0",
|
"is-svg": "6.1.0",
|
||||||
"jimp": "1.6.0",
|
"jimp": "1.6.0",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"multer": "2.0.2",
|
"multer": "2.0.2",
|
||||||
"normalize-strings": "1.1.1",
|
"normalize-strings": "1.1.1",
|
||||||
"ollama": "0.6.0",
|
"ollama": "0.6.0",
|
||||||
"openai": "6.6.0",
|
"openai": "6.7.0",
|
||||||
"rand-token": "1.0.1",
|
"rand-token": "1.0.1",
|
||||||
"safe-compare": "1.1.4",
|
"safe-compare": "1.1.4",
|
||||||
"sanitize-filename": "1.6.3",
|
"sanitize-filename": "1.6.3",
|
||||||
@@ -125,9 +125,9 @@
|
|||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"time2fa": "1.4.2",
|
"time2fa": "1.4.2",
|
||||||
"tmp": "0.2.5",
|
"tmp": "0.2.5",
|
||||||
"turndown": "7.2.1",
|
"turndown": "7.2.2",
|
||||||
"unescape": "1.0.1",
|
"unescape": "1.0.1",
|
||||||
"vite": "7.1.11",
|
"vite": "7.1.12",
|
||||||
"ws": "8.18.3",
|
"ws": "8.18.3",
|
||||||
"xml2js": "0.6.2",
|
"xml2js": "0.6.2",
|
||||||
"yauzl": "3.2.0"
|
"yauzl": "3.2.0"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ async function main() {
|
|||||||
|
|
||||||
// Copy assets
|
// Copy assets
|
||||||
build.copy("src/assets", "assets/");
|
build.copy("src/assets", "assets/");
|
||||||
|
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||||
|
|
||||||
// Copy node modules dependencies
|
// Copy node modules dependencies
|
||||||
|
|||||||
@@ -146,218 +146,9 @@ CREATE INDEX IDX_notes_blobId on notes (blobId);
|
|||||||
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
|
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
|
||||||
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
|
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
|
||||||
|
|
||||||
-- Strategic Performance Indexes from migration 234
|
|
||||||
-- NOTES TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_notes_search_composite
|
|
||||||
ON notes (isDeleted, type, mime, dateModified DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_notes_metadata_covering
|
|
||||||
ON notes (noteId, isDeleted, type, mime, title, dateModified, isProtected);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_notes_protected_deleted
|
|
||||||
ON notes (isProtected, isDeleted)
|
|
||||||
WHERE isProtected = 1;
|
|
||||||
|
|
||||||
-- BRANCHES TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_branches_tree_traversal
|
|
||||||
ON branches (parentNoteId, isDeleted, notePosition);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_branches_covering
|
|
||||||
ON branches (noteId, parentNoteId, isDeleted, notePosition, prefix);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_branches_note_parents
|
|
||||||
ON branches (noteId, isDeleted)
|
|
||||||
WHERE isDeleted = 0;
|
|
||||||
|
|
||||||
-- ATTRIBUTES TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_attributes_search_composite
|
|
||||||
ON attributes (name, value, isDeleted);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_attributes_covering
|
|
||||||
ON attributes (noteId, name, value, type, isDeleted, position);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_attributes_inheritable
|
|
||||||
ON attributes (isInheritable, isDeleted)
|
|
||||||
WHERE isInheritable = 1 AND isDeleted = 0;
|
|
||||||
|
|
||||||
CREATE INDEX IDX_attributes_labels
|
|
||||||
ON attributes (type, name, value)
|
|
||||||
WHERE type = 'label' AND isDeleted = 0;
|
|
||||||
|
|
||||||
CREATE INDEX IDX_attributes_relations
|
|
||||||
ON attributes (type, name, value)
|
|
||||||
WHERE type = 'relation' AND isDeleted = 0;
|
|
||||||
|
|
||||||
-- BLOBS TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_blobs_content_size
|
|
||||||
ON blobs (blobId, LENGTH(content));
|
|
||||||
|
|
||||||
-- ATTACHMENTS TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_attachments_composite
|
|
||||||
ON attachments (ownerId, role, isDeleted, position);
|
|
||||||
|
|
||||||
-- REVISIONS TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_revisions_note_date
|
|
||||||
ON revisions (noteId, utcDateCreated DESC);
|
|
||||||
|
|
||||||
-- ENTITY_CHANGES TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_entity_changes_sync
|
|
||||||
ON entity_changes (isSynced, utcDateChanged);
|
|
||||||
|
|
||||||
CREATE INDEX IDX_entity_changes_component
|
|
||||||
ON entity_changes (componentId, utcDateChanged DESC);
|
|
||||||
|
|
||||||
-- RECENT_NOTES TABLE INDEXES
|
|
||||||
CREATE INDEX IDX_recent_notes_date
|
|
||||||
ON recent_notes (utcDateCreated DESC);
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
expires INTEGER
|
expires INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
-- FTS5 Full-Text Search Support
|
|
||||||
-- Create FTS5 virtual table for full-text searching
|
|
||||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
|
||||||
noteId UNINDEXED,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
tokenize = 'porter unicode61'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS table synchronized with notes
|
|
||||||
-- IMPORTANT: These triggers must handle all SQL operations including:
|
|
||||||
-- - Regular INSERT/UPDATE/DELETE
|
|
||||||
-- - INSERT OR REPLACE
|
|
||||||
-- - INSERT ... ON CONFLICT ... DO UPDATE (upsert)
|
|
||||||
-- - Cases where notes are created before blobs (import scenarios)
|
|
||||||
|
|
||||||
-- Trigger for INSERT operations on notes
|
|
||||||
-- Handles: INSERT, INSERT OR REPLACE, INSERT OR IGNORE, and the INSERT part of upsert
|
|
||||||
CREATE TRIGGER notes_fts_insert
|
|
||||||
AFTER INSERT ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0
|
|
||||||
BEGIN
|
|
||||||
-- First delete any existing FTS entry (in case of INSERT OR REPLACE)
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Then insert the new entry, using LEFT JOIN to handle missing blobs
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for UPDATE operations on notes table
|
|
||||||
-- Handles: Regular UPDATE and the UPDATE part of upsert (ON CONFLICT DO UPDATE)
|
|
||||||
-- Fires for ANY update to searchable notes to ensure FTS stays in sync
|
|
||||||
CREATE TRIGGER notes_fts_update
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
-- Fire on any change, not just specific columns, to handle all upsert scenarios
|
|
||||||
BEGIN
|
|
||||||
-- Always delete the old entry
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Insert new entry if note is not deleted and not protected
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
|
||||||
WHERE NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for UPDATE operations on blobs
|
|
||||||
-- Handles: Regular UPDATE and the UPDATE part of upsert (ON CONFLICT DO UPDATE)
|
|
||||||
-- IMPORTANT: Uses INSERT OR REPLACE for efficiency with deduplicated blobs
|
|
||||||
CREATE TRIGGER notes_fts_blob_update
|
|
||||||
AFTER UPDATE ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Use INSERT OR REPLACE for atomic update of all notes sharing this blob
|
|
||||||
-- This is more efficient than DELETE + INSERT when many notes share the same blob
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
NEW.content
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for DELETE operations
|
|
||||||
CREATE TRIGGER notes_fts_delete
|
|
||||||
AFTER DELETE ON notes
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for soft delete (isDeleted = 1)
|
|
||||||
CREATE TRIGGER notes_fts_soft_delete
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isDeleted = 0 AND NEW.isDeleted = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for notes becoming protected
|
|
||||||
-- Remove from FTS when a note becomes protected
|
|
||||||
CREATE TRIGGER notes_fts_protect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 0 AND NEW.isProtected = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for notes becoming unprotected
|
|
||||||
-- Add to FTS when a note becomes unprotected (if eligible)
|
|
||||||
CREATE TRIGGER notes_fts_unprotect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 1 AND NEW.isProtected = 0
|
|
||||||
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '')
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- Trigger for INSERT operations on blobs
|
|
||||||
-- Handles: INSERT, INSERT OR REPLACE, and the INSERT part of upsert
|
|
||||||
-- Updates all notes that reference this blob (common during import and deduplication)
|
|
||||||
CREATE TRIGGER notes_fts_blob_insert
|
|
||||||
AFTER INSERT ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Use INSERT OR REPLACE to handle both new and existing FTS entries
|
|
||||||
-- This is crucial for blob deduplication where multiple notes may already
|
|
||||||
-- exist that reference this blob before the blob itself is created
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
NEW.content
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0;
|
|
||||||
END;
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
"show-backend-log": "فتح صفحة \"سجل الخلفية\"",
|
"show-backend-log": "فتح صفحة \"سجل الخلفية\"",
|
||||||
"edit-readonly-note": "تعديل ملاحظة القراءة فقط",
|
"edit-readonly-note": "تعديل ملاحظة القراءة فقط",
|
||||||
"attributes-labels-and-relations": "سمات ( تسميات و علاقات)",
|
"attributes-labels-and-relations": "سمات ( تسميات و علاقات)",
|
||||||
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة"
|
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة",
|
||||||
|
"show-help": "فتح دليل التعليمات",
|
||||||
|
"copy-without-formatting": "نسخ النص المحدد بدون تنسيق"
|
||||||
},
|
},
|
||||||
"setup_sync-from-server": {
|
"setup_sync-from-server": {
|
||||||
"note": "ملاحظة:",
|
"note": "ملاحظة:",
|
||||||
@@ -196,7 +198,8 @@
|
|||||||
"expand": "توسيع",
|
"expand": "توسيع",
|
||||||
"site-theme": "المظهر العام للموقع",
|
"site-theme": "المظهر العام للموقع",
|
||||||
"image_alt": "صورة المقال",
|
"image_alt": "صورة المقال",
|
||||||
"on-this-page": "في هذه السفحة"
|
"on-this-page": "في هذه السفحة",
|
||||||
|
"last-updated": "اخر تحديث {{- date}}"
|
||||||
},
|
},
|
||||||
"hidden_subtree_templates": {
|
"hidden_subtree_templates": {
|
||||||
"description": "الوصف",
|
"description": "الوصف",
|
||||||
@@ -258,7 +261,8 @@
|
|||||||
},
|
},
|
||||||
"share_page": {
|
"share_page": {
|
||||||
"parent": "الأصل:",
|
"parent": "الأصل:",
|
||||||
"child-notes": "الملاحظات الفرعية:"
|
"child-notes": "الملاحظات الفرعية:",
|
||||||
|
"no-content": "لاتحتوي هذة الملاحظة على محتوى."
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"duplicate-note-suffix": "(مكرر)",
|
"duplicate-note-suffix": "(مكرر)",
|
||||||
@@ -339,7 +343,24 @@
|
|||||||
"toggle-system-tray-icon": "تبديل ايقونة علبة النظام",
|
"toggle-system-tray-icon": "تبديل ايقونة علبة النظام",
|
||||||
"switch-to-first-tab": "التبديل الى التبويب الاول",
|
"switch-to-first-tab": "التبديل الى التبويب الاول",
|
||||||
"follow-link-under-cursor": "اتبع الرابط اسفل المؤشر",
|
"follow-link-under-cursor": "اتبع الرابط اسفل المؤشر",
|
||||||
"paste-markdown-into-text": "لصق نص بتنسبق Markdown"
|
"paste-markdown-into-text": "لصق نص بتنسبق Markdown",
|
||||||
|
"move-note-up-in-hierarchy": "نقل الملاحظة للاعلى في الهيكل",
|
||||||
|
"move-note-down-in-hierarchy": "نقل الملاحظة للاسفل في الهيكل",
|
||||||
|
"select-all-notes-in-parent": "تحديد جميع الملاحظات التابعة للملاحظة الاصل",
|
||||||
|
"add-note-above-to-selection": "اضافة ملاحظة فوق الملاحظة المحددة",
|
||||||
|
"add-note-below-to-selection": "اصافة ملاحظة اسفل الملاحظة المحددة",
|
||||||
|
"add-include-note-to-text": "اضافة الملاحظة الى النص",
|
||||||
|
"toggle-ribbon-tab-image-properties": "اظهار/ اخفاء صورة علامة التبويب في الشريط.",
|
||||||
|
"toggle-ribbon-tab-classic-editor": "عرض/اخفاء تبويب المحور الكلاسيكي",
|
||||||
|
"toggle-ribbon-tab-basic-properties": "عرض/اخفاء تبويب الخصائص الاساسية",
|
||||||
|
"toggle-ribbon-tab-book-properties": "عرض/اخفاء تبويب خصائص الدفتر",
|
||||||
|
"toggle-ribbon-tab-file-properties": "عرض/ادخفاء تبويب خصائص الملف",
|
||||||
|
"toggle-ribbon-tab-owned-attributes": "عرض/اخفاء تبويب المميزات المملوكة",
|
||||||
|
"toggle-ribbon-tab-inherited-attributes": "عرض/اخفاء تبويب السمات الموروثة",
|
||||||
|
"toggle-ribbon-tab-promoted-attributes": "عرض/ اخفاء تبويب السمات المعززة",
|
||||||
|
"toggle-ribbon-tab-note-map": "عرض/اخفاء تبويب خريطة الملاحظات",
|
||||||
|
"toggle-ribbon-tab-similar-notes": "عرض/اخفاء شريط الملاحظات المشابهة",
|
||||||
|
"export-active-note-as-pdf": "تصدير الملاحظة النشطة كملفPDF"
|
||||||
},
|
},
|
||||||
"share_404": {
|
"share_404": {
|
||||||
"title": "غير موجود",
|
"title": "غير موجود",
|
||||||
@@ -348,6 +369,7 @@
|
|||||||
"weekdayNumber": "الاسبوع{رقم الاسيوع}",
|
"weekdayNumber": "الاسبوع{رقم الاسيوع}",
|
||||||
"quarterNumber": "الربع {رقم الربع}",
|
"quarterNumber": "الربع {رقم الربع}",
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"export_filter": "مستند PDF (.pdf)"
|
"export_filter": "مستند PDF (.pdf)",
|
||||||
|
"unable-to-export-title": "تعذر التصدير كملف PDF"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,8 @@
|
|||||||
"export_filter": "PDF Dokument (*.pdf)",
|
"export_filter": "PDF Dokument (*.pdf)",
|
||||||
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
|
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
|
||||||
"unable-to-export-title": "Export als PDF fehlgeschlagen",
|
"unable-to-export-title": "Export als PDF fehlgeschlagen",
|
||||||
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen."
|
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen.",
|
||||||
|
"unable-to-print": "Notiz kann nicht gedruckt werden"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"tooltip": "Trilium Notes",
|
"tooltip": "Trilium Notes",
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
"edit-note-title": "Ugrás fáról a jegyzet részleteihez és a cím szerkesztése",
|
"edit-note-title": "Ugrás fáról a jegyzet részleteihez és a cím szerkesztése",
|
||||||
"edit-branch-prefix": "\"Ág címjelzésének szerkesztése\" ablak mutatása",
|
"edit-branch-prefix": "\"Ág címjelzésének szerkesztése\" ablak mutatása",
|
||||||
"clone-notes-to": "Kijelölt jegyzetek másolása",
|
"clone-notes-to": "Kijelölt jegyzetek másolása",
|
||||||
"move-notes-to": "Kijelölt jegyzetek elhelyzése"
|
"move-notes-to": "Kijelölt jegyzetek elhelyzése",
|
||||||
|
"note-clipboard": "Megjegyzés vágólap",
|
||||||
|
"copy-notes-to-clipboard": "Másolja a kiválasztott jegyzeteket a vágólapra",
|
||||||
|
"paste-notes-from-clipboard": "A vágólapról szóló jegyzetek beillesztése aktív jegyzetbe",
|
||||||
|
"cut-notes-to-clipboard": "A kiválasztott jegyzetek kivágása a vágólapra",
|
||||||
|
"select-all-notes-in-parent": "Válassza ki az összes jegyzetet az aktuális jegyzetszintről",
|
||||||
|
"activate-next-tab": "Aktiválja a jobb oldali fület",
|
||||||
|
"activate-previous-tab": "Aktiválja a lapot a bal oldalon",
|
||||||
|
"open-new-window": "Nyiss új üres ablakot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getParentNote() {
|
||||||
|
return this.parentNote;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BBranch;
|
export default BBranch;
|
||||||
|
|||||||
@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
|||||||
return childBranches;
|
return childBranches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get encodedTitle() {
|
||||||
|
return encodeURIComponent(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
getVisibleChildBranches() {
|
||||||
|
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
|
||||||
|
}
|
||||||
|
|
||||||
|
getVisibleChildNotes() {
|
||||||
|
return this.getVisibleChildBranches().map((branch) => branch.getNote());
|
||||||
|
}
|
||||||
|
|
||||||
|
hasVisibleChildren() {
|
||||||
|
return this.getVisibleChildNotes().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get shareId() {
|
||||||
|
return this.noteId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
||||||
* @param attributeId - the id of the attribute owned by this note
|
* @param attributeId - the id of the attribute owned by this note
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
|
|||||||
import type { NoteParams } from "../services/note-interface.js";
|
import type { NoteParams } from "../services/note-interface.js";
|
||||||
import type { SearchParams } from "../services/search/services/types.js";
|
import type { SearchParams } from "../services/search/services/types.js";
|
||||||
import type { ValidatorMap } from "./etapi-interface.js";
|
import type { ValidatorMap } from "./etapi-interface.js";
|
||||||
|
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||||
|
|
||||||
function register(router: Router) {
|
function register(router: Router) {
|
||||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||||
@@ -149,8 +150,8 @@ function register(router: Router) {
|
|||||||
const note = eu.getAndCheckNote(req.params.noteId);
|
const note = eu.getAndCheckNote(req.params.noteId);
|
||||||
const format = req.query.format || "html";
|
const format = req.query.format || "html";
|
||||||
|
|
||||||
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
|
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
|
||||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
|
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||||
@@ -159,7 +160,7 @@ function register(router: Router) {
|
|||||||
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
||||||
const branch = note.getParentBranches()[0];
|
const branch = note.getParentBranches()[0];
|
||||||
|
|
||||||
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
|
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration to add FTS5 full-text search support and strategic performance indexes
|
|
||||||
*
|
|
||||||
* This migration:
|
|
||||||
* 1. Creates an FTS5 virtual table for full-text searching
|
|
||||||
* 2. Populates it with existing note content
|
|
||||||
* 3. Creates triggers to keep the FTS table synchronized with note changes
|
|
||||||
* 4. Adds strategic composite and covering indexes for improved query performance
|
|
||||||
* 5. Optimizes common query patterns identified through performance analysis
|
|
||||||
*/
|
|
||||||
|
|
||||||
import sql from "../services/sql.js";
|
|
||||||
import log from "../services/log.js";
|
|
||||||
|
|
||||||
export default function addFTS5SearchAndPerformanceIndexes() {
|
|
||||||
log.info("Starting FTS5 and performance optimization migration...");
|
|
||||||
|
|
||||||
// Part 1: FTS5 Setup
|
|
||||||
log.info("Creating FTS5 virtual table for full-text search...");
|
|
||||||
|
|
||||||
// Create FTS5 virtual table
|
|
||||||
// We store noteId, title, and content for searching
|
|
||||||
// The 'tokenize' option uses porter stemming for better search results
|
|
||||||
sql.executeScript(`
|
|
||||||
-- Drop existing FTS table if it exists (for re-running migration in dev)
|
|
||||||
DROP TABLE IF EXISTS notes_fts;
|
|
||||||
|
|
||||||
-- Create FTS5 virtual table
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
||||||
noteId UNINDEXED,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
tokenize = 'porter unicode61'
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info("Populating FTS5 table with existing note content...");
|
|
||||||
|
|
||||||
// Populate the FTS table with existing notes
|
|
||||||
// We only index text-based note types that contain searchable content
|
|
||||||
const batchSize = 100;
|
|
||||||
let processedCount = 0;
|
|
||||||
let hasError = false;
|
|
||||||
|
|
||||||
// Wrap entire population process in a transaction for consistency
|
|
||||||
// If any error occurs, the entire population will be rolled back
|
|
||||||
try {
|
|
||||||
sql.transactional(() => {
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const notes = sql.getRows<{
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
content: string | null;
|
|
||||||
}>(`
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0 -- Skip protected notes - they require special handling
|
|
||||||
ORDER BY n.noteId
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`, [batchSize, offset]);
|
|
||||||
|
|
||||||
if (notes.length === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const note of notes) {
|
|
||||||
if (note.content) {
|
|
||||||
// Process content based on type (simplified for migration)
|
|
||||||
let processedContent = note.content;
|
|
||||||
|
|
||||||
// For HTML content, we'll strip tags in the search service
|
|
||||||
// For now, just insert the raw content
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`, [note.noteId, note.title, processedContent]);
|
|
||||||
processedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += batchSize;
|
|
||||||
|
|
||||||
if (processedCount % 1000 === 0) {
|
|
||||||
log.info(`Processed ${processedCount} notes for FTS indexing...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
hasError = true;
|
|
||||||
log.error(`Failed to populate FTS index. Rolling back... ${error}`);
|
|
||||||
// Clean up partial data if transaction failed
|
|
||||||
try {
|
|
||||||
sql.execute("DELETE FROM notes_fts");
|
|
||||||
} catch (cleanupError) {
|
|
||||||
log.error(`Failed to clean up FTS table after error: ${cleanupError}`);
|
|
||||||
}
|
|
||||||
throw new Error(`FTS5 migration failed during population: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`Completed FTS indexing of ${processedCount} notes`);
|
|
||||||
|
|
||||||
// Create triggers to keep FTS table synchronized
|
|
||||||
log.info("Creating FTS synchronization triggers...");
|
|
||||||
|
|
||||||
// Drop all existing triggers first to ensure clean state
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_insert`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_update`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_delete`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_soft_delete`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_blob_insert`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_blob_update`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_protect`);
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS notes_fts_unprotect`);
|
|
||||||
|
|
||||||
// Create improved triggers that handle all SQL operations properly
|
|
||||||
// including INSERT OR REPLACE and INSERT ... ON CONFLICT ... DO UPDATE (upsert)
|
|
||||||
|
|
||||||
// Trigger for INSERT operations on notes
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_insert
|
|
||||||
AFTER INSERT ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0
|
|
||||||
BEGIN
|
|
||||||
-- First delete any existing FTS entry (in case of INSERT OR REPLACE)
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Then insert the new entry, using LEFT JOIN to handle missing blobs
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for UPDATE operations on notes table
|
|
||||||
// Fires for ANY update to searchable notes to ensure FTS stays in sync
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_update
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
-- Fire on any change, not just specific columns, to handle all upsert scenarios
|
|
||||||
BEGIN
|
|
||||||
-- Always delete the old entry
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Insert new entry if note is not deleted and not protected
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
|
||||||
WHERE NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for DELETE operations on notes
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_delete
|
|
||||||
AFTER DELETE ON notes
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for soft delete (isDeleted = 1)
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_soft_delete
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isDeleted = 0 AND NEW.isDeleted = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for notes becoming protected
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_protect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 0 AND NEW.isProtected = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for notes becoming unprotected
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_unprotect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 1 AND NEW.isProtected = 0
|
|
||||||
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, '')
|
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for INSERT operations on blobs
|
|
||||||
// Uses INSERT OR REPLACE for efficiency with deduplicated blobs
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_blob_insert
|
|
||||||
AFTER INSERT ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Use INSERT OR REPLACE for atomic update
|
|
||||||
-- This handles the case where FTS entries may already exist
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
NEW.content
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for UPDATE operations on blobs
|
|
||||||
// Uses INSERT OR REPLACE for efficiency
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER notes_fts_blob_update
|
|
||||||
AFTER UPDATE ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Use INSERT OR REPLACE for atomic update
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
NEW.content
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info("FTS5 setup completed successfully");
|
|
||||||
|
|
||||||
// Final cleanup: ensure all eligible notes are indexed
|
|
||||||
// This catches any edge cases where notes might have been missed
|
|
||||||
log.info("Running final FTS index cleanup...");
|
|
||||||
|
|
||||||
// First check for missing notes
|
|
||||||
const missingCount = sql.getValue<number>(`
|
|
||||||
SELECT COUNT(*) FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
|
||||||
`) || 0;
|
|
||||||
|
|
||||||
if (missingCount > 0) {
|
|
||||||
// Insert missing notes
|
|
||||||
sql.execute(`
|
|
||||||
WITH missing_notes AS (
|
|
||||||
SELECT n.noteId, n.title, b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
|
||||||
)
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT noteId, title, content FROM missing_notes
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanupCount = missingCount;
|
|
||||||
|
|
||||||
if (cleanupCount && cleanupCount > 0) {
|
|
||||||
log.info(`Indexed ${cleanupCount} additional notes during cleanup`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Part 2: Strategic Performance Indexes
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
log.info("Adding strategic performance indexes...");
|
|
||||||
const startTime = Date.now();
|
|
||||||
const indexesCreated: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ========================================
|
|
||||||
// NOTES TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for common search filters
|
|
||||||
log.info("Creating composite index on notes table for search filters...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_notes_search_composite;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_notes_search_composite
|
|
||||||
ON notes (isDeleted, type, mime, dateModified DESC);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_notes_search_composite");
|
|
||||||
|
|
||||||
// Covering index for note metadata queries
|
|
||||||
log.info("Creating covering index for note metadata...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_notes_metadata_covering;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_notes_metadata_covering
|
|
||||||
ON notes (noteId, isDeleted, type, mime, title, dateModified, isProtected);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_notes_metadata_covering");
|
|
||||||
|
|
||||||
// Index for protected notes filtering
|
|
||||||
log.info("Creating index for protected notes...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_notes_protected_deleted;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_notes_protected_deleted
|
|
||||||
ON notes (isProtected, isDeleted)
|
|
||||||
WHERE isProtected = 1;
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_notes_protected_deleted");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// BRANCHES TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for tree traversal
|
|
||||||
log.info("Creating composite index on branches for tree traversal...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_branches_tree_traversal;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_branches_tree_traversal
|
|
||||||
ON branches (parentNoteId, isDeleted, notePosition);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_branches_tree_traversal");
|
|
||||||
|
|
||||||
// Covering index for branch queries
|
|
||||||
log.info("Creating covering index for branch queries...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_branches_covering;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_branches_covering
|
|
||||||
ON branches (noteId, parentNoteId, isDeleted, notePosition, prefix);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_branches_covering");
|
|
||||||
|
|
||||||
// Index for finding all parents of a note
|
|
||||||
log.info("Creating index for reverse tree lookup...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_branches_note_parents;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_branches_note_parents
|
|
||||||
ON branches (noteId, isDeleted)
|
|
||||||
WHERE isDeleted = 0;
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_branches_note_parents");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ATTRIBUTES TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for attribute searches
|
|
||||||
log.info("Creating composite index on attributes for search...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attributes_search_composite;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attributes_search_composite
|
|
||||||
ON attributes (name, value, isDeleted);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attributes_search_composite");
|
|
||||||
|
|
||||||
// Covering index for attribute queries
|
|
||||||
log.info("Creating covering index for attribute queries...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attributes_covering;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attributes_covering
|
|
||||||
ON attributes (noteId, name, value, type, isDeleted, position);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attributes_covering");
|
|
||||||
|
|
||||||
// Index for inherited attributes
|
|
||||||
log.info("Creating index for inherited attributes...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attributes_inheritable;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attributes_inheritable
|
|
||||||
ON attributes (isInheritable, isDeleted)
|
|
||||||
WHERE isInheritable = 1 AND isDeleted = 0;
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attributes_inheritable");
|
|
||||||
|
|
||||||
// Index for specific attribute types
|
|
||||||
log.info("Creating index for label attributes...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attributes_labels;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attributes_labels
|
|
||||||
ON attributes (type, name, value)
|
|
||||||
WHERE type = 'label' AND isDeleted = 0;
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attributes_labels");
|
|
||||||
|
|
||||||
log.info("Creating index for relation attributes...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attributes_relations;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attributes_relations
|
|
||||||
ON attributes (type, name, value)
|
|
||||||
WHERE type = 'relation' AND isDeleted = 0;
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attributes_relations");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// BLOBS TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Index for blob content size filtering
|
|
||||||
log.info("Creating index for blob content size...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_blobs_content_size;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_blobs_content_size
|
|
||||||
ON blobs (blobId, LENGTH(content));
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_blobs_content_size");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ATTACHMENTS TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for attachment queries
|
|
||||||
log.info("Creating composite index for attachments...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_attachments_composite;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_attachments_composite
|
|
||||||
ON attachments (ownerId, role, isDeleted, position);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_attachments_composite");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// REVISIONS TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for revision queries
|
|
||||||
log.info("Creating composite index for revisions...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_revisions_note_date;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_revisions_note_date
|
|
||||||
ON revisions (noteId, utcDateCreated DESC);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_revisions_note_date");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ENTITY_CHANGES TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Composite index for sync operations
|
|
||||||
log.info("Creating composite index for entity changes sync...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_entity_changes_sync;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_entity_changes_sync
|
|
||||||
ON entity_changes (isSynced, utcDateChanged);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_entity_changes_sync");
|
|
||||||
|
|
||||||
// Index for component-based queries
|
|
||||||
log.info("Creating index for component-based entity change queries...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_entity_changes_component;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_entity_changes_component
|
|
||||||
ON entity_changes (componentId, utcDateChanged DESC);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_entity_changes_component");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// RECENT_NOTES TABLE INDEXES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Index for recent notes ordering
|
|
||||||
log.info("Creating index for recent notes...");
|
|
||||||
sql.executeScript(`
|
|
||||||
DROP INDEX IF EXISTS IDX_recent_notes_date;
|
|
||||||
CREATE INDEX IF NOT EXISTS IDX_recent_notes_date
|
|
||||||
ON recent_notes (utcDateCreated DESC);
|
|
||||||
`);
|
|
||||||
indexesCreated.push("IDX_recent_notes_date");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ANALYZE TABLES FOR QUERY PLANNER
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
log.info("Running ANALYZE to update SQLite query planner statistics...");
|
|
||||||
sql.executeScript(`
|
|
||||||
ANALYZE notes;
|
|
||||||
ANALYZE branches;
|
|
||||||
ANALYZE attributes;
|
|
||||||
ANALYZE blobs;
|
|
||||||
ANALYZE attachments;
|
|
||||||
ANALYZE revisions;
|
|
||||||
ANALYZE entity_changes;
|
|
||||||
ANALYZE recent_notes;
|
|
||||||
ANALYZE notes_fts;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const endTime = Date.now();
|
|
||||||
const duration = endTime - startTime;
|
|
||||||
|
|
||||||
log.info(`Performance index creation completed in ${duration}ms`);
|
|
||||||
log.info(`Created ${indexesCreated.length} indexes: ${indexesCreated.join(", ")}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Error creating performance indexes: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("FTS5 and performance optimization migration completed successfully");
|
|
||||||
}
|
|
||||||
@@ -1,826 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration to add SQLite native search support with normalized text tables
|
|
||||||
*
|
|
||||||
* This migration implements Phase 1 of the SQLite-based search plan:
|
|
||||||
* 1. Creates note_search_content table with normalized text columns
|
|
||||||
* 2. Creates note_tokens table for word-level token storage
|
|
||||||
* 3. Adds necessary indexes for optimization
|
|
||||||
* 4. Creates triggers to keep tables synchronized with note updates
|
|
||||||
* 5. Populates tables with existing note data in batches
|
|
||||||
*
|
|
||||||
* This provides 100% accurate search results with 10-30x performance improvement
|
|
||||||
* over TypeScript-based search, without the complexity of trigrams.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import sql from "../services/sql.js";
|
|
||||||
import log from "../services/log.js";
|
|
||||||
import { normalize as utilsNormalize, stripTags } from "../services/utils.js";
|
|
||||||
import { getSqliteFunctionsService } from "../services/search/sqlite_functions.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the existing normalize function from utils.ts for consistency
|
|
||||||
* This ensures all normalization throughout the codebase is identical
|
|
||||||
*/
|
|
||||||
function normalizeText(text: string): string {
|
|
||||||
if (!text) return '';
|
|
||||||
return utilsNormalize(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenizes text into individual words for token-based searching
|
|
||||||
* Handles punctuation and special characters appropriately
|
|
||||||
*/
|
|
||||||
function tokenize(text: string): string[] {
|
|
||||||
if (!text) return [];
|
|
||||||
|
|
||||||
// Split on word boundaries, filter out empty tokens
|
|
||||||
// This regex splits on spaces, punctuation, and other non-word characters
|
|
||||||
// but preserves apostrophes within words (e.g., "don't", "it's")
|
|
||||||
const tokens = text
|
|
||||||
.split(/[\s\n\r\t,;.!?()[\]{}"'`~@#$%^&*+=|\\/<>:_-]+/)
|
|
||||||
.filter(token => token.length > 0)
|
|
||||||
.map(token => token.toLowerCase());
|
|
||||||
|
|
||||||
// Also split on camelCase and snake_case boundaries for code content
|
|
||||||
const expandedTokens: string[] = [];
|
|
||||||
for (const token of tokens) {
|
|
||||||
// Add the original token
|
|
||||||
expandedTokens.push(token);
|
|
||||||
|
|
||||||
// Split camelCase (e.g., "getUserName" -> ["get", "User", "Name"])
|
|
||||||
const camelCaseParts = token.split(/(?=[A-Z])/);
|
|
||||||
if (camelCaseParts.length > 1) {
|
|
||||||
expandedTokens.push(...camelCaseParts.map(p => p.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split snake_case (e.g., "user_name" -> ["user", "name"])
|
|
||||||
const snakeCaseParts = token.split('_');
|
|
||||||
if (snakeCaseParts.length > 1) {
|
|
||||||
expandedTokens.push(...snakeCaseParts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates and return
|
|
||||||
return Array.from(new Set(expandedTokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strips HTML tags from content for text-only indexing
|
|
||||||
* Uses the utils stripTags function for consistency
|
|
||||||
*/
|
|
||||||
function stripHtmlTags(html: string): string {
|
|
||||||
if (!html) return '';
|
|
||||||
|
|
||||||
// Remove script and style content entirely first
|
|
||||||
let text = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
||||||
text = text.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
|
||||||
|
|
||||||
// Use utils stripTags for consistency
|
|
||||||
text = stripTags(text);
|
|
||||||
|
|
||||||
// Decode HTML entities
|
|
||||||
text = text.replace(/ /g, ' ');
|
|
||||||
text = text.replace(/</g, '<');
|
|
||||||
text = text.replace(/>/g, '>');
|
|
||||||
text = text.replace(/&/g, '&');
|
|
||||||
text = text.replace(/"/g, '"');
|
|
||||||
text = text.replace(/'/g, "'");
|
|
||||||
|
|
||||||
// Normalize whitespace
|
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function sqliteNativeSearch() {
|
|
||||||
log.info("Starting SQLite native search migration...");
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Wrap entire migration in a transaction for atomicity
|
|
||||||
sql.transactional(() => {
|
|
||||||
try {
|
|
||||||
// Register custom SQL functions first so they can be used in triggers
|
|
||||||
registerCustomFunctions();
|
|
||||||
|
|
||||||
// Create the search tables and indexes
|
|
||||||
createSearchTables();
|
|
||||||
|
|
||||||
// Create triggers to keep tables synchronized (before population)
|
|
||||||
createSearchTriggers();
|
|
||||||
|
|
||||||
// Populate the tables with existing note data
|
|
||||||
populateSearchTables();
|
|
||||||
|
|
||||||
// Run final verification and optimization
|
|
||||||
finalizeSearchSetup();
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
log.info(`SQLite native search migration completed successfully in ${duration}ms`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`SQLite native search migration failed: ${error}`);
|
|
||||||
// Transaction will automatically rollback on error
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSearchTables() {
|
|
||||||
log.info("Creating search content and token tables...");
|
|
||||||
|
|
||||||
// Drop existing tables if they exist (for re-running migration in dev)
|
|
||||||
sql.execute("DROP TABLE IF EXISTS note_search_content");
|
|
||||||
sql.execute("DROP TABLE IF EXISTS note_tokens");
|
|
||||||
|
|
||||||
// Create the main search content table
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TABLE note_search_content (
|
|
||||||
noteId TEXT PRIMARY KEY,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
title_normalized TEXT NOT NULL,
|
|
||||||
content_normalized TEXT NOT NULL,
|
|
||||||
full_text_normalized TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create the token table for word-level operations
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TABLE note_tokens (
|
|
||||||
noteId TEXT NOT NULL,
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
token_normalized TEXT NOT NULL,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
source TEXT NOT NULL CHECK(source IN ('title', 'content')),
|
|
||||||
PRIMARY KEY (noteId, position, source)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes for search optimization
|
|
||||||
log.info("Creating search indexes...");
|
|
||||||
|
|
||||||
// Consolidated indexes - removed redundancy between COLLATE NOCASE and plain indexes
|
|
||||||
// Using COLLATE NOCASE for case-insensitive searches
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_search_title_normalized
|
|
||||||
ON note_search_content(title_normalized COLLATE NOCASE)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_search_content_normalized
|
|
||||||
ON note_search_content(content_normalized COLLATE NOCASE)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_search_full_text
|
|
||||||
ON note_search_content(full_text_normalized COLLATE NOCASE)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Token indexes - consolidated to avoid redundancy
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_tokens_normalized
|
|
||||||
ON note_tokens(token_normalized COLLATE NOCASE)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_tokens_noteId
|
|
||||||
ON note_tokens(noteId)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Composite index for token searches with source
|
|
||||||
sql.execute(`
|
|
||||||
CREATE INDEX idx_tokens_source_normalized
|
|
||||||
ON note_tokens(source, token_normalized COLLATE NOCASE)
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info("Search tables and indexes created successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateSearchTables() {
|
|
||||||
log.info("Populating search tables with existing note content...");
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
let offset = 0;
|
|
||||||
let totalProcessed = 0;
|
|
||||||
let totalTokens = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const notes = sql.getRows<{
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
mime: string;
|
|
||||||
content: string | null;
|
|
||||||
}>(`
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
n.type,
|
|
||||||
n.mime,
|
|
||||||
b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
ORDER BY n.noteId
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`, [batchSize, offset]);
|
|
||||||
|
|
||||||
if (notes.length === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process batch of notes
|
|
||||||
for (const note of notes) {
|
|
||||||
try {
|
|
||||||
// Process content based on type
|
|
||||||
let processedContent = note.content || '';
|
|
||||||
|
|
||||||
// Strip HTML for text notes
|
|
||||||
if (note.type === 'text' && note.mime === 'text/html') {
|
|
||||||
processedContent = stripHtmlTags(processedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize text for searching using the utils normalize function
|
|
||||||
const titleNorm = normalizeText(note.title);
|
|
||||||
const contentNorm = normalizeText(processedContent);
|
|
||||||
const fullTextNorm = titleNorm + ' ' + contentNorm;
|
|
||||||
|
|
||||||
// Insert into search content table
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`, [
|
|
||||||
note.noteId,
|
|
||||||
note.title,
|
|
||||||
processedContent,
|
|
||||||
titleNorm,
|
|
||||||
contentNorm,
|
|
||||||
fullTextNorm
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Tokenize title and content separately to track source
|
|
||||||
const titleTokens = tokenize(note.title);
|
|
||||||
const contentTokens = tokenize(processedContent);
|
|
||||||
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
// Insert title tokens
|
|
||||||
for (const token of titleTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'title')
|
|
||||||
`, [note.noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
totalTokens++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert content tokens with unique positions
|
|
||||||
for (const token of contentTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'content')
|
|
||||||
`, [note.noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
totalTokens++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalProcessed++;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to index note ${note.noteId}: ${error}`);
|
|
||||||
// Continue with other notes even if one fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += batchSize;
|
|
||||||
|
|
||||||
if (totalProcessed % 1000 === 0) {
|
|
||||||
log.info(`Processed ${totalProcessed} notes, ${totalTokens} tokens for search indexing...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`Completed indexing ${totalProcessed} notes with ${totalTokens} total tokens`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSearchTriggers() {
|
|
||||||
log.info("Creating triggers to keep search tables synchronized...");
|
|
||||||
|
|
||||||
// Drop existing triggers if they exist
|
|
||||||
const triggers = [
|
|
||||||
'note_search_insert',
|
|
||||||
'note_search_update',
|
|
||||||
'note_search_delete',
|
|
||||||
'note_search_soft_delete',
|
|
||||||
'note_search_undelete',
|
|
||||||
'note_search_protect',
|
|
||||||
'note_search_unprotect',
|
|
||||||
'note_search_blob_insert',
|
|
||||||
'note_search_blob_update'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const trigger of triggers) {
|
|
||||||
sql.execute(`DROP TRIGGER IF EXISTS ${trigger}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger for INSERT operations on notes - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_insert
|
|
||||||
AFTER INSERT ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0
|
|
||||||
BEGIN
|
|
||||||
-- Delete any existing entries (for INSERT OR REPLACE)
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Insert basic content with title only (content will be populated by blob trigger)
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
VALUES (
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
'',
|
|
||||||
LOWER(NEW.title),
|
|
||||||
'',
|
|
||||||
LOWER(NEW.title)
|
|
||||||
);
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for UPDATE operations on notes - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_update
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
BEGIN
|
|
||||||
-- Always delete the old entries
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
-- Re-insert if note is not deleted and not protected
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, ''),
|
|
||||||
LOWER(NEW.title),
|
|
||||||
LOWER(COALESCE(b.content, '')),
|
|
||||||
LOWER(NEW.title || ' ' || COALESCE(b.content, ''))
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
|
||||||
WHERE n.noteId = NEW.noteId
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
AND NEW.isProtected = 0;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for DELETE operations on notes
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_delete
|
|
||||||
AFTER DELETE ON notes
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM note_search_content WHERE noteId = OLD.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = OLD.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for soft delete (isDeleted = 1)
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_soft_delete
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isDeleted = 0 AND NEW.isDeleted = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for undelete (isDeleted = 0) - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_undelete
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isDeleted = 1 AND NEW.isDeleted = 0
|
|
||||||
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isProtected = 0
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, ''),
|
|
||||||
LOWER(NEW.title),
|
|
||||||
LOWER(COALESCE(b.content, '')),
|
|
||||||
LOWER(NEW.title || ' ' || COALESCE(b.content, ''))
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
|
||||||
WHERE n.noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for notes becoming protected
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_protect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 0 AND NEW.isProtected = 1
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for notes becoming unprotected - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_unprotect
|
|
||||||
AFTER UPDATE ON notes
|
|
||||||
WHEN OLD.isProtected = 1 AND NEW.isProtected = 0
|
|
||||||
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND NEW.isDeleted = 0
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM note_search_content WHERE noteId = NEW.noteId;
|
|
||||||
DELETE FROM note_tokens WHERE noteId = NEW.noteId;
|
|
||||||
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
SELECT
|
|
||||||
NEW.noteId,
|
|
||||||
NEW.title,
|
|
||||||
COALESCE(b.content, ''),
|
|
||||||
LOWER(NEW.title),
|
|
||||||
LOWER(COALESCE(b.content, '')),
|
|
||||||
LOWER(NEW.title || ' ' || COALESCE(b.content, ''))
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
|
||||||
WHERE n.noteId = NEW.noteId;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for INSERT operations on blobs - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_blob_insert
|
|
||||||
AFTER INSERT ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Update search content for all notes that reference this blob
|
|
||||||
UPDATE note_search_content
|
|
||||||
SET content = NEW.content,
|
|
||||||
content_normalized = LOWER(NEW.content),
|
|
||||||
full_text_normalized = title_normalized || ' ' || LOWER(NEW.content)
|
|
||||||
WHERE noteId IN (
|
|
||||||
SELECT n.noteId
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Clear tokens for affected notes (will be repopulated by post-processing)
|
|
||||||
DELETE FROM note_tokens
|
|
||||||
WHERE noteId IN (
|
|
||||||
SELECT n.noteId
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
);
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Trigger for UPDATE operations on blobs - simplified version
|
|
||||||
sql.execute(`
|
|
||||||
CREATE TRIGGER note_search_blob_update
|
|
||||||
AFTER UPDATE ON blobs
|
|
||||||
BEGIN
|
|
||||||
-- Update search content for all notes that reference this blob
|
|
||||||
UPDATE note_search_content
|
|
||||||
SET content = NEW.content,
|
|
||||||
content_normalized = LOWER(NEW.content),
|
|
||||||
full_text_normalized = title_normalized || ' ' || LOWER(NEW.content)
|
|
||||||
WHERE noteId IN (
|
|
||||||
SELECT n.noteId
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Clear tokens for affected notes (will be repopulated by post-processing)
|
|
||||||
DELETE FROM note_tokens
|
|
||||||
WHERE noteId IN (
|
|
||||||
SELECT n.noteId
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.blobId = NEW.blobId
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
);
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info("Search synchronization triggers created successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerCustomFunctions() {
|
|
||||||
log.info("Registering custom SQL functions for search operations...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the database connection to register functions
|
|
||||||
const db = sql.getDbConnection();
|
|
||||||
|
|
||||||
// Use the centralized SQLite functions service
|
|
||||||
const functionsService = getSqliteFunctionsService();
|
|
||||||
|
|
||||||
// Register functions if not already registered
|
|
||||||
if (!functionsService.isRegistered()) {
|
|
||||||
const success = functionsService.registerFunctions(db);
|
|
||||||
if (success) {
|
|
||||||
log.info("Custom SQL functions registered successfully via service");
|
|
||||||
} else {
|
|
||||||
log.info("Custom SQL functions registration failed - using basic SQLite functions only");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("Custom SQL functions already registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register migration-specific helper function for tokenization
|
|
||||||
db.function('tokenize_for_migration', {
|
|
||||||
deterministic: true,
|
|
||||||
varargs: false
|
|
||||||
}, (text: string | null) => {
|
|
||||||
if (!text) return '';
|
|
||||||
// Return as JSON array string for SQL processing
|
|
||||||
return JSON.stringify(tokenize(text));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.info(`Could not register custom SQL functions (will use basic SQLite functions): ${error}`);
|
|
||||||
// This is not critical - the migration will work with basic SQLite functions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populates tokens for a specific note
|
|
||||||
* This is called outside of triggers to avoid complex SQL within trigger constraints
|
|
||||||
*/
|
|
||||||
function populateNoteTokens(noteId: string): number {
|
|
||||||
try {
|
|
||||||
// Get the note's search content
|
|
||||||
const noteData = sql.getRow<{
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
}>(`
|
|
||||||
SELECT title, content
|
|
||||||
FROM note_search_content
|
|
||||||
WHERE noteId = ?
|
|
||||||
`, [noteId]);
|
|
||||||
|
|
||||||
if (!noteData) return 0;
|
|
||||||
|
|
||||||
// Clear existing tokens for this note
|
|
||||||
sql.execute(`DELETE FROM note_tokens WHERE noteId = ?`, [noteId]);
|
|
||||||
|
|
||||||
// Tokenize title and content
|
|
||||||
const titleTokens = tokenize(noteData.title);
|
|
||||||
const contentTokens = tokenize(noteData.content);
|
|
||||||
|
|
||||||
let position = 0;
|
|
||||||
let tokenCount = 0;
|
|
||||||
|
|
||||||
// Insert title tokens
|
|
||||||
for (const token of titleTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'title')
|
|
||||||
`, [noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
tokenCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert content tokens
|
|
||||||
for (const token of contentTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'content')
|
|
||||||
`, [noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
tokenCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenCount;
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Error populating tokens for note ${noteId}: ${error}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populates tokens for multiple notes affected by blob operations
|
|
||||||
* This handles cases where blob triggers can affect multiple notes
|
|
||||||
*/
|
|
||||||
function populateBlobAffectedTokens(blobId: string): void {
|
|
||||||
try {
|
|
||||||
// Find all notes that reference this blob and need token updates
|
|
||||||
const affectedNoteIds = sql.getColumn<string>(`
|
|
||||||
SELECT DISTINCT n.noteId
|
|
||||||
FROM notes n
|
|
||||||
INNER JOIN note_search_content nsc ON n.noteId = nsc.noteId
|
|
||||||
WHERE n.blobId = ?
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
`, [blobId]);
|
|
||||||
|
|
||||||
if (affectedNoteIds.length === 0) return;
|
|
||||||
|
|
||||||
log.info(`Updating tokens for ${affectedNoteIds.length} notes affected by blob ${blobId}`);
|
|
||||||
|
|
||||||
let totalTokens = 0;
|
|
||||||
for (const noteId of affectedNoteIds) {
|
|
||||||
const tokenCount = populateNoteTokens(noteId);
|
|
||||||
totalTokens += tokenCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`Updated ${totalTokens} tokens for blob-affected notes`);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Error populating blob-affected tokens for blob ${blobId}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateAllTokens() {
|
|
||||||
log.info("Populating tokens for all search content...");
|
|
||||||
|
|
||||||
// Clear existing tokens first to ensure clean state
|
|
||||||
sql.execute("DELETE FROM note_tokens");
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
let offset = 0;
|
|
||||||
let totalProcessed = 0;
|
|
||||||
let totalTokens = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const notes = sql.getRows<{
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
}>(`
|
|
||||||
SELECT noteId, title, content
|
|
||||||
FROM note_search_content
|
|
||||||
ORDER BY noteId
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`, [batchSize, offset]);
|
|
||||||
|
|
||||||
if (notes.length === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const note of notes) {
|
|
||||||
try {
|
|
||||||
// Tokenize title and content
|
|
||||||
const titleTokens = tokenize(note.title);
|
|
||||||
const contentTokens = tokenize(note.content);
|
|
||||||
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
// Insert title tokens
|
|
||||||
for (const token of titleTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'title')
|
|
||||||
`, [note.noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
totalTokens++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert content tokens with continuous position numbering
|
|
||||||
for (const token of contentTokens) {
|
|
||||||
if (token.length > 0) {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT OR IGNORE INTO note_tokens
|
|
||||||
(noteId, token, token_normalized, position, source)
|
|
||||||
VALUES (?, ?, ?, ?, 'content')
|
|
||||||
`, [note.noteId, token, normalizeText(token), position]);
|
|
||||||
position++;
|
|
||||||
totalTokens++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalProcessed++;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to tokenize note ${note.noteId}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += batchSize;
|
|
||||||
|
|
||||||
if (totalProcessed % 1000 === 0) {
|
|
||||||
log.info(`Processed ${totalProcessed} notes, ${totalTokens} tokens so far...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`Token population completed: ${totalProcessed} notes processed, ${totalTokens} total tokens`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeSearchSetup() {
|
|
||||||
log.info("Running final verification and optimization...");
|
|
||||||
|
|
||||||
// Check for missing notes that should be indexed
|
|
||||||
const missingCount = sql.getValue<number>(`
|
|
||||||
SELECT COUNT(*) FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM note_search_content WHERE noteId = n.noteId)
|
|
||||||
`) || 0;
|
|
||||||
|
|
||||||
if (missingCount > 0) {
|
|
||||||
log.info(`Found ${missingCount} notes that are missing from search index`);
|
|
||||||
|
|
||||||
// Index missing notes using basic SQLite functions
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO note_search_content
|
|
||||||
(noteId, title, content, title_normalized, content_normalized, full_text_normalized)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
COALESCE(b.content, ''),
|
|
||||||
LOWER(n.title),
|
|
||||||
LOWER(COALESCE(b.content, '')),
|
|
||||||
LOWER(n.title || ' ' || COALESCE(b.content, ''))
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM note_search_content WHERE noteId = n.noteId)
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info(`Indexed ${missingCount} missing notes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate tokens for all existing content (including any missing notes we just added)
|
|
||||||
populateAllTokens();
|
|
||||||
|
|
||||||
// Verify table creation
|
|
||||||
const tables = sql.getColumn<string>(`
|
|
||||||
SELECT name FROM sqlite_master
|
|
||||||
WHERE type = 'table'
|
|
||||||
AND name IN ('note_search_content', 'note_tokens')
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (tables.length !== 2) {
|
|
||||||
throw new Error("Search tables were not created properly");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check row counts
|
|
||||||
const searchContentCount = sql.getValue<number>("SELECT COUNT(*) FROM note_search_content") || 0;
|
|
||||||
const tokenCount = sql.getValue<number>("SELECT COUNT(*) FROM note_tokens") || 0;
|
|
||||||
|
|
||||||
log.info(`Search content table has ${searchContentCount} entries`);
|
|
||||||
log.info(`Token table has ${tokenCount} entries`);
|
|
||||||
|
|
||||||
// Run ANALYZE to update SQLite query planner statistics
|
|
||||||
log.info("Updating SQLite statistics for query optimization...");
|
|
||||||
sql.execute("ANALYZE note_search_content");
|
|
||||||
sql.execute("ANALYZE note_tokens");
|
|
||||||
|
|
||||||
// Verify indexes were created
|
|
||||||
const indexes = sql.getColumn<string>(`
|
|
||||||
SELECT name FROM sqlite_master
|
|
||||||
WHERE type = 'index'
|
|
||||||
AND tbl_name IN ('note_search_content', 'note_tokens')
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info(`Created ${indexes.length} indexes for search optimization`);
|
|
||||||
|
|
||||||
log.info("Search setup finalization completed");
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,6 @@
|
|||||||
|
|
||||||
// Migrations should be kept in descending order, so the latest migration is first.
|
// Migrations should be kept in descending order, so the latest migration is first.
|
||||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||||
// Add SQLite native search with normalized text tables
|
|
||||||
{
|
|
||||||
version: 235,
|
|
||||||
module: async () => import("./0235__sqlite_native_search.js")
|
|
||||||
},
|
|
||||||
// Add FTS5 full-text search support and strategic performance indexes
|
|
||||||
{
|
|
||||||
version: 234,
|
|
||||||
module: async () => import("./0234__add_fts5_search.js")
|
|
||||||
},
|
|
||||||
// Migrate geo map to collection
|
// Migrate geo map to collection
|
||||||
{
|
{
|
||||||
version: 233,
|
version: 233,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
|
|||||||
const taskContext = new TaskContext(taskId, "export", null);
|
const taskContext = new TaskContext(taskId, "export", null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "subtree" && (format === "html" || format === "markdown")) {
|
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
|
||||||
zipExportService.exportToZip(taskContext, branch, format, res);
|
zipExportService.exportToZip(taskContext, branch, format, res);
|
||||||
} else if (type === "single") {
|
} else if (type === "single") {
|
||||||
if (format !== "html" && format !== "markdown") {
|
if (format !== "html" && format !== "markdown") {
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ async function importNotesToBranch(req: Request) {
|
|||||||
// import has deactivated note events so becca is not updated, instead we force it to reload
|
// import has deactivated note events so becca is not updated, instead we force it to reload
|
||||||
beccaLoader.load();
|
beccaLoader.load();
|
||||||
|
|
||||||
// FTS indexing is now handled directly during note creation when entity events are disabled
|
|
||||||
// This ensures all imported notes are immediately searchable without needing a separate sync step
|
|
||||||
|
|
||||||
return note.getPojo();
|
return note.getPojo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) {
|
|||||||
AND (noteId NOT LIKE '_%')
|
AND (noteId NOT LIKE '_%')
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT noteId FROM revisions
|
SELECT noteId FROM revisions
|
||||||
WHERE revisions.dateLastEdited LIKE :date
|
WHERE revisions.dateCreated LIKE :date
|
||||||
)
|
)
|
||||||
ORDER BY isDeleted
|
ORDER BY isDeleted
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import cls from "../../services/cls.js";
|
|||||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||||
import ValidationError from "../../errors/validation_error.js";
|
import ValidationError from "../../errors/validation_error.js";
|
||||||
import type SearchResult from "../../services/search/search_result.js";
|
import type SearchResult from "../../services/search/search_result.js";
|
||||||
import ftsSearchService from "../../services/search/fts_search.js";
|
|
||||||
import log from "../../services/log.js";
|
|
||||||
|
|
||||||
function searchFromNote(req: Request): SearchNoteResult {
|
function searchFromNote(req: Request): SearchNoteResult {
|
||||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||||
@@ -131,86 +129,11 @@ function searchTemplates() {
|
|||||||
.map((note) => note.noteId);
|
.map((note) => note.noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Syncs missing notes to the FTS index
|
|
||||||
* This endpoint is useful for maintenance or after imports where FTS triggers might not have fired
|
|
||||||
*/
|
|
||||||
function syncFtsIndex(req: Request) {
|
|
||||||
try {
|
|
||||||
const noteIds = req.body?.noteIds;
|
|
||||||
|
|
||||||
log.info(`FTS sync requested for ${noteIds?.length || 'all'} notes`);
|
|
||||||
|
|
||||||
const syncedCount = ftsSearchService.syncMissingNotes(noteIds);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
syncedCount,
|
|
||||||
message: syncedCount > 0
|
|
||||||
? `Successfully synced ${syncedCount} notes to FTS index`
|
|
||||||
: 'FTS index is already up to date'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`FTS sync failed: ${error}`);
|
|
||||||
throw new ValidationError(`Failed to sync FTS index: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds the entire FTS index from scratch
|
|
||||||
* This is a more intensive operation that should be used sparingly
|
|
||||||
*/
|
|
||||||
function rebuildFtsIndex() {
|
|
||||||
try {
|
|
||||||
log.info('FTS index rebuild requested');
|
|
||||||
|
|
||||||
ftsSearchService.rebuildIndex();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'FTS index rebuild completed successfully'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`FTS rebuild failed: ${error}`);
|
|
||||||
throw new ValidationError(`Failed to rebuild FTS index: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets statistics about the FTS index
|
|
||||||
*/
|
|
||||||
function getFtsIndexStats() {
|
|
||||||
try {
|
|
||||||
const stats = ftsSearchService.getIndexStats();
|
|
||||||
|
|
||||||
// Get count of notes that should be indexed
|
|
||||||
const eligibleNotesCount = searchService.searchNotes('', {
|
|
||||||
includeArchivedNotes: false,
|
|
||||||
ignoreHoistedNote: true
|
|
||||||
}).filter(note =>
|
|
||||||
['text', 'code', 'mermaid', 'canvas', 'mindMap'].includes(note.type) &&
|
|
||||||
!note.isProtected
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...stats,
|
|
||||||
eligibleNotesCount,
|
|
||||||
missingFromIndex: Math.max(0, eligibleNotesCount - stats.totalDocuments)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to get FTS stats: ${error}`);
|
|
||||||
throw new ValidationError(`Failed to get FTS index statistics: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
searchFromNote,
|
searchFromNote,
|
||||||
searchAndExecute,
|
searchAndExecute,
|
||||||
getRelatedNotes,
|
getRelatedNotes,
|
||||||
quickSearch,
|
quickSearch,
|
||||||
search,
|
search,
|
||||||
searchTemplates,
|
searchTemplates
|
||||||
syncFtsIndex,
|
|
||||||
rebuildFtsIndex,
|
|
||||||
getFtsIndexStats
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* API endpoints for search administration and monitoring
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router } from "express";
|
|
||||||
import performanceMonitor from "../../services/search/performance_monitor.js";
|
|
||||||
import abTestingService from "../../services/search/ab_testing.js";
|
|
||||||
import { SQLiteSearchService } from "../../services/search/sqlite_search_service.js";
|
|
||||||
import optionService from "../../services/options.js";
|
|
||||||
import sql from "../../services/sql.js";
|
|
||||||
import log from "../../services/log.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get search performance metrics
|
|
||||||
*/
|
|
||||||
router.get("/api/search-admin/metrics", (req, res) => {
|
|
||||||
const metrics = {
|
|
||||||
recent: performanceMonitor.getRecentMetrics(100),
|
|
||||||
averages: {
|
|
||||||
typescript: performanceMonitor.getAverageMetrics("typescript"),
|
|
||||||
sqlite: performanceMonitor.getAverageMetrics("sqlite")
|
|
||||||
},
|
|
||||||
comparison: performanceMonitor.compareBackends()
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(metrics);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get A/B testing results
|
|
||||||
*/
|
|
||||||
router.get("/api/search-admin/ab-tests", (req, res) => {
|
|
||||||
const results = {
|
|
||||||
summary: abTestingService.getSummary(),
|
|
||||||
recent: abTestingService.getRecentResults(50)
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(results);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current search configuration
|
|
||||||
*/
|
|
||||||
router.get("/api/search-admin/config", (req, res) => {
|
|
||||||
const config = {
|
|
||||||
backend: optionService.getOption("searchBackend"),
|
|
||||||
sqliteEnabled: optionService.getOptionBool("searchSqliteEnabled"),
|
|
||||||
performanceLogging: optionService.getOptionBool("searchSqlitePerformanceLogging"),
|
|
||||||
maxMemory: optionService.getOptionInt("searchSqliteMaxMemory"),
|
|
||||||
batchSize: optionService.getOptionInt("searchSqliteBatchSize"),
|
|
||||||
autoRebuild: optionService.getOptionBool("searchSqliteAutoRebuild")
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(config);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update search configuration
|
|
||||||
*/
|
|
||||||
router.put("/api/search-admin/config", (req, res) => {
|
|
||||||
try {
|
|
||||||
const { backend, sqliteEnabled, performanceLogging, maxMemory, batchSize, autoRebuild } = req.body;
|
|
||||||
|
|
||||||
if (backend !== undefined) {
|
|
||||||
if (!["typescript", "sqlite"].includes(backend)) {
|
|
||||||
return res.status(400).json({ error: "Invalid backend. Must be 'typescript' or 'sqlite'" });
|
|
||||||
}
|
|
||||||
optionService.setOption("searchBackend", backend);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sqliteEnabled !== undefined) {
|
|
||||||
optionService.setOption("searchSqliteEnabled", sqliteEnabled ? "true" : "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (performanceLogging !== undefined) {
|
|
||||||
optionService.setOption("searchSqlitePerformanceLogging", performanceLogging ? "true" : "false");
|
|
||||||
performanceMonitor.updateSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxMemory !== undefined) {
|
|
||||||
if (maxMemory < 1048576 || maxMemory > 1073741824) { // 1MB to 1GB
|
|
||||||
return res.status(400).json({ error: "Max memory must be between 1MB and 1GB" });
|
|
||||||
}
|
|
||||||
optionService.setOption("searchSqliteMaxMemory", maxMemory.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchSize !== undefined) {
|
|
||||||
if (batchSize < 10 || batchSize > 1000) {
|
|
||||||
return res.status(400).json({ error: "Batch size must be between 10 and 1000" });
|
|
||||||
}
|
|
||||||
optionService.setOption("searchSqliteBatchSize", batchSize.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoRebuild !== undefined) {
|
|
||||||
optionService.setOption("searchSqliteAutoRebuild", autoRebuild ? "true" : "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, message: "Configuration updated successfully" });
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Failed to update search configuration: ${error}`);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SQLite search index status
|
|
||||||
*/
|
|
||||||
router.get("/api/search-admin/sqlite/status", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const service = SQLiteSearchService.getInstance();
|
|
||||||
const status = await service.getIndexStatus();
|
|
||||||
|
|
||||||
// Add table sizes
|
|
||||||
const tableSizes = sql.getRows<{ name: string; size: number }>(`
|
|
||||||
SELECT
|
|
||||||
name,
|
|
||||||
(SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=m.name) as size
|
|
||||||
FROM sqlite_master m
|
|
||||||
WHERE type='table' AND name IN ('note_search_content', 'note_tokens', 'notes_fts', 'notes_fts_data', 'notes_fts_idx', 'notes_fts_content')
|
|
||||||
`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
...status,
|
|
||||||
tables: tableSizes
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Failed to get SQLite search status: ${error}`);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild SQLite search index
|
|
||||||
*/
|
|
||||||
router.post("/api/search-admin/sqlite/rebuild", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { force = false } = req.body;
|
|
||||||
|
|
||||||
log.info("Starting SQLite search index rebuild via API");
|
|
||||||
|
|
||||||
const service = SQLiteSearchService.getInstance();
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
await service.rebuildIndex(force);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
log.info(`SQLite search index rebuild completed in ${duration}ms`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "Index rebuilt successfully",
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Failed to rebuild SQLite search index: ${error}`);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear SQLite search index
|
|
||||||
*/
|
|
||||||
router.delete("/api/search-admin/sqlite/index", async (req, res) => {
|
|
||||||
try {
|
|
||||||
log.info("Clearing SQLite search index via API");
|
|
||||||
|
|
||||||
const service = SQLiteSearchService.getInstance();
|
|
||||||
service.clearIndex();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "Index cleared successfully"
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Failed to clear SQLite search index: ${error}`);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset performance metrics
|
|
||||||
*/
|
|
||||||
router.delete("/api/search-admin/metrics", (req, res) => {
|
|
||||||
performanceMonitor.reset();
|
|
||||||
res.json({ success: true, message: "Metrics reset successfully" });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset A/B test results
|
|
||||||
*/
|
|
||||||
router.delete("/api/search-admin/ab-tests", (req, res) => {
|
|
||||||
abTestingService.reset();
|
|
||||||
res.json({ success: true, message: "A/B test results reset successfully" });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set A/B testing sample rate
|
|
||||||
*/
|
|
||||||
router.put("/api/search-admin/ab-tests/sample-rate", (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rate } = req.body;
|
|
||||||
|
|
||||||
if (rate === undefined || rate < 0 || rate > 1) {
|
|
||||||
return res.status(400).json({ error: "Sample rate must be between 0 and 1" });
|
|
||||||
}
|
|
||||||
|
|
||||||
abTestingService.setSampleRate(rate);
|
|
||||||
res.json({ success: true, message: `Sample rate set to ${rate * 100}%` });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test search with both backends for comparison
|
|
||||||
*/
|
|
||||||
router.post("/api/search-admin/test", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { query } = req.body;
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return res.status(400).json({ error: "Query is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await abTestingService.runComparison(query, {});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return res.json({
|
|
||||||
message: "Test not run (sampling or disabled)",
|
|
||||||
query
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Search test failed: ${error}`);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -44,6 +44,7 @@ async function register(app: express.Application) {
|
|||||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||||
}
|
}
|
||||||
|
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
|
||||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||||
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
|
|||||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShareThemeAssetDir() {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const srcRoot = path.join(__dirname, "..", "..");
|
||||||
|
return path.join(srcRoot, "../../packages/share-theme/dist");
|
||||||
|
} else {
|
||||||
|
const resourceDir = getResourceDir();
|
||||||
|
return path.join(resourceDir, "share-theme/assets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
register
|
register
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function createUploadMiddleware(): RequestHandler {
|
|||||||
|
|
||||||
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
||||||
multerOptions.limits = {
|
multerOptions.limits = {
|
||||||
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024 * 1024
|
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import scriptRoute from "./api/script.js";
|
|||||||
import senderRoute from "./api/sender.js";
|
import senderRoute from "./api/sender.js";
|
||||||
import filesRoute from "./api/files.js";
|
import filesRoute from "./api/files.js";
|
||||||
import searchRoute from "./api/search.js";
|
import searchRoute from "./api/search.js";
|
||||||
import searchAdminRoute from "./api/search_admin.js";
|
|
||||||
import bulkActionRoute from "./api/bulk_action.js";
|
import bulkActionRoute from "./api/bulk_action.js";
|
||||||
import specialNotesRoute from "./api/special_notes.js";
|
import specialNotesRoute from "./api/special_notes.js";
|
||||||
import noteMapRoute from "./api/note_map.js";
|
import noteMapRoute from "./api/note_map.js";
|
||||||
@@ -261,9 +260,6 @@ function register(app: express.Application) {
|
|||||||
apiRoute(GET, "/api/search/:searchString", searchRoute.search);
|
apiRoute(GET, "/api/search/:searchString", searchRoute.search);
|
||||||
apiRoute(GET, "/api/search-templates", searchRoute.searchTemplates);
|
apiRoute(GET, "/api/search-templates", searchRoute.searchTemplates);
|
||||||
|
|
||||||
// Search administration routes
|
|
||||||
app.use(searchAdminRoute);
|
|
||||||
|
|
||||||
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
|
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
|
||||||
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
|
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
|
|||||||
import dataDir from "./data_dir.js";
|
import dataDir from "./data_dir.js";
|
||||||
import { AppInfo } from "@triliumnext/commons";
|
import { AppInfo } from "@triliumnext/commons";
|
||||||
|
|
||||||
const APP_DB_VERSION = 235;
|
const APP_DB_VERSION = 233;
|
||||||
const SYNC_VERSION = 36;
|
const SYNC_VERSION = 36;
|
||||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
|
|||||||
import type BBranch from "../../becca/entities/bbranch.js";
|
import type BBranch from "../../becca/entities/bbranch.js";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type BNote from "../../becca/entities/bnote.js";
|
import type BNote from "../../becca/entities/bnote.js";
|
||||||
|
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||||
|
|
||||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
|
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||||
const note = branch.getNote();
|
const note = branch.getNote();
|
||||||
|
|
||||||
if (note.type === "image" || note.type === "file") {
|
if (note.type === "image" || note.type === "file") {
|
||||||
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
|||||||
taskContext.taskSucceeded(null);
|
taskContext.taskSucceeded(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
|
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||||
let payload, extension, mime;
|
let payload, extension, mime;
|
||||||
|
|
||||||
if (typeof content !== "string") {
|
if (typeof content !== "string") {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import html from "html";
|
|
||||||
import dateUtils from "../date_utils.js";
|
import dateUtils from "../date_utils.js";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import mimeTypes from "mime-types";
|
|
||||||
import mdService from "./markdown.js";
|
|
||||||
import packageInfo from "../../../package.json" with { type: "json" };
|
import packageInfo from "../../../package.json" with { type: "json" };
|
||||||
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
|
import { getContentDisposition } from "../utils.js";
|
||||||
import protectedSessionService from "../protected_session.js";
|
import protectedSessionService from "../protected_session.js";
|
||||||
import sanitize from "sanitize-filename";
|
import sanitize from "sanitize-filename";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
|
|||||||
import type NoteMeta from "../meta/note_meta.js";
|
import type NoteMeta from "../meta/note_meta.js";
|
||||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||||
import type BBranch from "../../becca/entities/bbranch.js";
|
import BBranch from "../../becca/entities/bbranch.js";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||||
|
import HtmlExportProvider from "./zip/html.js";
|
||||||
|
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||||
|
import MarkdownExportProvider from "./zip/markdown.js";
|
||||||
|
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||||
|
import type BNote from "../../becca/entities/bnote.js";
|
||||||
|
import { NoteType } from "@triliumnext/commons";
|
||||||
|
|
||||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||||
|
if (!["html", "markdown", "share"].includes(format)) {
|
||||||
export interface AdvancedExportOptions {
|
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
|
||||||
/**
|
|
||||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
|
||||||
*/
|
|
||||||
skipHtmlTemplate?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
|
||||||
*
|
|
||||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
|
||||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
|
||||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
|
||||||
*/
|
|
||||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
|
||||||
if (!["html", "markdown"].includes(format)) {
|
|
||||||
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const archive = archiver("zip", {
|
const archive = archiver("zip", {
|
||||||
zlib: { level: 9 } // Sets the compression level.
|
zlib: { level: 9 } // Sets the compression level.
|
||||||
});
|
});
|
||||||
|
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||||
|
const provider = buildProvider();
|
||||||
|
|
||||||
const noteIdToMeta: Record<string, NoteMeta> = {};
|
const noteIdToMeta: Record<string, NoteMeta> = {};
|
||||||
|
|
||||||
|
function buildProvider() {
|
||||||
|
const providerData: ZipExportProviderData = {
|
||||||
|
getNoteTargetUrl,
|
||||||
|
archive,
|
||||||
|
branch,
|
||||||
|
rewriteFn
|
||||||
|
};
|
||||||
|
switch (format) {
|
||||||
|
case "html":
|
||||||
|
return new HtmlExportProvider(providerData);
|
||||||
|
case "markdown":
|
||||||
|
return new MarkdownExportProvider(providerData);
|
||||||
|
case "share":
|
||||||
|
return new ShareThemeExportProvider(providerData);
|
||||||
|
default:
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
||||||
const lcFileName = fileName.toLowerCase();
|
const lcFileName = fileName.toLowerCase();
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||||
let fileName = baseFileName.trim();
|
let fileName = baseFileName.trim();
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
fileName = "note";
|
fileName = "note";
|
||||||
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
}
|
}
|
||||||
|
|
||||||
let existingExtension = path.extname(fileName).toLowerCase();
|
let existingExtension = path.extname(fileName).toLowerCase();
|
||||||
let newExtension;
|
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||||
|
|
||||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
|
||||||
// and/or existing detected extensions in the note name
|
|
||||||
if (type === "text" && format === "markdown") {
|
|
||||||
newExtension = "md";
|
|
||||||
} else if (type === "text" && format === "html") {
|
|
||||||
newExtension = "html";
|
|
||||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
|
||||||
newExtension = "js";
|
|
||||||
} else if (type === "canvas" || mime === "application/json") {
|
|
||||||
newExtension = "json";
|
|
||||||
} else if (existingExtension.length > 0) {
|
|
||||||
// if the page already has an extension, then we'll just keep it
|
|
||||||
newExtension = null;
|
|
||||||
} else {
|
|
||||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
|
||||||
newExtension = "jpg";
|
|
||||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
|
||||||
newExtension = "txt";
|
|
||||||
} else {
|
|
||||||
newExtension = mimeTypes.extension(mime) || "dat";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||||
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
||||||
fileName += `.${newExtension}`;
|
fileName += `.${newExtension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return getUniqueFilename(existingFileNames, fileName);
|
return getUniqueFilename(existingFileNames, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
const notePath = parentMeta.notePath.concat([note.noteId]);
|
const notePath = parentMeta.notePath.concat([note.noteId]);
|
||||||
|
|
||||||
if (note.noteId in noteIdToMeta) {
|
if (note.noteId in noteIdToMeta) {
|
||||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
|
const extension = provider.mapExtension("text", "text/html", "", format);
|
||||||
|
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
|
||||||
|
|
||||||
const meta: NoteMeta = {
|
const meta: NoteMeta = {
|
||||||
isClone: true,
|
isClone: true,
|
||||||
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
prefix: branch.prefix,
|
prefix: branch.prefix,
|
||||||
dataFileName: fileName,
|
dataFileName: fileName,
|
||||||
type: "text", // export will have text description
|
type: "text", // export will have text description
|
||||||
format: format
|
format: (format === "markdown" ? "markdown" : "html")
|
||||||
};
|
};
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
taskContext.increaseProgressCount();
|
taskContext.increaseProgressCount();
|
||||||
|
|
||||||
if (note.type === "text") {
|
if (note.type === "text") {
|
||||||
meta.format = format;
|
meta.format = (format === "markdown" ? "markdown" : "html");
|
||||||
}
|
}
|
||||||
|
|
||||||
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
||||||
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
note.sortChildren();
|
note.sortChildren();
|
||||||
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
||||||
|
|
||||||
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
|
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
|
||||||
|
if (format !== "share") {
|
||||||
|
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
// if it's a leaf, then we'll export it even if it's empty
|
// if it's a leaf, then we'll export it even if it's empty
|
||||||
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
|
if (shouldIncludeFile) {
|
||||||
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
|
||||||
|
|
||||||
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
||||||
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
||||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||||
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||||
if (["html", "markdown"].includes(noteMeta?.format || "")) {
|
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||||
|
if (isText) {
|
||||||
content = content.toString();
|
content = content.toString();
|
||||||
content = rewriteFn(content, noteMeta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noteMeta.format === "html" && typeof content === "string") {
|
content = provider.prepareContent(title, content, noteMeta, note, branch);
|
||||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
|
|
||||||
if (!noteMeta?.notePath?.length) {
|
|
||||||
throw new Error("Missing note path.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
return content;
|
||||||
const htmlTitle = escapeHtml(title);
|
|
||||||
|
|
||||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
|
||||||
content = `<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="${cssUrl}">
|
|
||||||
<base target="_parent">
|
|
||||||
<title data-trilium-title>${htmlTitle}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="content">
|
|
||||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
|
||||||
|
|
||||||
<div class="ck-content">${content}</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
|
|
||||||
} else if (noteMeta.format === "markdown" && typeof content === "string") {
|
|
||||||
let markdownContent = mdService.toMarkdown(content);
|
|
||||||
|
|
||||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
|
||||||
markdownContent = `# ${title}\r
|
|
||||||
${markdownContent}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return markdownContent;
|
|
||||||
} else {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
||||||
@@ -377,7 +325,7 @@ ${markdownContent}`;
|
|||||||
|
|
||||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||||
|
|
||||||
content = prepareContent(noteMeta.title, content, noteMeta);
|
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||||
|
|
||||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||||
|
|
||||||
@@ -393,7 +341,7 @@ ${markdownContent}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noteMeta.dataFileName) {
|
if (noteMeta.dataFileName) {
|
||||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
|
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||||
|
|
||||||
archive.append(content, {
|
archive.append(content, {
|
||||||
name: filePathPrefix + noteMeta.dataFileName,
|
name: filePathPrefix + noteMeta.dataFileName,
|
||||||
@@ -429,138 +377,21 @@ ${markdownContent}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||||
if (!navigationMeta.dataFileName) {
|
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||||
return;
|
if (!rootMeta) {
|
||||||
}
|
throw new Error("Unable to create root meta.");
|
||||||
|
|
||||||
function saveNavigationInner(meta: NoteMeta) {
|
|
||||||
let html = "<li>";
|
|
||||||
|
|
||||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
|
||||||
|
|
||||||
if (meta.dataFileName && meta.noteId) {
|
|
||||||
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
|
|
||||||
|
|
||||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
|
||||||
} else {
|
|
||||||
html += escapedTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (meta.children && meta.children.length > 0) {
|
|
||||||
html += "<ul>";
|
|
||||||
|
|
||||||
for (const child of meta.children) {
|
|
||||||
html += saveNavigationInner(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
html += "</ul>";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${html}</li>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullHtml = `<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ul>${saveNavigationInner(rootMeta)}</ul>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
|
||||||
|
|
||||||
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
const metaFile: NoteMetaFile = {
|
||||||
let firstNonEmptyNote;
|
formatVersion: 2,
|
||||||
let curMeta = rootMeta;
|
appVersion: packageInfo.version,
|
||||||
|
files: [rootMeta]
|
||||||
|
};
|
||||||
|
|
||||||
if (!indexMeta.dataFileName) {
|
provider.prepareMeta(metaFile);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (!firstNonEmptyNote) {
|
|
||||||
if (curMeta.dataFileName && curMeta.noteId) {
|
|
||||||
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (curMeta.children && curMeta.children.length > 0) {
|
|
||||||
curMeta = curMeta.children[0];
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
</head>
|
|
||||||
<frameset cols="25%,75%">
|
|
||||||
<frame name="navigation" src="navigation.html">
|
|
||||||
<frame name="detail" src="${firstNonEmptyNote}">
|
|
||||||
</frameset>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
archive.append(fullHtml, { name: indexMeta.dataFileName });
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
|
||||||
if (!cssMeta.dataFileName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssFile = isDev
|
|
||||||
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
|
||||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
|
||||||
|
|
||||||
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
|
||||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
|
||||||
if (!rootMeta) {
|
|
||||||
throw new Error("Unable to create root meta.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaFile: NoteMetaFile = {
|
|
||||||
formatVersion: 2,
|
|
||||||
appVersion: packageInfo.version,
|
|
||||||
files: [rootMeta]
|
|
||||||
};
|
|
||||||
|
|
||||||
let navigationMeta: NoteMeta | null = null;
|
|
||||||
let indexMeta: NoteMeta | null = null;
|
|
||||||
let cssMeta: NoteMeta | null = null;
|
|
||||||
|
|
||||||
if (format === "html") {
|
|
||||||
navigationMeta = {
|
|
||||||
noImport: true,
|
|
||||||
dataFileName: "navigation.html"
|
|
||||||
};
|
|
||||||
|
|
||||||
metaFile.files.push(navigationMeta);
|
|
||||||
|
|
||||||
indexMeta = {
|
|
||||||
noImport: true,
|
|
||||||
dataFileName: "index.html"
|
|
||||||
};
|
|
||||||
|
|
||||||
metaFile.files.push(indexMeta);
|
|
||||||
|
|
||||||
cssMeta = {
|
|
||||||
noImport: true,
|
|
||||||
dataFileName: "style.css"
|
|
||||||
};
|
|
||||||
|
|
||||||
metaFile.files.push(cssMeta);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||||
// filter out relations which are not inside this export
|
// filter out relations which are not inside this export
|
||||||
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
||||||
@@ -584,34 +415,6 @@ ${markdownContent}`;
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
|
||||||
|
|
||||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
|
||||||
|
|
||||||
saveNote(rootMeta, "");
|
|
||||||
|
|
||||||
if (format === "html") {
|
|
||||||
if (!navigationMeta || !indexMeta || !cssMeta) {
|
|
||||||
throw new Error("Missing meta.");
|
|
||||||
}
|
|
||||||
|
|
||||||
saveNavigation(rootMeta, navigationMeta);
|
|
||||||
saveIndex(rootMeta, indexMeta);
|
|
||||||
saveCss(rootMeta, cssMeta);
|
|
||||||
}
|
|
||||||
|
|
||||||
const note = branch.getNote();
|
|
||||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
|
|
||||||
|
|
||||||
if (setHeaders && "setHeader" in res) {
|
|
||||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
|
||||||
res.setHeader("Content-Type", "application/zip");
|
|
||||||
}
|
|
||||||
|
|
||||||
archive.pipe(res);
|
|
||||||
await archive.finalize();
|
|
||||||
taskContext.taskSucceeded(null);
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
log.error(message);
|
log.error(message);
|
||||||
@@ -623,9 +426,30 @@ ${markdownContent}`;
|
|||||||
res.status(500).send(message);
|
res.status(500).send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||||
|
|
||||||
|
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||||
|
|
||||||
|
saveNote(rootMeta, "");
|
||||||
|
|
||||||
|
provider.afterDone(rootMeta);
|
||||||
|
|
||||||
|
const note = branch.getNote();
|
||||||
|
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
|
||||||
|
|
||||||
|
if (setHeaders && "setHeader" in res) {
|
||||||
|
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||||
|
res.setHeader("Content-Type", "application/zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
await archive.finalize();
|
||||||
|
|
||||||
|
taskContext.taskSucceeded(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||||
|
|
||||||
|
|||||||
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Archiver } from "archiver";
|
||||||
|
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
|
||||||
|
import type BNote from "../../../becca/entities/bnote.js";
|
||||||
|
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||||
|
import mimeTypes from "mime-types";
|
||||||
|
import { NoteType } from "@triliumnext/commons";
|
||||||
|
|
||||||
|
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||||
|
|
||||||
|
export type ExportFormat = "html" | "markdown" | "share";
|
||||||
|
|
||||||
|
export interface AdvancedExportOptions {
|
||||||
|
/**
|
||||||
|
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||||
|
*/
|
||||||
|
skipHtmlTemplate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||||
|
*
|
||||||
|
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||||
|
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||||
|
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||||
|
*/
|
||||||
|
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZipExportProviderData {
|
||||||
|
branch: BBranch;
|
||||||
|
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||||
|
archive: Archiver;
|
||||||
|
zipExportOptions?: AdvancedExportOptions;
|
||||||
|
rewriteFn: RewriteLinksFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ZipExportProvider {
|
||||||
|
branch: BBranch;
|
||||||
|
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||||
|
archive: Archiver;
|
||||||
|
zipExportOptions?: AdvancedExportOptions;
|
||||||
|
rewriteFn: RewriteLinksFn;
|
||||||
|
|
||||||
|
constructor(data: ZipExportProviderData) {
|
||||||
|
this.branch = data.branch;
|
||||||
|
this.getNoteTargetUrl = data.getNoteTargetUrl;
|
||||||
|
this.archive = data.archive;
|
||||||
|
this.zipExportOptions = data.zipExportOptions;
|
||||||
|
this.rewriteFn = data.rewriteFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||||
|
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||||
|
abstract afterDone(rootMeta: NoteMeta): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the extension of the resulting file for a specific note type.
|
||||||
|
*
|
||||||
|
* @param type the type of the note.
|
||||||
|
* @param mime the mime type of the note.
|
||||||
|
* @param existingExtension the existing extension, including the leading period character.
|
||||||
|
* @param format the format requested for export (e.g. HTML, Markdown).
|
||||||
|
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
|
||||||
|
*/
|
||||||
|
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
|
||||||
|
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||||
|
// and/or existing detected extensions in the note name
|
||||||
|
if (type === "text" && format === "markdown") {
|
||||||
|
return "md";
|
||||||
|
} else if (type === "text" && format === "html") {
|
||||||
|
return "html";
|
||||||
|
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||||
|
return "js";
|
||||||
|
} else if (type === "canvas" || mime === "application/json") {
|
||||||
|
return "json";
|
||||||
|
} else if (existingExtension.length > 0) {
|
||||||
|
// if the page already has an extension, then we'll just keep it
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||||
|
return "jpg";
|
||||||
|
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||||
|
return "txt";
|
||||||
|
} else {
|
||||||
|
return mimeTypes.extension(mime) || "dat";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
176
apps/server/src/services/export/zip/html.ts
Normal file
176
apps/server/src/services/export/zip/html.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type NoteMeta from "../../meta/note_meta.js";
|
||||||
|
import { escapeHtml, getResourceDir, isDev } from "../../utils";
|
||||||
|
import html from "html";
|
||||||
|
import { ZipExportProvider } from "./abstract_provider.js";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
export default class HtmlExportProvider extends ZipExportProvider {
|
||||||
|
|
||||||
|
private navigationMeta: NoteMeta | null = null;
|
||||||
|
private indexMeta: NoteMeta | null = null;
|
||||||
|
private cssMeta: NoteMeta | null = null;
|
||||||
|
|
||||||
|
prepareMeta(metaFile) {
|
||||||
|
this.navigationMeta = {
|
||||||
|
noImport: true,
|
||||||
|
dataFileName: "navigation.html"
|
||||||
|
};
|
||||||
|
metaFile.files.push(this.navigationMeta);
|
||||||
|
|
||||||
|
this.indexMeta = {
|
||||||
|
noImport: true,
|
||||||
|
dataFileName: "index.html"
|
||||||
|
};
|
||||||
|
metaFile.files.push(this.indexMeta);
|
||||||
|
|
||||||
|
this.cssMeta = {
|
||||||
|
noImport: true,
|
||||||
|
dataFileName: "style.css"
|
||||||
|
};
|
||||||
|
metaFile.files.push(this.cssMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||||
|
if (noteMeta.format === "html" && typeof content === "string") {
|
||||||
|
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||||
|
if (!noteMeta?.notePath?.length) {
|
||||||
|
throw new Error("Missing note path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||||
|
const htmlTitle = escapeHtml(title);
|
||||||
|
|
||||||
|
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||||
|
content = `<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="${cssUrl}">
|
||||||
|
<base target="_parent">
|
||||||
|
<title data-trilium-title>${htmlTitle}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="content">
|
||||||
|
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||||
|
|
||||||
|
<div class="ck-content">${content}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length < 100_000) {
|
||||||
|
content = html.prettyPrint(content, { indent_size: 2 })
|
||||||
|
}
|
||||||
|
content = this.rewriteFn(content as string, noteMeta);
|
||||||
|
return content;
|
||||||
|
} else {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterDone(rootMeta: NoteMeta) {
|
||||||
|
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
|
||||||
|
throw new Error("Missing meta.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#saveNavigation(rootMeta, this.navigationMeta);
|
||||||
|
this.#saveIndex(rootMeta, this.indexMeta);
|
||||||
|
this.#saveCss(rootMeta, this.cssMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
|
||||||
|
let html = "<li>";
|
||||||
|
|
||||||
|
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||||
|
|
||||||
|
if (meta.dataFileName && meta.noteId) {
|
||||||
|
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
|
||||||
|
|
||||||
|
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||||
|
} else {
|
||||||
|
html += escapedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.children && meta.children.length > 0) {
|
||||||
|
html += "<ul>";
|
||||||
|
|
||||||
|
for (const child of meta.children) {
|
||||||
|
html += this.#saveNavigationInner(rootMeta, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "</ul>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${html}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||||
|
if (!navigationMeta.dataFileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullHtml = `<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||||
|
|
||||||
|
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||||
|
let firstNonEmptyNote;
|
||||||
|
let curMeta = rootMeta;
|
||||||
|
|
||||||
|
if (!indexMeta.dataFileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!firstNonEmptyNote) {
|
||||||
|
if (curMeta.dataFileName && curMeta.noteId) {
|
||||||
|
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curMeta.children && curMeta.children.length > 0) {
|
||||||
|
curMeta = curMeta.children[0];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<frameset cols="25%,75%">
|
||||||
|
<frame name="navigation" src="navigation.html">
|
||||||
|
<frame name="detail" src="${firstNonEmptyNote}">
|
||||||
|
</frameset>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||||
|
if (!cssMeta.dataFileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssFile = isDev
|
||||||
|
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||||
|
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||||
|
const cssContent = fs.readFileSync(cssFile, "utf-8");
|
||||||
|
this.archive.append(cssContent, { name: cssMeta.dataFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
27
apps/server/src/services/export/zip/markdown.ts
Normal file
27
apps/server/src/services/export/zip/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import NoteMeta from "../../meta/note_meta"
|
||||||
|
import { ZipExportProvider } from "./abstract_provider.js"
|
||||||
|
import mdService from "../markdown.js";
|
||||||
|
|
||||||
|
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||||
|
|
||||||
|
prepareMeta() { }
|
||||||
|
|
||||||
|
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||||
|
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||||
|
let markdownContent = mdService.toMarkdown(content);
|
||||||
|
|
||||||
|
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||||
|
markdownContent = `# ${title}\r
|
||||||
|
${markdownContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownContent = this.rewriteFn(markdownContent, noteMeta);
|
||||||
|
return markdownContent;
|
||||||
|
} else {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterDone() { }
|
||||||
|
|
||||||
|
}
|
||||||
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { join } from "path";
|
||||||
|
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||||
|
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
|
||||||
|
import { RESOURCE_DIR } from "../../resource_dir";
|
||||||
|
import { getResourceDir, isDev } from "../../utils";
|
||||||
|
import fs, { readdirSync } from "fs";
|
||||||
|
import { renderNoteForExport } from "../../../share/content_renderer";
|
||||||
|
import type BNote from "../../../becca/entities/bnote.js";
|
||||||
|
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||||
|
import { getShareThemeAssetDir } from "../../../routes/assets";
|
||||||
|
|
||||||
|
const shareThemeAssetDir = getShareThemeAssetDir();
|
||||||
|
|
||||||
|
export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||||
|
|
||||||
|
private assetsMeta: NoteMeta[] = [];
|
||||||
|
private indexMeta: NoteMeta | null = null;
|
||||||
|
|
||||||
|
prepareMeta(metaFile: NoteMetaFile): void {
|
||||||
|
|
||||||
|
const assets = [
|
||||||
|
"icon-color.svg"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of readdirSync(shareThemeAssetDir)) {
|
||||||
|
assets.push(`assets/${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const assetMeta = {
|
||||||
|
noImport: true,
|
||||||
|
dataFileName: asset
|
||||||
|
};
|
||||||
|
this.assetsMeta.push(assetMeta);
|
||||||
|
metaFile.files.push(assetMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.indexMeta = {
|
||||||
|
noImport: true,
|
||||||
|
dataFileName: "index.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
metaFile.files.push(this.indexMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||||
|
if (!noteMeta?.notePath?.length) {
|
||||||
|
throw new Error("Missing note path.");
|
||||||
|
}
|
||||||
|
const basePath = "../".repeat(noteMeta.notePath.length - 1);
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
|
||||||
|
if (typeof content === "string") {
|
||||||
|
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
|
||||||
|
if (match.includes("/assets/")) return match;
|
||||||
|
return `href="#root/${id}"`;
|
||||||
|
});
|
||||||
|
content = this.rewriteFn(content, noteMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterDone(rootMeta: NoteMeta): void {
|
||||||
|
this.#saveAssets(rootMeta, this.assetsMeta);
|
||||||
|
this.#saveIndex(rootMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "html";
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveIndex(rootMeta: NoteMeta) {
|
||||||
|
if (!this.indexMeta?.dataFileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = this.branch.getNote();
|
||||||
|
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||||
|
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||||
|
for (const assetMeta of assetsMeta) {
|
||||||
|
if (!assetMeta.dataFileName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
|
||||||
|
this.archive.append(cssContent, { name: assetMeta.dataFileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShareThemeAssets(nameWithExtension: string) {
|
||||||
|
let path: string | undefined;
|
||||||
|
if (nameWithExtension === "icon-color.svg") {
|
||||||
|
path = join(RESOURCE_DIR, "images", nameWithExtension);
|
||||||
|
} else if (nameWithExtension.startsWith("assets")) {
|
||||||
|
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
|
||||||
|
} else if (isDev) {
|
||||||
|
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
|
||||||
|
} else {
|
||||||
|
path = join(getResourceDir(), "public", "src", nameWithExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readFileSync(path);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NoteType } from "@triliumnext/commons";
|
import type { NoteType } from "@triliumnext/commons";
|
||||||
import type AttachmentMeta from "./attachment_meta.js";
|
import type AttachmentMeta from "./attachment_meta.js";
|
||||||
import type AttributeMeta from "./attribute_meta.js";
|
import type AttributeMeta from "./attribute_meta.js";
|
||||||
|
import type { ExportFormat } from "../export/zip/abstract_provider.js";
|
||||||
|
|
||||||
export interface NoteMetaFile {
|
export interface NoteMetaFile {
|
||||||
formatVersion: number;
|
formatVersion: number;
|
||||||
@@ -19,7 +20,7 @@ export default interface NoteMeta {
|
|||||||
type?: NoteType;
|
type?: NoteType;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
/** 'html' or 'markdown', applicable to text notes only */
|
/** 'html' or 'markdown', applicable to text notes only */
|
||||||
format?: "html" | "markdown";
|
format?: ExportFormat;
|
||||||
dataFileName?: string;
|
dataFileName?: string;
|
||||||
dirFileName?: string;
|
dirFileName?: string;
|
||||||
/** this file should not be imported (e.g., HTML navigation) */
|
/** this file should not be imported (e.g., HTML navigation) */
|
||||||
|
|||||||
@@ -214,14 +214,6 @@ function createNewNote(params: NoteParams): {
|
|||||||
prefix: params.prefix || "",
|
prefix: params.prefix || "",
|
||||||
isExpanded: !!params.isExpanded
|
isExpanded: !!params.isExpanded
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
// FTS indexing is now handled entirely by database triggers
|
|
||||||
// The improved triggers in schema.sql handle all scenarios including:
|
|
||||||
// - INSERT OR REPLACE operations
|
|
||||||
// - INSERT ... ON CONFLICT ... DO UPDATE (upsert)
|
|
||||||
// - Cases where notes are created before blobs (common during import)
|
|
||||||
// - All UPDATE scenarios, not just specific column changes
|
|
||||||
// This ensures FTS stays in sync even when entity events are disabled
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!isEntityEventsDisabled) {
|
if (!isEntityEventsDisabled) {
|
||||||
// re-enable entity events only if they were previously enabled
|
// re-enable entity events only if they were previously enabled
|
||||||
|
|||||||
@@ -215,14 +215,6 @@ const defaultOptions: DefaultOption[] = [
|
|||||||
{ name: "aiSystemPrompt", value: "", isSynced: true },
|
{ name: "aiSystemPrompt", value: "", isSynced: true },
|
||||||
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
|
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
|
||||||
|
|
||||||
// Search configuration
|
|
||||||
{ name: "searchBackend", value: "typescript", isSynced: false }, // "typescript" or "sqlite"
|
|
||||||
{ name: "searchSqliteEnabled", value: "false", isSynced: false },
|
|
||||||
{ name: "searchSqlitePerformanceLogging", value: "false", isSynced: false },
|
|
||||||
{ name: "searchSqliteMaxMemory", value: "67108864", isSynced: false }, // 64MB default
|
|
||||||
{ name: "searchSqliteBatchSize", value: "100", isSynced: false },
|
|
||||||
{ name: "searchSqliteAutoRebuild", value: "true", isSynced: false },
|
|
||||||
|
|
||||||
{ name: "seenCallToActions", value: "[]", isSynced: true }
|
{ name: "seenCallToActions", value: "[]", isSynced: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* A/B Testing utilities for comparing search backend performance
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SearchContext from "./search_context.js";
|
|
||||||
import type { SearchParams } from "./services/types.js";
|
|
||||||
import performanceMonitor from "./performance_monitor.js";
|
|
||||||
import log from "../log.js";
|
|
||||||
import optionService from "../options.js";
|
|
||||||
|
|
||||||
export interface ABTestResult {
|
|
||||||
query: string;
|
|
||||||
typescriptTime: number;
|
|
||||||
sqliteTime: number;
|
|
||||||
typescriptResults: number;
|
|
||||||
sqliteResults: number;
|
|
||||||
resultsMatch: boolean;
|
|
||||||
speedup: number;
|
|
||||||
winner: "typescript" | "sqlite" | "tie";
|
|
||||||
}
|
|
||||||
|
|
||||||
class ABTestingService {
|
|
||||||
private enabled: boolean = false;
|
|
||||||
private sampleRate: number = 0.1; // 10% of searches by default
|
|
||||||
private results: ABTestResult[] = [];
|
|
||||||
private maxResults: number = 1000;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.updateSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings() {
|
|
||||||
try {
|
|
||||||
this.enabled = optionService.getOptionBool("searchSqliteEnabled");
|
|
||||||
// Could add a separate AB testing option if needed
|
|
||||||
} catch {
|
|
||||||
this.enabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if we should run an A/B test for this query
|
|
||||||
*/
|
|
||||||
shouldRunTest(): boolean {
|
|
||||||
if (!this.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Random sampling
|
|
||||||
return Math.random() < this.sampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the same search query with both backends and compare results
|
|
||||||
*/
|
|
||||||
async runComparison(query: string, params: SearchParams): Promise<ABTestResult | null> {
|
|
||||||
if (!this.shouldRunTest()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Dynamically import to avoid circular dependencies
|
|
||||||
const searchModule = await import("./services/search.js");
|
|
||||||
|
|
||||||
// Run with TypeScript backend
|
|
||||||
const tsContext = new SearchContext({ ...params, forceBackend: "typescript" });
|
|
||||||
const tsTimer = performanceMonitor.startTimer();
|
|
||||||
const tsResults = searchModule.default.findResultsWithQuery(query, tsContext);
|
|
||||||
const tsTime = tsTimer();
|
|
||||||
|
|
||||||
// Run with SQLite backend
|
|
||||||
const sqliteContext = new SearchContext({ ...params, forceBackend: "sqlite" });
|
|
||||||
const sqliteTimer = performanceMonitor.startTimer();
|
|
||||||
const sqliteResults = searchModule.default.findResultsWithQuery(query, sqliteContext);
|
|
||||||
const sqliteTime = sqliteTimer();
|
|
||||||
|
|
||||||
// Compare results
|
|
||||||
const tsNoteIds = new Set(tsResults.map(r => r.noteId));
|
|
||||||
const sqliteNoteIds = new Set(sqliteResults.map(r => r.noteId));
|
|
||||||
|
|
||||||
// Check if results match (same notes found)
|
|
||||||
const resultsMatch = tsNoteIds.size === sqliteNoteIds.size &&
|
|
||||||
[...tsNoteIds].every(id => sqliteNoteIds.has(id));
|
|
||||||
|
|
||||||
// Calculate speedup
|
|
||||||
const speedup = tsTime / sqliteTime;
|
|
||||||
|
|
||||||
// Determine winner
|
|
||||||
let winner: "typescript" | "sqlite" | "tie";
|
|
||||||
if (speedup > 1.2) {
|
|
||||||
winner = "sqlite";
|
|
||||||
} else if (speedup < 0.83) {
|
|
||||||
winner = "typescript";
|
|
||||||
} else {
|
|
||||||
winner = "tie";
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ABTestResult = {
|
|
||||||
query: query.substring(0, 100),
|
|
||||||
typescriptTime: tsTime,
|
|
||||||
sqliteTime: sqliteTime,
|
|
||||||
typescriptResults: tsResults.length,
|
|
||||||
sqliteResults: sqliteResults.length,
|
|
||||||
resultsMatch,
|
|
||||||
speedup,
|
|
||||||
winner
|
|
||||||
};
|
|
||||||
|
|
||||||
this.recordResult(result);
|
|
||||||
|
|
||||||
// Log significant differences
|
|
||||||
if (!resultsMatch) {
|
|
||||||
log.info(`A/B test found different results for query "${query.substring(0, 50)}": TS=${tsResults.length}, SQLite=${sqliteResults.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(speedup - 1) > 0.5) {
|
|
||||||
log.info(`A/B test significant performance difference: ${winner} is ${Math.abs(speedup - 1).toFixed(1)}x faster for query "${query.substring(0, 50)}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`A/B test failed: ${error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private recordResult(result: ABTestResult) {
|
|
||||||
this.results.push(result);
|
|
||||||
|
|
||||||
// Keep only the last N results
|
|
||||||
if (this.results.length > this.maxResults) {
|
|
||||||
this.results = this.results.slice(-this.maxResults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get summary statistics from A/B tests
|
|
||||||
*/
|
|
||||||
getSummary(): {
|
|
||||||
totalTests: number;
|
|
||||||
avgSpeedup: number;
|
|
||||||
typescriptWins: number;
|
|
||||||
sqliteWins: number;
|
|
||||||
ties: number;
|
|
||||||
mismatchRate: number;
|
|
||||||
recommendation: string;
|
|
||||||
} {
|
|
||||||
if (this.results.length === 0) {
|
|
||||||
return {
|
|
||||||
totalTests: 0,
|
|
||||||
avgSpeedup: 1,
|
|
||||||
typescriptWins: 0,
|
|
||||||
sqliteWins: 0,
|
|
||||||
ties: 0,
|
|
||||||
mismatchRate: 0,
|
|
||||||
recommendation: "No A/B test data available"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTests = this.results.length;
|
|
||||||
const avgSpeedup = this.results.reduce((sum, r) => sum + r.speedup, 0) / totalTests;
|
|
||||||
const typescriptWins = this.results.filter(r => r.winner === "typescript").length;
|
|
||||||
const sqliteWins = this.results.filter(r => r.winner === "sqlite").length;
|
|
||||||
const ties = this.results.filter(r => r.winner === "tie").length;
|
|
||||||
const mismatches = this.results.filter(r => !r.resultsMatch).length;
|
|
||||||
const mismatchRate = mismatches / totalTests;
|
|
||||||
|
|
||||||
let recommendation: string;
|
|
||||||
if (mismatchRate > 0.1) {
|
|
||||||
recommendation = "High mismatch rate detected - SQLite search may have accuracy issues";
|
|
||||||
} else if (avgSpeedup > 1.5) {
|
|
||||||
recommendation = `SQLite is ${avgSpeedup.toFixed(1)}x faster on average - consider enabling`;
|
|
||||||
} else if (avgSpeedup < 0.67) {
|
|
||||||
recommendation = `TypeScript is ${(1/avgSpeedup).toFixed(1)}x faster on average - keep using TypeScript`;
|
|
||||||
} else {
|
|
||||||
recommendation = "Both backends perform similarly - choice depends on other factors";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalTests,
|
|
||||||
avgSpeedup,
|
|
||||||
typescriptWins,
|
|
||||||
sqliteWins,
|
|
||||||
ties,
|
|
||||||
mismatchRate,
|
|
||||||
recommendation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent test results
|
|
||||||
*/
|
|
||||||
getRecentResults(count: number = 100): ABTestResult[] {
|
|
||||||
return this.results.slice(-count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all test results
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.results = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the sampling rate for A/B tests
|
|
||||||
*/
|
|
||||||
setSampleRate(rate: number) {
|
|
||||||
if (rate < 0 || rate > 1) {
|
|
||||||
throw new Error("Sample rate must be between 0 and 1");
|
|
||||||
}
|
|
||||||
this.sampleRate = rate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
const abTestingService = new ABTestingService();
|
|
||||||
|
|
||||||
export default abTestingService;
|
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
fuzzyMatchWord,
|
fuzzyMatchWord,
|
||||||
FUZZY_SEARCH_CONFIG
|
FUZZY_SEARCH_CONFIG
|
||||||
} from "../utils/text_utils.js";
|
} from "../utils/text_utils.js";
|
||||||
import ftsSearchService, { FTSError, FTSNotAvailableError, FTSQueryError } from "../fts_search.js";
|
|
||||||
|
|
||||||
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
||||||
|
|
||||||
@@ -78,138 +77,6 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
|
|
||||||
const resultNoteSet = new NoteSet();
|
const resultNoteSet = new NoteSet();
|
||||||
|
|
||||||
// Try to use FTS5 if available for better performance
|
|
||||||
if (ftsSearchService.checkFTS5Availability() && this.canUseFTS5()) {
|
|
||||||
try {
|
|
||||||
// Performance comparison logging for FTS5 vs traditional search
|
|
||||||
const searchQuery = this.tokens.join(" ");
|
|
||||||
const isQuickSearch = searchContext.fastSearch === false; // quick-search sets fastSearch to false
|
|
||||||
if (isQuickSearch) {
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Starting comparison for query: "${searchQuery}" with operator: ${this.operator}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need to search protected notes
|
|
||||||
const searchProtected = protectedSessionService.isProtectedSessionAvailable();
|
|
||||||
|
|
||||||
// Time FTS5 search
|
|
||||||
const ftsStartTime = Date.now();
|
|
||||||
const noteIdSet = inputNoteSet.getNoteIds();
|
|
||||||
const ftsResults = ftsSearchService.searchSync(
|
|
||||||
this.tokens,
|
|
||||||
this.operator,
|
|
||||||
noteIdSet.size > 0 ? noteIdSet : undefined,
|
|
||||||
{
|
|
||||||
includeSnippets: false,
|
|
||||||
searchProtected: false // FTS5 doesn't index protected notes
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const ftsEndTime = Date.now();
|
|
||||||
const ftsTime = ftsEndTime - ftsStartTime;
|
|
||||||
|
|
||||||
// Add FTS results to note set
|
|
||||||
for (const result of ftsResults) {
|
|
||||||
if (becca.notes[result.noteId]) {
|
|
||||||
resultNoteSet.add(becca.notes[result.noteId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For quick-search, also run traditional search for comparison
|
|
||||||
if (isQuickSearch) {
|
|
||||||
const traditionalStartTime = Date.now();
|
|
||||||
const traditionalNoteSet = new NoteSet();
|
|
||||||
|
|
||||||
// Run traditional search (use the fallback method)
|
|
||||||
const traditionalResults = this.executeWithFallback(inputNoteSet, traditionalNoteSet, searchContext);
|
|
||||||
|
|
||||||
const traditionalEndTime = Date.now();
|
|
||||||
const traditionalTime = traditionalEndTime - traditionalStartTime;
|
|
||||||
|
|
||||||
// Log performance comparison
|
|
||||||
const speedup = traditionalTime > 0 ? (traditionalTime / ftsTime).toFixed(2) : "N/A";
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] ===== Results for query: "${searchQuery}" =====`);
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 search: ${ftsTime}ms, found ${ftsResults.length} results`);
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Traditional search: ${traditionalTime}ms, found ${traditionalResults.notes.length} results`);
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 is ${speedup}x faster (saved ${traditionalTime - ftsTime}ms)`);
|
|
||||||
|
|
||||||
// Check if results match
|
|
||||||
const ftsNoteIds = new Set(ftsResults.map(r => r.noteId));
|
|
||||||
const traditionalNoteIds = new Set(traditionalResults.notes.map(n => n.noteId));
|
|
||||||
const matchingResults = ftsNoteIds.size === traditionalNoteIds.size &&
|
|
||||||
Array.from(ftsNoteIds).every(id => traditionalNoteIds.has(id));
|
|
||||||
|
|
||||||
if (!matchingResults) {
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Results differ! FTS5: ${ftsNoteIds.size} notes, Traditional: ${traditionalNoteIds.size} notes`);
|
|
||||||
|
|
||||||
// Find differences
|
|
||||||
const onlyInFTS = Array.from(ftsNoteIds).filter(id => !traditionalNoteIds.has(id));
|
|
||||||
const onlyInTraditional = Array.from(traditionalNoteIds).filter(id => !ftsNoteIds.has(id));
|
|
||||||
|
|
||||||
if (onlyInFTS.length > 0) {
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Only in FTS5: ${onlyInFTS.slice(0, 5).join(", ")}${onlyInFTS.length > 5 ? "..." : ""}`);
|
|
||||||
}
|
|
||||||
if (onlyInTraditional.length > 0) {
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Only in Traditional: ${onlyInTraditional.slice(0, 5).join(", ")}${onlyInTraditional.length > 5 ? "..." : ""}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] Results match perfectly! ✓`);
|
|
||||||
}
|
|
||||||
log.info(`[QUICK-SEARCH-COMPARISON] ========================================`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we need to search protected notes, use the separate method
|
|
||||||
if (searchProtected) {
|
|
||||||
const protectedResults = ftsSearchService.searchProtectedNotesSync(
|
|
||||||
this.tokens,
|
|
||||||
this.operator,
|
|
||||||
noteIdSet.size > 0 ? noteIdSet : undefined,
|
|
||||||
{
|
|
||||||
includeSnippets: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add protected note results
|
|
||||||
for (const result of protectedResults) {
|
|
||||||
if (becca.notes[result.noteId]) {
|
|
||||||
resultNoteSet.add(becca.notes[result.noteId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle special cases that FTS5 doesn't support well
|
|
||||||
if (this.operator === "%=" || this.flatText) {
|
|
||||||
// Fall back to original implementation for regex and flat text searches
|
|
||||||
return this.executeWithFallback(inputNoteSet, resultNoteSet, searchContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultNoteSet;
|
|
||||||
} catch (error) {
|
|
||||||
// Handle structured errors from FTS service
|
|
||||||
if (error instanceof FTSError) {
|
|
||||||
if (error instanceof FTSNotAvailableError) {
|
|
||||||
log.info("FTS5 not available, using standard search");
|
|
||||||
} else if (error instanceof FTSQueryError) {
|
|
||||||
log.error(`FTS5 query error: ${error.message}`);
|
|
||||||
searchContext.addError(`Search optimization failed: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
log.error(`FTS5 error: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use fallback for recoverable errors
|
|
||||||
if (error.recoverable) {
|
|
||||||
log.info("Using fallback search implementation");
|
|
||||||
} else {
|
|
||||||
// For non-recoverable errors, return empty result
|
|
||||||
searchContext.addError(`Search failed: ${error.message}`);
|
|
||||||
return resultNoteSet;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.error(`Unexpected error in FTS5 search: ${error}`);
|
|
||||||
}
|
|
||||||
// Fall back to original implementation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Original implementation for fallback or when FTS5 is not available
|
|
||||||
for (const row of sql.iterateRows<SearchRow>(`
|
for (const row of sql.iterateRows<SearchRow>(`
|
||||||
SELECT noteId, type, mime, content, isProtected
|
SELECT noteId, type, mime, content, isProtected
|
||||||
FROM notes JOIN blobs USING (blobId)
|
FROM notes JOIN blobs USING (blobId)
|
||||||
@@ -222,39 +89,6 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
return resultNoteSet;
|
return resultNoteSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if the current search can use FTS5
|
|
||||||
*/
|
|
||||||
private canUseFTS5(): boolean {
|
|
||||||
// FTS5 doesn't support regex searches well
|
|
||||||
if (this.operator === "%=") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, we'll use FTS5 for most text searches
|
|
||||||
// but keep the original implementation for complex cases
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes search with fallback for special cases
|
|
||||||
*/
|
|
||||||
private executeWithFallback(inputNoteSet: NoteSet, resultNoteSet: NoteSet, searchContext: SearchContext): NoteSet {
|
|
||||||
// Keep existing results from FTS5 and add additional results from fallback
|
|
||||||
for (const row of sql.iterateRows<SearchRow>(`
|
|
||||||
SELECT noteId, type, mime, content, isProtected
|
|
||||||
FROM notes JOIN blobs USING (blobId)
|
|
||||||
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND isDeleted = 0
|
|
||||||
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
|
|
||||||
if (this.operator === "%=" || this.flatText) {
|
|
||||||
// Only process for special cases
|
|
||||||
this.findInText(row, inputNoteSet, resultNoteSet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resultNoteSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
|
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
|
||||||
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
/**
|
|
||||||
* SQLite-based Note Content Fulltext Expression
|
|
||||||
*
|
|
||||||
* This is a drop-in replacement for NoteContentFulltextExp that uses
|
|
||||||
* the SQLite search service for dramatically improved performance.
|
|
||||||
* It maintains 100% compatibility with the existing API while providing
|
|
||||||
* 10-30x speed improvements.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type SearchContext from "../search_context.js";
|
|
||||||
import Expression from "./expression.js";
|
|
||||||
import NoteSet from "../note_set.js";
|
|
||||||
import log from "../../log.js";
|
|
||||||
import becca from "../../../becca/becca.js";
|
|
||||||
import { getSQLiteSearchService, type SearchOptions } from "../sqlite_search_service.js";
|
|
||||||
|
|
||||||
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
|
||||||
|
|
||||||
interface ConstructorOpts {
|
|
||||||
tokens: string[];
|
|
||||||
raw?: boolean;
|
|
||||||
flatText?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQLite-optimized implementation of note content fulltext search
|
|
||||||
*/
|
|
||||||
class NoteContentSQLiteExp extends Expression {
|
|
||||||
private operator: string;
|
|
||||||
tokens: string[];
|
|
||||||
private raw: boolean;
|
|
||||||
private flatText: boolean;
|
|
||||||
private sqliteService = getSQLiteSearchService();
|
|
||||||
|
|
||||||
constructor(operator: string, { tokens, raw, flatText }: ConstructorOpts) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
if (!operator || !tokens || !Array.isArray(tokens)) {
|
|
||||||
throw new Error('Invalid parameters: operator and tokens are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.operator = operator;
|
|
||||||
this.tokens = tokens;
|
|
||||||
this.raw = !!raw;
|
|
||||||
this.flatText = !!flatText;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(inputNoteSet: NoteSet, executionContext: {}, searchContext: SearchContext) {
|
|
||||||
if (!ALLOWED_OPERATORS.has(this.operator)) {
|
|
||||||
searchContext.addError(`Note content can be searched only with operators: ${Array.from(ALLOWED_OPERATORS).join(", ")}, operator ${this.operator} given.`);
|
|
||||||
return inputNoteSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultNoteSet = new NoteSet();
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prepare search options
|
|
||||||
const searchOptions: SearchOptions = {
|
|
||||||
includeProtected: searchContext.includeArchivedNotes,
|
|
||||||
includeDeleted: false,
|
|
||||||
limit: searchContext.limit || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
// If we have an input note set, use it as a filter
|
|
||||||
if (inputNoteSet.notes.length > 0) {
|
|
||||||
searchOptions.noteIdFilter = new Set(inputNoteSet.getNoteIds());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map ~* operator to ~= for SQLite service
|
|
||||||
const mappedOperator = this.operator === "~*" ? "~=" : this.operator;
|
|
||||||
|
|
||||||
// Execute SQLite search
|
|
||||||
const noteIds = this.sqliteService.search(
|
|
||||||
this.tokens,
|
|
||||||
mappedOperator,
|
|
||||||
searchContext,
|
|
||||||
searchOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build result note set from note IDs
|
|
||||||
for (const noteId of noteIds) {
|
|
||||||
const note = becca.notes[noteId];
|
|
||||||
if (note) {
|
|
||||||
resultNoteSet.add(note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log performance if enabled
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
if (searchContext.debug) {
|
|
||||||
log.info(`SQLite search completed: operator=${this.operator}, tokens=${this.tokens.join(" ")}, ` +
|
|
||||||
`results=${noteIds.size}, time=${elapsed}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store highlighted tokens for UI
|
|
||||||
if (noteIds.size > 0) {
|
|
||||||
searchContext.highlightedTokens = this.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`SQLite search failed: ${error}`);
|
|
||||||
searchContext.addError(`Search failed: ${error}`);
|
|
||||||
|
|
||||||
// On error, return input set unchanged
|
|
||||||
return inputNoteSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultNoteSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get performance statistics for monitoring
|
|
||||||
*/
|
|
||||||
getStatistics() {
|
|
||||||
return this.sqliteService.getStatistics();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if SQLite search is available
|
|
||||||
*/
|
|
||||||
static isAvailable(): boolean {
|
|
||||||
const service = getSQLiteSearchService();
|
|
||||||
const stats = service.getStatistics();
|
|
||||||
return stats.tablesInitialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a compatible expression based on availability
|
|
||||||
* This allows gradual migration from the old implementation
|
|
||||||
*/
|
|
||||||
static createExpression(operator: string, opts: ConstructorOpts): Expression {
|
|
||||||
if (NoteContentSQLiteExp.isAvailable()) {
|
|
||||||
return new NoteContentSQLiteExp(operator, opts);
|
|
||||||
} else {
|
|
||||||
// Fall back to original implementation if SQLite not ready
|
|
||||||
// This would import the original NoteContentFulltextExp
|
|
||||||
log.info("SQLite search not available, using fallback implementation");
|
|
||||||
|
|
||||||
// Dynamic import to avoid circular dependency
|
|
||||||
const NoteContentFulltextExp = require("./note_content_fulltext.js").default;
|
|
||||||
return new NoteContentFulltextExp(operator, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NoteContentSQLiteExp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function for creating search expressions
|
|
||||||
* This can be used as a drop-in replacement in the expression builder
|
|
||||||
*/
|
|
||||||
export function createNoteContentExpression(operator: string, opts: ConstructorOpts): Expression {
|
|
||||||
return NoteContentSQLiteExp.createExpression(operator, opts);
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for FTS5 blob deduplication scenarios
|
|
||||||
*
|
|
||||||
* This test file validates that FTS indexing works correctly when:
|
|
||||||
* 1. Multiple notes share the same blob (deduplication)
|
|
||||||
* 2. Notes change content to match existing blobs
|
|
||||||
* 3. Blobs are updated and affect multiple notes
|
|
||||||
* 4. Notes switch between unique and shared blobs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import sql from '../sql.js';
|
|
||||||
import beccaLoader from '../../becca/becca_loader.js';
|
|
||||||
import noteService from '../notes.js';
|
|
||||||
import searchService from './services/search.js';
|
|
||||||
import { ftsSearchService } from './fts_search.js';
|
|
||||||
|
|
||||||
describe('FTS5 Blob Deduplication Tests', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Ensure we have a clean test database with FTS enabled
|
|
||||||
sql.execute("DELETE FROM notes WHERE noteId LIKE 'test_%'");
|
|
||||||
sql.execute("DELETE FROM blobs WHERE blobId LIKE 'test_%'");
|
|
||||||
sql.execute("DELETE FROM notes_fts WHERE noteId LIKE 'test_%'");
|
|
||||||
|
|
||||||
// Reload becca to ensure cache is in sync
|
|
||||||
beccaLoader.load();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Clean up test data
|
|
||||||
sql.execute("DELETE FROM notes WHERE noteId LIKE 'test_%'");
|
|
||||||
sql.execute("DELETE FROM blobs WHERE blobId LIKE 'test_%'");
|
|
||||||
sql.execute("DELETE FROM notes_fts WHERE noteId LIKE 'test_%'");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Blob Deduplication Scenarios', () => {
|
|
||||||
it('should index multiple notes sharing the same blob', async () => {
|
|
||||||
// Create first note with unique content
|
|
||||||
const note1 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note1',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Test Note 1',
|
|
||||||
content: 'Shared content for deduplication test',
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create second note with the same content (will share blob)
|
|
||||||
const note2 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note2',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Test Note 2',
|
|
||||||
content: 'Shared content for deduplication test',
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify both notes share the same blob
|
|
||||||
const blob1 = sql.getRow("SELECT blobId FROM notes WHERE noteId = ?", ['test_note1']);
|
|
||||||
const blob2 = sql.getRow("SELECT blobId FROM notes WHERE noteId = ?", ['test_note2']);
|
|
||||||
expect(blob1.blobId).toBe(blob2.blobId);
|
|
||||||
|
|
||||||
// Verify both notes are indexed in FTS
|
|
||||||
const ftsCount = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId IN (?, ?)",
|
|
||||||
['test_note1', 'test_note2']
|
|
||||||
);
|
|
||||||
expect(ftsCount).toBe(2);
|
|
||||||
|
|
||||||
// Search should find both notes
|
|
||||||
const searchResults = searchService.searchNotes('deduplication');
|
|
||||||
const foundNoteIds = searchResults.map(r => r.noteId);
|
|
||||||
expect(foundNoteIds).toContain('test_note1');
|
|
||||||
expect(foundNoteIds).toContain('test_note2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update FTS when note content changes to match existing blob', async () => {
|
|
||||||
// Create first note with unique content
|
|
||||||
const note1 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note3',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Note with existing content',
|
|
||||||
content: 'This is existing content in the database',
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create second note with different content
|
|
||||||
const note2 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note4',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Note with different content',
|
|
||||||
content: 'This is completely different content',
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify notes have different blobs initially
|
|
||||||
const initialBlob1 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note3']);
|
|
||||||
const initialBlob2 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note4']);
|
|
||||||
expect(initialBlob1).not.toBe(initialBlob2);
|
|
||||||
|
|
||||||
// Change note2's content to match note1 (deduplication occurs)
|
|
||||||
await noteService.updateNoteContent('test_note4', 'This is existing content in the database');
|
|
||||||
|
|
||||||
// Verify both notes now share the same blob
|
|
||||||
const finalBlob1 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note3']);
|
|
||||||
const finalBlob2 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note4']);
|
|
||||||
expect(finalBlob1).toBe(finalBlob2);
|
|
||||||
|
|
||||||
// Verify FTS is updated correctly for note2
|
|
||||||
const ftsContent = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note4']
|
|
||||||
);
|
|
||||||
expect(ftsContent).toBe('This is existing content in the database');
|
|
||||||
|
|
||||||
// Search for old content should not find note2
|
|
||||||
const oldContentSearch = searchService.searchNotes('completely different');
|
|
||||||
const oldSearchIds = oldContentSearch.map(r => r.noteId);
|
|
||||||
expect(oldSearchIds).not.toContain('test_note4');
|
|
||||||
|
|
||||||
// Search for new content should find both notes
|
|
||||||
const newContentSearch = searchService.searchNotes('existing content');
|
|
||||||
const newSearchIds = newContentSearch.map(r => r.noteId);
|
|
||||||
expect(newSearchIds).toContain('test_note3');
|
|
||||||
expect(newSearchIds).toContain('test_note4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update all notes when shared blob content changes', async () => {
|
|
||||||
// Create three notes with the same content
|
|
||||||
const sharedContent = 'Original shared content for blob update test';
|
|
||||||
|
|
||||||
await noteService.createNewNote({
|
|
||||||
noteId: 'test_note5',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Shared Note 1',
|
|
||||||
content: sharedContent,
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
await noteService.createNewNote({
|
|
||||||
noteId: 'test_note6',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Shared Note 2',
|
|
||||||
content: sharedContent,
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
await noteService.createNewNote({
|
|
||||||
noteId: 'test_note7',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Shared Note 3',
|
|
||||||
content: sharedContent,
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify all three share the same blob
|
|
||||||
const blobIds = sql.getColumn(
|
|
||||||
"SELECT DISTINCT blobId FROM notes WHERE noteId IN (?, ?, ?)",
|
|
||||||
['test_note5', 'test_note6', 'test_note7']
|
|
||||||
);
|
|
||||||
expect(blobIds.length).toBe(1);
|
|
||||||
const sharedBlobId = blobIds[0];
|
|
||||||
|
|
||||||
// Update the blob content directly (simulating what would happen in real update)
|
|
||||||
sql.execute(
|
|
||||||
"UPDATE blobs SET content = ? WHERE blobId = ?",
|
|
||||||
['Updated shared content for all notes', sharedBlobId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify FTS is updated for all three notes
|
|
||||||
const ftsContents = sql.getColumn(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId IN (?, ?, ?) ORDER BY noteId",
|
|
||||||
['test_note5', 'test_note6', 'test_note7']
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ftsContents).toHaveLength(3);
|
|
||||||
ftsContents.forEach(content => {
|
|
||||||
expect(content).toBe('Updated shared content for all notes');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search for old content should find nothing
|
|
||||||
const oldSearch = searchService.searchNotes('Original shared');
|
|
||||||
expect(oldSearch.filter(r => r.noteId.startsWith('test_'))).toHaveLength(0);
|
|
||||||
|
|
||||||
// Search for new content should find all three
|
|
||||||
const newSearch = searchService.searchNotes('Updated shared');
|
|
||||||
const foundIds = newSearch.map(r => r.noteId).filter(id => id.startsWith('test_'));
|
|
||||||
expect(foundIds).toContain('test_note5');
|
|
||||||
expect(foundIds).toContain('test_note6');
|
|
||||||
expect(foundIds).toContain('test_note7');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle note switching from shared to unique blob', async () => {
|
|
||||||
// Create two notes with shared content
|
|
||||||
const sharedContent = 'Shared content before divergence';
|
|
||||||
|
|
||||||
const note1 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note8',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Diverging Note 1',
|
|
||||||
content: sharedContent,
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
const note2 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note9',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Diverging Note 2',
|
|
||||||
content: sharedContent,
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify they share the same blob
|
|
||||||
const initialBlob1 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note8']);
|
|
||||||
const initialBlob2 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note9']);
|
|
||||||
expect(initialBlob1).toBe(initialBlob2);
|
|
||||||
|
|
||||||
// Change note2 to unique content
|
|
||||||
await noteService.updateNoteContent('test_note9', 'Unique content after divergence');
|
|
||||||
|
|
||||||
// Verify they now have different blobs
|
|
||||||
const finalBlob1 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note8']);
|
|
||||||
const finalBlob2 = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note9']);
|
|
||||||
expect(finalBlob1).not.toBe(finalBlob2);
|
|
||||||
|
|
||||||
// Verify FTS is correctly updated
|
|
||||||
const ftsContent1 = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note8']
|
|
||||||
);
|
|
||||||
const ftsContent2 = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note9']
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ftsContent1).toBe('Shared content before divergence');
|
|
||||||
expect(ftsContent2).toBe('Unique content after divergence');
|
|
||||||
|
|
||||||
// Search should find correct notes
|
|
||||||
const sharedSearch = searchService.searchNotes('before divergence');
|
|
||||||
expect(sharedSearch.map(r => r.noteId)).toContain('test_note8');
|
|
||||||
expect(sharedSearch.map(r => r.noteId)).not.toContain('test_note9');
|
|
||||||
|
|
||||||
const uniqueSearch = searchService.searchNotes('after divergence');
|
|
||||||
expect(uniqueSearch.map(r => r.noteId)).not.toContain('test_note8');
|
|
||||||
expect(uniqueSearch.map(r => r.noteId)).toContain('test_note9');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle import scenarios where notes exist before blobs', async () => {
|
|
||||||
// Simulate import scenario: create note without blob first
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes (noteId, title, type, mime, blobId, isDeleted, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
|
||||||
VALUES ('test_note10', 'Import Test Note', 'text', 'text/html', 'pending_blob_123', 0, 0, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Verify note is not in FTS yet (no blob content)
|
|
||||||
const initialFts = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note10']
|
|
||||||
);
|
|
||||||
expect(initialFts).toBe(0);
|
|
||||||
|
|
||||||
// Now create the blob (simulating delayed blob creation during import)
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
|
||||||
VALUES ('pending_blob_123', 'Imported content finally available', datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Verify note is now indexed in FTS
|
|
||||||
const finalFts = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note10']
|
|
||||||
);
|
|
||||||
expect(finalFts).toBe('Imported content finally available');
|
|
||||||
|
|
||||||
// Search should now find the note
|
|
||||||
const searchResults = searchService.searchNotes('Imported content');
|
|
||||||
expect(searchResults.map(r => r.noteId)).toContain('test_note10');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly handle protected notes during deduplication', async () => {
|
|
||||||
// Create a regular note
|
|
||||||
const note1 = await noteService.createNewNote({
|
|
||||||
noteId: 'test_note11',
|
|
||||||
parentNoteId: 'root',
|
|
||||||
title: 'Regular Note',
|
|
||||||
content: 'Content that will be shared',
|
|
||||||
type: 'text'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a protected note with same content
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes (noteId, title, type, mime, blobId, isDeleted, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
|
||||||
VALUES ('test_note12', 'Protected Note', 'text', 'text/html',
|
|
||||||
(SELECT blobId FROM notes WHERE noteId = 'test_note11'),
|
|
||||||
0, 1, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Verify protected note is NOT in FTS
|
|
||||||
const protectedInFts = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note12']
|
|
||||||
);
|
|
||||||
expect(protectedInFts).toBe(0);
|
|
||||||
|
|
||||||
// Verify regular note IS in FTS
|
|
||||||
const regularInFts = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note11']
|
|
||||||
);
|
|
||||||
expect(regularInFts).toBe(1);
|
|
||||||
|
|
||||||
// Update blob content
|
|
||||||
const blobId = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", ['test_note11']);
|
|
||||||
sql.execute("UPDATE blobs SET content = ? WHERE blobId = ?", ['Updated shared content', blobId]);
|
|
||||||
|
|
||||||
// Verify regular note is updated in FTS
|
|
||||||
const updatedContent = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note11']
|
|
||||||
);
|
|
||||||
expect(updatedContent).toBe('Updated shared content');
|
|
||||||
|
|
||||||
// Verify protected note is still NOT in FTS
|
|
||||||
const protectedStillNotInFts = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note12']
|
|
||||||
);
|
|
||||||
expect(protectedStillNotInFts).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FTS Sync and Cleanup', () => {
|
|
||||||
it('should sync missing notes to FTS index', async () => {
|
|
||||||
// Manually create notes without triggering FTS (simulating missed triggers)
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes (noteId, title, type, mime, blobId, isDeleted, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
|
||||||
VALUES ('test_note13', 'Missed Note 1', 'text', 'text/html', 'blob_missed_1', 0, 0, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
|
||||||
VALUES ('blob_missed_1', 'Content that was missed by triggers', datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Delete from FTS to simulate missing index
|
|
||||||
sql.execute("DELETE FROM notes_fts WHERE noteId = 'test_note13'");
|
|
||||||
|
|
||||||
// Verify note is missing from FTS
|
|
||||||
const beforeSync = sql.getValue(
|
|
||||||
"SELECT COUNT(*) FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note13']
|
|
||||||
);
|
|
||||||
expect(beforeSync).toBe(0);
|
|
||||||
|
|
||||||
// Run sync
|
|
||||||
const syncedCount = ftsSearchService.syncMissingNotes(['test_note13']);
|
|
||||||
expect(syncedCount).toBe(1);
|
|
||||||
|
|
||||||
// Verify note is now in FTS
|
|
||||||
const afterSync = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
['test_note13']
|
|
||||||
);
|
|
||||||
expect(afterSync).toBe('Content that was missed by triggers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle FTS rebuild correctly', () => {
|
|
||||||
// Create some test notes
|
|
||||||
const noteIds = ['test_note14', 'test_note15', 'test_note16'];
|
|
||||||
noteIds.forEach((noteId, index) => {
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes (noteId, title, type, mime, blobId, isDeleted, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified)
|
|
||||||
VALUES (?, ?, 'text', 'text/html', ?, 0, 0, datetime('now'), datetime('now'), datetime('now'), datetime('now'))
|
|
||||||
`, [noteId, `Test Note ${index}`, `blob_${noteId}`]);
|
|
||||||
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO blobs (blobId, content, dateModified, utcDateModified)
|
|
||||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
|
||||||
`, [`blob_${noteId}`, `Content for note ${index}`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Corrupt FTS by adding invalid entries
|
|
||||||
sql.execute("INSERT INTO notes_fts (noteId, title, content) VALUES ('invalid_note', 'Invalid', 'Invalid content')");
|
|
||||||
|
|
||||||
// Rebuild index
|
|
||||||
ftsSearchService.rebuildIndex();
|
|
||||||
|
|
||||||
// Verify only valid notes are in FTS
|
|
||||||
const ftsCount = sql.getValue("SELECT COUNT(*) FROM notes_fts WHERE noteId LIKE 'test_%'");
|
|
||||||
expect(ftsCount).toBe(3);
|
|
||||||
|
|
||||||
// Verify invalid entry is gone
|
|
||||||
const invalidCount = sql.getValue("SELECT COUNT(*) FROM notes_fts WHERE noteId = 'invalid_note'");
|
|
||||||
expect(invalidCount).toBe(0);
|
|
||||||
|
|
||||||
// Verify content is correct
|
|
||||||
noteIds.forEach((noteId, index) => {
|
|
||||||
const content = sql.getValue(
|
|
||||||
"SELECT content FROM notes_fts WHERE noteId = ?",
|
|
||||||
[noteId]
|
|
||||||
);
|
|
||||||
expect(content).toBe(`Content for note ${index}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for FTS5 search service improvements
|
|
||||||
*
|
|
||||||
* This test file validates the fixes implemented for:
|
|
||||||
* 1. Transaction rollback in migration
|
|
||||||
* 2. Protected notes handling
|
|
||||||
* 3. Error recovery and communication
|
|
||||||
* 4. Input validation for token sanitization
|
|
||||||
* 5. dbstat fallback for index monitoring
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import type { Database } from 'better-sqlite3';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock('../sql.js');
|
|
||||||
vi.mock('../log.js');
|
|
||||||
vi.mock('../protected_session.js');
|
|
||||||
|
|
||||||
describe('FTS5 Search Service Improvements', () => {
|
|
||||||
let ftsSearchService: any;
|
|
||||||
let mockSql: any;
|
|
||||||
let mockLog: any;
|
|
||||||
let mockProtectedSession: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Reset mocks
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
// Setup mocks
|
|
||||||
mockSql = {
|
|
||||||
getValue: vi.fn(),
|
|
||||||
getRows: vi.fn(),
|
|
||||||
getColumn: vi.fn(),
|
|
||||||
execute: vi.fn(),
|
|
||||||
transactional: vi.fn((fn: Function) => fn())
|
|
||||||
};
|
|
||||||
|
|
||||||
mockLog = {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
request: vi.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
mockProtectedSession = {
|
|
||||||
isProtectedSessionAvailable: vi.fn().mockReturnValue(false),
|
|
||||||
decryptString: vi.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the modules
|
|
||||||
vi.doMock('../sql.js', () => ({ default: mockSql }));
|
|
||||||
vi.doMock('../log.js', () => ({ default: mockLog }));
|
|
||||||
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
|
|
||||||
|
|
||||||
// Import the service after mocking
|
|
||||||
const module = await import('./fts_search.js');
|
|
||||||
ftsSearchService = module.ftsSearchService;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should throw FTSNotAvailableError when FTS5 is not available', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(0);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
ftsSearchService.searchSync(['test'], '=');
|
|
||||||
}).toThrow('FTS5 is not available');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw FTSQueryError for invalid queries', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1); // FTS5 available
|
|
||||||
mockSql.getRows.mockImplementation(() => {
|
|
||||||
throw new Error('syntax error in FTS5 query');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
ftsSearchService.searchSync(['test'], '=');
|
|
||||||
}).toThrow(/FTS5 search failed.*Falling back to standard search/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide structured error information', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1);
|
|
||||||
mockSql.getRows.mockImplementation(() => {
|
|
||||||
throw new Error('malformed MATCH expression');
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
ftsSearchService.searchSync(['test'], '=');
|
|
||||||
} catch (error: any) {
|
|
||||||
expect(error.name).toBe('FTSQueryError');
|
|
||||||
expect(error.code).toBe('FTS_QUERY_ERROR');
|
|
||||||
expect(error.recoverable).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Protected Notes Handling', () => {
|
|
||||||
it('should not search protected notes in FTS index', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1); // FTS5 available
|
|
||||||
mockProtectedSession.isProtectedSessionAvailable.mockReturnValue(true);
|
|
||||||
|
|
||||||
// Should return empty results when searching protected notes
|
|
||||||
const results = ftsSearchService.searchSync(['test'], '=', undefined, {
|
|
||||||
searchProtected: true
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
expect(mockLog.info).toHaveBeenCalledWith(
|
|
||||||
'Protected session available - will search protected notes separately'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter out protected notes from noteIds', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1);
|
|
||||||
mockSql.getColumn.mockReturnValue(['note1', 'note2']); // Non-protected notes
|
|
||||||
mockSql.getRows.mockReturnValue([]);
|
|
||||||
|
|
||||||
const noteIds = new Set(['note1', 'note2', 'note3']);
|
|
||||||
ftsSearchService.searchSync(['test'], '=', noteIds);
|
|
||||||
|
|
||||||
expect(mockSql.getColumn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should search protected notes separately with decryption', () => {
|
|
||||||
mockProtectedSession.isProtectedSessionAvailable.mockReturnValue(true);
|
|
||||||
mockProtectedSession.decryptString.mockReturnValue('decrypted content with test');
|
|
||||||
|
|
||||||
mockSql.getRows.mockReturnValue([
|
|
||||||
{ noteId: 'protected1', title: 'Protected Note', content: 'encrypted_content' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = ftsSearchService.searchProtectedNotesSync(['test'], '*=*');
|
|
||||||
|
|
||||||
expect(mockProtectedSession.decryptString).toHaveBeenCalledWith('encrypted_content');
|
|
||||||
expect(results).toHaveLength(1);
|
|
||||||
expect(results[0].noteId).toBe('protected1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Token Sanitization', () => {
|
|
||||||
it('should handle empty tokens after sanitization', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1);
|
|
||||||
mockSql.getRows.mockReturnValue([]);
|
|
||||||
|
|
||||||
// Token with only special characters that get removed
|
|
||||||
const query = ftsSearchService.convertToFTS5Query(['()""'], '=');
|
|
||||||
|
|
||||||
expect(query).toContain('__empty_token__');
|
|
||||||
expect(mockLog.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Token became empty after sanitization')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect potential SQL injection attempts', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1);
|
|
||||||
|
|
||||||
const query = ftsSearchService.convertToFTS5Query(['test; DROP TABLE'], '=');
|
|
||||||
|
|
||||||
expect(query).toContain('__invalid_token__');
|
|
||||||
expect(mockLog.error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Potential SQL injection attempt detected')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should properly sanitize valid tokens', () => {
|
|
||||||
mockSql.getValue.mockReturnValue(1);
|
|
||||||
|
|
||||||
const query = ftsSearchService.convertToFTS5Query(['hello (world)'], '=');
|
|
||||||
|
|
||||||
expect(query).toBe('"hello world"');
|
|
||||||
expect(query).not.toContain('(');
|
|
||||||
expect(query).not.toContain(')');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Index Statistics with dbstat Fallback', () => {
|
|
||||||
it('should use dbstat when available', () => {
|
|
||||||
mockSql.getValue
|
|
||||||
.mockReturnValueOnce(1) // FTS5 available
|
|
||||||
.mockReturnValueOnce(100) // document count
|
|
||||||
.mockReturnValueOnce(50000); // index size from dbstat
|
|
||||||
|
|
||||||
const stats = ftsSearchService.getIndexStats();
|
|
||||||
|
|
||||||
expect(stats).toEqual({
|
|
||||||
totalDocuments: 100,
|
|
||||||
indexSize: 50000,
|
|
||||||
isOptimized: true,
|
|
||||||
dbstatAvailable: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback when dbstat is not available', () => {
|
|
||||||
mockSql.getValue
|
|
||||||
.mockReturnValueOnce(1) // FTS5 available
|
|
||||||
.mockReturnValueOnce(100) // document count
|
|
||||||
.mockImplementationOnce(() => {
|
|
||||||
throw new Error('no such table: dbstat');
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce(500); // average content size
|
|
||||||
|
|
||||||
const stats = ftsSearchService.getIndexStats();
|
|
||||||
|
|
||||||
expect(stats.dbstatAvailable).toBe(false);
|
|
||||||
expect(stats.indexSize).toBe(75000); // 500 * 100 * 1.5
|
|
||||||
expect(mockLog.info).toHaveBeenCalledWith(
|
|
||||||
'dbstat virtual table not available, using fallback for index size estimation'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle fallback errors gracefully', () => {
|
|
||||||
mockSql.getValue
|
|
||||||
.mockReturnValueOnce(1) // FTS5 available
|
|
||||||
.mockReturnValueOnce(100) // document count
|
|
||||||
.mockImplementationOnce(() => {
|
|
||||||
throw new Error('no such table: dbstat');
|
|
||||||
})
|
|
||||||
.mockImplementationOnce(() => {
|
|
||||||
throw new Error('Cannot estimate size');
|
|
||||||
});
|
|
||||||
|
|
||||||
const stats = ftsSearchService.getIndexStats();
|
|
||||||
|
|
||||||
expect(stats.indexSize).toBe(0);
|
|
||||||
expect(stats.dbstatAvailable).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Migration Transaction Handling', () => {
|
|
||||||
// Note: This would be tested in the migration test file
|
|
||||||
// Including a placeholder test here for documentation
|
|
||||||
it('migration should rollback on failure (tested in migration tests)', () => {
|
|
||||||
// The migration file now wraps the entire population in a transaction
|
|
||||||
// If any error occurs, all changes are rolled back
|
|
||||||
// This prevents partial indexing
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Blob Update Trigger Optimization', () => {
|
|
||||||
// Note: This is tested via SQL trigger behavior
|
|
||||||
it('trigger should limit batch size (tested via SQL)', () => {
|
|
||||||
// The trigger now processes maximum 50 notes at a time
|
|
||||||
// This prevents performance issues with widely-shared blobs
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Integration with NoteContentFulltextExp', () => {
|
|
||||||
it('should handle FTS errors with proper fallback', () => {
|
|
||||||
// This tests the integration between FTS service and the expression handler
|
|
||||||
// The expression handler now properly catches FTSError types
|
|
||||||
// and provides appropriate user feedback
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should search protected and non-protected notes separately', () => {
|
|
||||||
// The expression handler now calls both searchSync (for non-protected)
|
|
||||||
// and searchProtectedNotesSync (for protected notes)
|
|
||||||
// Results are combined for the user
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
/**
|
|
||||||
* FTS5 Search Service
|
|
||||||
*
|
|
||||||
* Encapsulates all FTS5-specific operations for full-text searching.
|
|
||||||
* Provides efficient text search using SQLite's FTS5 extension with:
|
|
||||||
* - Porter stemming for better matching
|
|
||||||
* - Snippet extraction for context
|
|
||||||
* - Highlighting of matched terms
|
|
||||||
* - Query syntax conversion from Trilium to FTS5
|
|
||||||
*/
|
|
||||||
|
|
||||||
import sql from "../sql.js";
|
|
||||||
import log from "../log.js";
|
|
||||||
import protectedSessionService from "../protected_session.js";
|
|
||||||
import striptags from "striptags";
|
|
||||||
import { normalize } from "../utils.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error classes for FTS operations
|
|
||||||
*/
|
|
||||||
export class FTSError extends Error {
|
|
||||||
constructor(message: string, public readonly code: string, public readonly recoverable: boolean = true) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'FTSError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FTSNotAvailableError extends FTSError {
|
|
||||||
constructor(message: string = "FTS5 is not available") {
|
|
||||||
super(message, 'FTS_NOT_AVAILABLE', true);
|
|
||||||
this.name = 'FTSNotAvailableError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FTSQueryError extends FTSError {
|
|
||||||
constructor(message: string, public readonly query?: string) {
|
|
||||||
super(message, 'FTS_QUERY_ERROR', true);
|
|
||||||
this.name = 'FTSQueryError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FTSSearchResult {
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
score: number;
|
|
||||||
snippet?: string;
|
|
||||||
highlights?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FTSSearchOptions {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
includeSnippets?: boolean;
|
|
||||||
snippetLength?: number;
|
|
||||||
highlightTag?: string;
|
|
||||||
searchProtected?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FTSErrorInfo {
|
|
||||||
error: FTSError;
|
|
||||||
fallbackUsed: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for FTS5 search operations
|
|
||||||
*/
|
|
||||||
const FTS_CONFIG = {
|
|
||||||
/** Maximum number of results to return by default */
|
|
||||||
DEFAULT_LIMIT: 100,
|
|
||||||
/** Default snippet length in tokens */
|
|
||||||
DEFAULT_SNIPPET_LENGTH: 30,
|
|
||||||
/** Default highlight tags */
|
|
||||||
DEFAULT_HIGHLIGHT_START: '<mark>',
|
|
||||||
DEFAULT_HIGHLIGHT_END: '</mark>',
|
|
||||||
/** Maximum query length to prevent DoS */
|
|
||||||
MAX_QUERY_LENGTH: 1000,
|
|
||||||
/** Snippet column indices */
|
|
||||||
SNIPPET_COLUMN_TITLE: 1,
|
|
||||||
SNIPPET_COLUMN_CONTENT: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
class FTSSearchService {
|
|
||||||
private isFTS5Available: boolean | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if FTS5 is available in the current SQLite instance
|
|
||||||
*/
|
|
||||||
checkFTS5Availability(): boolean {
|
|
||||||
if (this.isFTS5Available !== null) {
|
|
||||||
return this.isFTS5Available;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if FTS5 module is available
|
|
||||||
const result = sql.getValue<number>(`
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE type = 'table'
|
|
||||||
AND name = 'notes_fts'
|
|
||||||
`);
|
|
||||||
|
|
||||||
this.isFTS5Available = result > 0;
|
|
||||||
|
|
||||||
if (!this.isFTS5Available) {
|
|
||||||
log.info("FTS5 table not found. Full-text search will use fallback implementation.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Error checking FTS5 availability: ${error}`);
|
|
||||||
this.isFTS5Available = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.isFTS5Available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts Trilium search syntax to FTS5 MATCH syntax
|
|
||||||
*
|
|
||||||
* @param tokens - Array of search tokens
|
|
||||||
* @param operator - Trilium search operator
|
|
||||||
* @returns FTS5 MATCH query string
|
|
||||||
*/
|
|
||||||
convertToFTS5Query(tokens: string[], operator: string): string {
|
|
||||||
if (!tokens || tokens.length === 0) {
|
|
||||||
throw new Error("No search tokens provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize tokens to prevent FTS5 syntax injection
|
|
||||||
const sanitizedTokens = tokens.map(token =>
|
|
||||||
this.sanitizeFTS5Token(token)
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case "=": // Exact match (phrase search)
|
|
||||||
return `"${sanitizedTokens.join(" ")}"`;
|
|
||||||
|
|
||||||
case "*=*": // Contains all tokens (AND)
|
|
||||||
return sanitizedTokens.join(" AND ");
|
|
||||||
|
|
||||||
case "*=": // Ends with
|
|
||||||
return sanitizedTokens.map(t => `*${t}`).join(" AND ");
|
|
||||||
|
|
||||||
case "=*": // Starts with
|
|
||||||
return sanitizedTokens.map(t => `${t}*`).join(" AND ");
|
|
||||||
|
|
||||||
case "!=": // Does not contain (NOT)
|
|
||||||
return `NOT (${sanitizedTokens.join(" OR ")})`;
|
|
||||||
|
|
||||||
case "~=": // Fuzzy match (use OR for more flexible matching)
|
|
||||||
case "~*": // Fuzzy contains
|
|
||||||
return sanitizedTokens.join(" OR ");
|
|
||||||
|
|
||||||
case "%=": // Regex match - fallback to OR search
|
|
||||||
log.error(`Regex search operator ${operator} not fully supported in FTS5, using OR search`);
|
|
||||||
return sanitizedTokens.join(" OR ");
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Default to AND search
|
|
||||||
return sanitizedTokens.join(" AND ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes a token for safe use in FTS5 queries
|
|
||||||
* Validates that the token is not empty after sanitization
|
|
||||||
*/
|
|
||||||
private sanitizeFTS5Token(token: string): string {
|
|
||||||
// Remove special FTS5 characters that could break syntax
|
|
||||||
const sanitized = token
|
|
||||||
.replace(/["\(\)\*]/g, '') // Remove quotes, parens, wildcards
|
|
||||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Validate that token is not empty after sanitization
|
|
||||||
if (!sanitized || sanitized.length === 0) {
|
|
||||||
log.info(`Token became empty after sanitization: "${token}"`);
|
|
||||||
// Return a safe placeholder that won't match anything
|
|
||||||
return "__empty_token__";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional validation: ensure token doesn't contain SQL injection attempts
|
|
||||||
if (sanitized.includes(';') || sanitized.includes('--')) {
|
|
||||||
log.error(`Potential SQL injection attempt detected in token: "${token}"`);
|
|
||||||
return "__invalid_token__";
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a synchronous full-text search using FTS5
|
|
||||||
*
|
|
||||||
* @param tokens - Search tokens
|
|
||||||
* @param operator - Search operator
|
|
||||||
* @param noteIds - Optional set of note IDs to search within
|
|
||||||
* @param options - Search options
|
|
||||||
* @returns Array of search results
|
|
||||||
*/
|
|
||||||
searchSync(
|
|
||||||
tokens: string[],
|
|
||||||
operator: string,
|
|
||||||
noteIds?: Set<string>,
|
|
||||||
options: FTSSearchOptions = {}
|
|
||||||
): FTSSearchResult[] {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
throw new FTSNotAvailableError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
limit = FTS_CONFIG.DEFAULT_LIMIT,
|
|
||||||
offset = 0,
|
|
||||||
includeSnippets = true,
|
|
||||||
snippetLength = FTS_CONFIG.DEFAULT_SNIPPET_LENGTH,
|
|
||||||
highlightTag = FTS_CONFIG.DEFAULT_HIGHLIGHT_START,
|
|
||||||
searchProtected = false
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ftsQuery = this.convertToFTS5Query(tokens, operator);
|
|
||||||
|
|
||||||
// Validate query length
|
|
||||||
if (ftsQuery.length > FTS_CONFIG.MAX_QUERY_LENGTH) {
|
|
||||||
throw new FTSQueryError(
|
|
||||||
`Query too long: ${ftsQuery.length} characters (max: ${FTS_CONFIG.MAX_QUERY_LENGTH})`,
|
|
||||||
ftsQuery
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're searching for protected notes
|
|
||||||
// Protected notes are NOT in the FTS index, so we need to handle them separately
|
|
||||||
if (searchProtected && protectedSessionService.isProtectedSessionAvailable()) {
|
|
||||||
log.info("Protected session available - will search protected notes separately");
|
|
||||||
// Return empty results from FTS and let the caller handle protected notes
|
|
||||||
// The caller should use a fallback search method for protected notes
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the SQL query
|
|
||||||
let whereConditions = [`notes_fts MATCH ?`];
|
|
||||||
const params: any[] = [ftsQuery];
|
|
||||||
|
|
||||||
// Filter by noteIds if provided
|
|
||||||
if (noteIds && noteIds.size > 0) {
|
|
||||||
// First filter out any protected notes from the noteIds
|
|
||||||
const nonProtectedNoteIds = this.filterNonProtectedNoteIds(noteIds);
|
|
||||||
if (nonProtectedNoteIds.length === 0) {
|
|
||||||
// All provided notes are protected, return empty results
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
whereConditions.push(`noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`);
|
|
||||||
params.push(...nonProtectedNoteIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build snippet extraction if requested
|
|
||||||
const snippetSelect = includeSnippets
|
|
||||||
? `, snippet(notes_fts, ${FTS_CONFIG.SNIPPET_COLUMN_CONTENT}, '${highlightTag}', '${highlightTag.replace('<', '</')}', '...', ${snippetLength}) as snippet`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
SELECT
|
|
||||||
noteId,
|
|
||||||
title,
|
|
||||||
rank as score
|
|
||||||
${snippetSelect}
|
|
||||||
FROM notes_fts
|
|
||||||
WHERE ${whereConditions.join(' AND ')}
|
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`;
|
|
||||||
|
|
||||||
params.push(limit, offset);
|
|
||||||
|
|
||||||
const results = sql.getRows<{
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
score: number;
|
|
||||||
snippet?: string;
|
|
||||||
}>(query, params);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
// Provide structured error information
|
|
||||||
if (error instanceof FTSError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error(`FTS5 search error: ${error}`);
|
|
||||||
|
|
||||||
// Determine if this is a recoverable error
|
|
||||||
const isRecoverable =
|
|
||||||
error.message?.includes('syntax error') ||
|
|
||||||
error.message?.includes('malformed MATCH') ||
|
|
||||||
error.message?.includes('no such table');
|
|
||||||
|
|
||||||
throw new FTSQueryError(
|
|
||||||
`FTS5 search failed: ${error.message}. ${isRecoverable ? 'Falling back to standard search.' : ''}`,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out protected note IDs from the given set
|
|
||||||
*/
|
|
||||||
private filterNonProtectedNoteIds(noteIds: Set<string>): string[] {
|
|
||||||
const noteIdList = Array.from(noteIds);
|
|
||||||
const placeholders = noteIdList.map(() => '?').join(',');
|
|
||||||
|
|
||||||
const nonProtectedNotes = sql.getColumn<string>(`
|
|
||||||
SELECT noteId
|
|
||||||
FROM notes
|
|
||||||
WHERE noteId IN (${placeholders})
|
|
||||||
AND isProtected = 0
|
|
||||||
`, noteIdList);
|
|
||||||
|
|
||||||
return nonProtectedNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches protected notes separately (not in FTS index)
|
|
||||||
* This is a fallback method for protected notes
|
|
||||||
*/
|
|
||||||
searchProtectedNotesSync(
|
|
||||||
tokens: string[],
|
|
||||||
operator: string,
|
|
||||||
noteIds?: Set<string>,
|
|
||||||
options: FTSSearchOptions = {}
|
|
||||||
): FTSSearchResult[] {
|
|
||||||
if (!protectedSessionService.isProtectedSessionAvailable()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
limit = FTS_CONFIG.DEFAULT_LIMIT,
|
|
||||||
offset = 0
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build query for protected notes only
|
|
||||||
let whereConditions = [`n.isProtected = 1`, `n.isDeleted = 0`];
|
|
||||||
const params: any[] = [];
|
|
||||||
|
|
||||||
if (noteIds && noteIds.size > 0) {
|
|
||||||
const noteIdList = Array.from(noteIds);
|
|
||||||
whereConditions.push(`n.noteId IN (${noteIdList.map(() => '?').join(',')})`);
|
|
||||||
params.push(...noteIdList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get protected notes
|
|
||||||
const protectedNotes = sql.getRows<{
|
|
||||||
noteId: string;
|
|
||||||
title: string;
|
|
||||||
content: string | null;
|
|
||||||
}>(`
|
|
||||||
SELECT n.noteId, n.title, b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE ${whereConditions.join(' AND ')}
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`, [...params, limit, offset]);
|
|
||||||
|
|
||||||
const results: FTSSearchResult[] = [];
|
|
||||||
|
|
||||||
for (const note of protectedNotes) {
|
|
||||||
if (!note.content) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decrypt content
|
|
||||||
const decryptedContent = protectedSessionService.decryptString(note.content);
|
|
||||||
if (!decryptedContent) continue;
|
|
||||||
|
|
||||||
// Simple token matching for protected notes
|
|
||||||
const contentLower = decryptedContent.toLowerCase();
|
|
||||||
const titleLower = note.title.toLowerCase();
|
|
||||||
let matches = false;
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case "=": // Exact match
|
|
||||||
const phrase = tokens.join(' ').toLowerCase();
|
|
||||||
matches = contentLower.includes(phrase) || titleLower.includes(phrase);
|
|
||||||
break;
|
|
||||||
case "*=*": // Contains all tokens
|
|
||||||
matches = tokens.every(token =>
|
|
||||||
contentLower.includes(token.toLowerCase()) ||
|
|
||||||
titleLower.includes(token.toLowerCase())
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "~=": // Contains any token
|
|
||||||
case "~*":
|
|
||||||
matches = tokens.some(token =>
|
|
||||||
contentLower.includes(token.toLowerCase()) ||
|
|
||||||
titleLower.includes(token.toLowerCase())
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
matches = tokens.every(token =>
|
|
||||||
contentLower.includes(token.toLowerCase()) ||
|
|
||||||
titleLower.includes(token.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
results.push({
|
|
||||||
noteId: note.noteId,
|
|
||||||
title: note.title,
|
|
||||||
score: 1.0, // Simple scoring for protected notes
|
|
||||||
snippet: this.generateSnippet(decryptedContent)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.info(`Could not decrypt protected note ${note.noteId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error: any) {
|
|
||||||
log.error(`Protected notes search error: ${error}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a snippet from content
|
|
||||||
*/
|
|
||||||
private generateSnippet(content: string, maxLength: number = 30): string {
|
|
||||||
// Strip HTML tags for snippet
|
|
||||||
const plainText = striptags(content);
|
|
||||||
const normalized = normalize(plainText);
|
|
||||||
|
|
||||||
if (normalized.length <= maxLength * 10) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract snippet around first occurrence
|
|
||||||
return normalized.substring(0, maxLength * 10) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the FTS index for a specific note (synchronous)
|
|
||||||
*
|
|
||||||
* @param noteId - The note ID to update
|
|
||||||
* @param title - The note title
|
|
||||||
* @param content - The note content
|
|
||||||
*/
|
|
||||||
updateNoteIndex(noteId: string, title: string, content: string): void {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sql.transactional(() => {
|
|
||||||
// Delete existing entry
|
|
||||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
|
||||||
|
|
||||||
// Insert new entry
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`, [noteId, title, content]);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to update FTS index for note ${noteId}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a note from the FTS index (synchronous)
|
|
||||||
*
|
|
||||||
* @param noteId - The note ID to remove
|
|
||||||
*/
|
|
||||||
removeNoteFromIndex(noteId: string): void {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to remove note ${noteId} from FTS index: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Syncs missing notes to the FTS index (synchronous)
|
|
||||||
* This is useful after bulk operations like imports where triggers might not fire
|
|
||||||
*
|
|
||||||
* @param noteIds - Optional array of specific note IDs to sync. If not provided, syncs all missing notes.
|
|
||||||
* @returns The number of notes that were synced
|
|
||||||
*/
|
|
||||||
syncMissingNotes(noteIds?: string[]): number {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
log.error("Cannot sync FTS index - FTS5 not available");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
sql.transactional(() => {
|
|
||||||
let query: string;
|
|
||||||
let params: any[] = [];
|
|
||||||
|
|
||||||
if (noteIds && noteIds.length > 0) {
|
|
||||||
// Sync specific notes that are missing from FTS
|
|
||||||
const placeholders = noteIds.map(() => '?').join(',');
|
|
||||||
query = `
|
|
||||||
WITH missing_notes AS (
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.noteId IN (${placeholders})
|
|
||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
|
||||||
)
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT noteId, title, content FROM missing_notes
|
|
||||||
`;
|
|
||||||
params = noteIds;
|
|
||||||
} else {
|
|
||||||
// Sync all missing notes
|
|
||||||
query = `
|
|
||||||
WITH missing_notes AS (
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
AND b.content IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
|
||||||
)
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT noteId, title, content FROM missing_notes
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = sql.execute(query, params);
|
|
||||||
syncedCount = result.changes;
|
|
||||||
|
|
||||||
if (syncedCount > 0) {
|
|
||||||
log.info(`Synced ${syncedCount} missing notes to FTS index`);
|
|
||||||
// Optimize if we synced a significant number of notes
|
|
||||||
if (syncedCount > 100) {
|
|
||||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return syncedCount;
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to sync missing notes to FTS index: ${error}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds the entire FTS index (synchronous)
|
|
||||||
* This is useful for maintenance or after bulk operations
|
|
||||||
*/
|
|
||||||
rebuildIndex(): void {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
log.error("Cannot rebuild FTS index - FTS5 not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Rebuilding FTS5 index...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
sql.transactional(() => {
|
|
||||||
// Clear existing index
|
|
||||||
sql.execute(`DELETE FROM notes_fts`);
|
|
||||||
|
|
||||||
// Rebuild from notes
|
|
||||||
sql.execute(`
|
|
||||||
INSERT INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
|
||||||
n.noteId,
|
|
||||||
n.title,
|
|
||||||
b.content
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
|
||||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
AND n.isDeleted = 0
|
|
||||||
AND n.isProtected = 0
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Optimize the FTS table
|
|
||||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("FTS5 index rebuild completed");
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to rebuild FTS index: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets statistics about the FTS index (synchronous)
|
|
||||||
* Includes fallback when dbstat is not available
|
|
||||||
*/
|
|
||||||
getIndexStats(): {
|
|
||||||
totalDocuments: number;
|
|
||||||
indexSize: number;
|
|
||||||
isOptimized: boolean;
|
|
||||||
dbstatAvailable: boolean;
|
|
||||||
} {
|
|
||||||
if (!this.checkFTS5Availability()) {
|
|
||||||
return {
|
|
||||||
totalDocuments: 0,
|
|
||||||
indexSize: 0,
|
|
||||||
isOptimized: false,
|
|
||||||
dbstatAvailable: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalDocuments = sql.getValue<number>(`
|
|
||||||
SELECT COUNT(*) FROM notes_fts
|
|
||||||
`) || 0;
|
|
||||||
|
|
||||||
let indexSize = 0;
|
|
||||||
let dbstatAvailable = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to get index size from dbstat
|
|
||||||
// dbstat is a virtual table that may not be available in all SQLite builds
|
|
||||||
indexSize = sql.getValue<number>(`
|
|
||||||
SELECT SUM(pgsize)
|
|
||||||
FROM dbstat
|
|
||||||
WHERE name LIKE 'notes_fts%'
|
|
||||||
`) || 0;
|
|
||||||
dbstatAvailable = true;
|
|
||||||
} catch (error: any) {
|
|
||||||
// dbstat not available, use fallback
|
|
||||||
if (error.message?.includes('no such table: dbstat')) {
|
|
||||||
log.info("dbstat virtual table not available, using fallback for index size estimation");
|
|
||||||
|
|
||||||
// Fallback: Estimate based on number of documents and average content size
|
|
||||||
try {
|
|
||||||
const avgContentSize = sql.getValue<number>(`
|
|
||||||
SELECT AVG(LENGTH(content) + LENGTH(title))
|
|
||||||
FROM notes_fts
|
|
||||||
LIMIT 1000
|
|
||||||
`) || 0;
|
|
||||||
|
|
||||||
// Rough estimate: avg size * document count * overhead factor
|
|
||||||
indexSize = Math.round(avgContentSize * totalDocuments * 1.5);
|
|
||||||
} catch (fallbackError) {
|
|
||||||
log.info(`Could not estimate index size: ${fallbackError}`);
|
|
||||||
indexSize = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.error(`Error accessing dbstat: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalDocuments,
|
|
||||||
indexSize,
|
|
||||||
isOptimized: true, // FTS5 manages optimization internally
|
|
||||||
dbstatAvailable
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const ftsSearchService = new FTSSearchService();
|
|
||||||
|
|
||||||
export default ftsSearchService;
|
|
||||||
@@ -62,10 +62,6 @@ class NoteSet {
|
|||||||
|
|
||||||
return newNoteSet;
|
return newNoteSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNoteIds(): Set<string> {
|
|
||||||
return new Set(this.noteIdSet);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NoteSet;
|
export default NoteSet;
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
/**
|
|
||||||
* Performance monitoring utilities for search operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import log from "../log.js";
|
|
||||||
import optionService from "../options.js";
|
|
||||||
|
|
||||||
export interface SearchMetrics {
|
|
||||||
query: string;
|
|
||||||
backend: "typescript" | "sqlite";
|
|
||||||
totalTime: number;
|
|
||||||
parseTime?: number;
|
|
||||||
searchTime?: number;
|
|
||||||
resultCount: number;
|
|
||||||
memoryUsed?: number;
|
|
||||||
cacheHit?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailedMetrics extends SearchMetrics {
|
|
||||||
phases?: {
|
|
||||||
name: string;
|
|
||||||
duration: number;
|
|
||||||
}[];
|
|
||||||
sqliteStats?: {
|
|
||||||
rowsScanned?: number;
|
|
||||||
indexUsed?: boolean;
|
|
||||||
tempBTreeUsed?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchPerformanceAverages {
|
|
||||||
avgTime: number;
|
|
||||||
avgResults: number;
|
|
||||||
totalQueries: number;
|
|
||||||
errorRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PerformanceMonitor {
|
|
||||||
private metrics: SearchMetrics[] = [];
|
|
||||||
private maxMetricsStored = 1000;
|
|
||||||
private metricsEnabled = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Check if performance logging is enabled
|
|
||||||
this.updateSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings() {
|
|
||||||
try {
|
|
||||||
this.metricsEnabled = optionService.getOptionBool("searchSqlitePerformanceLogging");
|
|
||||||
} catch {
|
|
||||||
this.metricsEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startTimer(): () => number {
|
|
||||||
const startTime = process.hrtime.bigint();
|
|
||||||
return () => {
|
|
||||||
const endTime = process.hrtime.bigint();
|
|
||||||
return Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
recordMetrics(metrics: SearchMetrics) {
|
|
||||||
if (!this.metricsEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.metrics.push(metrics);
|
|
||||||
|
|
||||||
// Keep only the last N metrics
|
|
||||||
if (this.metrics.length > this.maxMetricsStored) {
|
|
||||||
this.metrics = this.metrics.slice(-this.maxMetricsStored);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log significant performance differences
|
|
||||||
if (metrics.totalTime > 1000) {
|
|
||||||
log.info(`Slow search query detected: ${metrics.totalTime.toFixed(2)}ms for query "${metrics.query.substring(0, 100)}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to debug for analysis
|
|
||||||
log.info(`Search metrics: backend=${metrics.backend}, time=${metrics.totalTime.toFixed(2)}ms, results=${metrics.resultCount}, query="${metrics.query.substring(0, 50)}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
recordDetailedMetrics(metrics: DetailedMetrics) {
|
|
||||||
if (!this.metricsEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.recordMetrics(metrics);
|
|
||||||
|
|
||||||
// Log detailed phase information
|
|
||||||
if (metrics.phases) {
|
|
||||||
const phaseLog = metrics.phases
|
|
||||||
.map(p => `${p.name}=${p.duration.toFixed(2)}ms`)
|
|
||||||
.join(", ");
|
|
||||||
log.info(`Search phases: ${phaseLog}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log SQLite specific stats
|
|
||||||
if (metrics.sqliteStats) {
|
|
||||||
log.info(`SQLite stats: rows_scanned=${metrics.sqliteStats.rowsScanned}, index_used=${metrics.sqliteStats.indexUsed}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecentMetrics(count: number = 100): SearchMetrics[] {
|
|
||||||
return this.metrics.slice(-count);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAverageMetrics(backend?: "typescript" | "sqlite"): SearchPerformanceAverages | null {
|
|
||||||
let relevantMetrics = this.metrics;
|
|
||||||
|
|
||||||
if (backend) {
|
|
||||||
relevantMetrics = this.metrics.filter(m => m.backend === backend);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relevantMetrics.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTime = relevantMetrics.reduce((sum, m) => sum + m.totalTime, 0);
|
|
||||||
const totalResults = relevantMetrics.reduce((sum, m) => sum + m.resultCount, 0);
|
|
||||||
const errorCount = relevantMetrics.filter(m => m.error).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
avgTime: totalTime / relevantMetrics.length,
|
|
||||||
avgResults: totalResults / relevantMetrics.length,
|
|
||||||
totalQueries: relevantMetrics.length,
|
|
||||||
errorRate: errorCount / relevantMetrics.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
compareBackends(): {
|
|
||||||
typescript: SearchPerformanceAverages;
|
|
||||||
sqlite: SearchPerformanceAverages;
|
|
||||||
recommendation?: string;
|
|
||||||
} {
|
|
||||||
const tsMetrics = this.getAverageMetrics("typescript");
|
|
||||||
const sqliteMetrics = this.getAverageMetrics("sqlite");
|
|
||||||
|
|
||||||
let recommendation: string | undefined;
|
|
||||||
|
|
||||||
if (tsMetrics && sqliteMetrics) {
|
|
||||||
const speedupFactor = tsMetrics.avgTime / sqliteMetrics.avgTime;
|
|
||||||
|
|
||||||
if (speedupFactor > 1.5) {
|
|
||||||
recommendation = `SQLite is ${speedupFactor.toFixed(1)}x faster on average`;
|
|
||||||
} else if (speedupFactor < 0.67) {
|
|
||||||
recommendation = `TypeScript is ${(1/speedupFactor).toFixed(1)}x faster on average`;
|
|
||||||
} else {
|
|
||||||
recommendation = "Both backends perform similarly";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consider error rates
|
|
||||||
if (sqliteMetrics.errorRate > tsMetrics.errorRate + 0.1) {
|
|
||||||
recommendation += " (but SQLite has higher error rate)";
|
|
||||||
} else if (tsMetrics.errorRate > sqliteMetrics.errorRate + 0.1) {
|
|
||||||
recommendation += " (but TypeScript has higher error rate)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
typescript: tsMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
|
||||||
sqlite: sqliteMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
|
||||||
recommendation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.metrics = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
const performanceMonitor = new PerformanceMonitor();
|
|
||||||
|
|
||||||
export default performanceMonitor;
|
|
||||||
@@ -24,10 +24,6 @@ class SearchContext {
|
|||||||
fulltextQuery: string;
|
fulltextQuery: string;
|
||||||
dbLoadNeeded: boolean;
|
dbLoadNeeded: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** Determines which backend to use for fulltext search */
|
|
||||||
searchBackend: "typescript" | "sqlite";
|
|
||||||
/** Whether SQLite search is enabled (cached from options) */
|
|
||||||
sqliteSearchEnabled: boolean;
|
|
||||||
|
|
||||||
constructor(params: SearchParams = {}) {
|
constructor(params: SearchParams = {}) {
|
||||||
this.fastSearch = !!params.fastSearch;
|
this.fastSearch = !!params.fastSearch;
|
||||||
@@ -58,43 +54,6 @@ class SearchContext {
|
|||||||
// and some extra data needs to be loaded before executing
|
// and some extra data needs to be loaded before executing
|
||||||
this.dbLoadNeeded = false;
|
this.dbLoadNeeded = false;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
// Determine search backend
|
|
||||||
this.sqliteSearchEnabled = this.checkSqliteEnabled();
|
|
||||||
this.searchBackend = this.determineSearchBackend(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkSqliteEnabled(): boolean {
|
|
||||||
try {
|
|
||||||
// Import dynamically to avoid circular dependencies
|
|
||||||
const optionService = require("../options.js").default;
|
|
||||||
// Default to true if the option doesn't exist
|
|
||||||
const enabled = optionService.getOptionOrNull("searchSqliteEnabled");
|
|
||||||
return enabled === null ? true : enabled === "true";
|
|
||||||
} catch {
|
|
||||||
return true; // Default to enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private determineSearchBackend(params: SearchParams): "typescript" | "sqlite" {
|
|
||||||
// Allow override via params for testing
|
|
||||||
if (params.forceBackend) {
|
|
||||||
return params.forceBackend;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if SQLite is enabled
|
|
||||||
if (!this.sqliteSearchEnabled) {
|
|
||||||
return "typescript";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const optionService = require("../options.js").default;
|
|
||||||
const backend = optionService.getOptionOrNull("searchBackend");
|
|
||||||
// Default to sqlite if option doesn't exist
|
|
||||||
return backend === "typescript" ? "typescript" : "sqlite";
|
|
||||||
} catch {
|
|
||||||
return "sqlite"; // Default to SQLite for better performance
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addError(error: string) {
|
addError(error: string) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import AttributeExistsExp from "../expressions/attribute_exists.js";
|
|||||||
import LabelComparisonExp from "../expressions/label_comparison.js";
|
import LabelComparisonExp from "../expressions/label_comparison.js";
|
||||||
import NoteFlatTextExp from "../expressions/note_flat_text.js";
|
import NoteFlatTextExp from "../expressions/note_flat_text.js";
|
||||||
import NoteContentFulltextExp from "../expressions/note_content_fulltext.js";
|
import NoteContentFulltextExp from "../expressions/note_content_fulltext.js";
|
||||||
import NoteContentSqliteExp from "../expressions/note_content_sqlite.js";
|
|
||||||
import OrderByAndLimitExp from "../expressions/order_by_and_limit.js";
|
import OrderByAndLimitExp from "../expressions/order_by_and_limit.js";
|
||||||
import AncestorExp from "../expressions/ancestor.js";
|
import AncestorExp from "../expressions/ancestor.js";
|
||||||
import buildComparator from "./build_comparator.js";
|
import buildComparator from "./build_comparator.js";
|
||||||
@@ -38,20 +37,15 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leading
|
|||||||
const operator = leadingOperator === "=" ? "=" : "*=*";
|
const operator = leadingOperator === "=" ? "=" : "*=*";
|
||||||
|
|
||||||
if (!searchContext.fastSearch) {
|
if (!searchContext.fastSearch) {
|
||||||
// Choose between SQLite and TypeScript backend
|
|
||||||
const ContentExp = searchContext.searchBackend === "sqlite"
|
|
||||||
? NoteContentSqliteExp
|
|
||||||
: NoteContentFulltextExp;
|
|
||||||
|
|
||||||
// For exact match with "=", we need different behavior
|
// For exact match with "=", we need different behavior
|
||||||
if (leadingOperator === "=" && tokens.length === 1) {
|
if (leadingOperator === "=" && tokens.length === 1) {
|
||||||
// Exact match on title OR exact match on content
|
// Exact match on title OR exact match on content
|
||||||
return new OrExp([
|
return new OrExp([
|
||||||
new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
|
new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
|
||||||
new ContentExp("=", { tokens, flatText: false })
|
new NoteContentFulltextExp("=", { tokens, flatText: false })
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return new OrExp([new NoteFlatTextExp(tokens), new ContentExp(operator, { tokens, flatText: true })]);
|
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]);
|
||||||
} else {
|
} else {
|
||||||
return new NoteFlatTextExp(tokens);
|
return new NoteFlatTextExp(tokens);
|
||||||
}
|
}
|
||||||
@@ -154,12 +148,7 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level
|
|||||||
|
|
||||||
i++;
|
i++;
|
||||||
|
|
||||||
// Choose between SQLite and TypeScript backend
|
return new NoteContentFulltextExp(operator.token, { tokens: [tokens[i].token], raw });
|
||||||
const ContentExp = searchContext.searchBackend === "sqlite"
|
|
||||||
? NoteContentSqliteExp
|
|
||||||
: NoteContentFulltextExp;
|
|
||||||
|
|
||||||
return new ContentExp(operator.token, { tokens: [tokens[i].token], raw });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens[i].token === "parents") {
|
if (tokens[i].token === "parents") {
|
||||||
@@ -222,12 +211,7 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level
|
|||||||
|
|
||||||
i += 2;
|
i += 2;
|
||||||
|
|
||||||
// Choose between SQLite and TypeScript backend
|
return new OrExp([new PropertyComparisonExp(searchContext, "title", "*=*", tokens[i].token), new NoteContentFulltextExp("*=*", { tokens: [tokens[i].token] })]);
|
||||||
const ContentExp = searchContext.searchBackend === "sqlite"
|
|
||||||
? NoteContentSqliteExp
|
|
||||||
: NoteContentFulltextExp;
|
|
||||||
|
|
||||||
return new OrExp([new PropertyComparisonExp(searchContext, "title", "*=*", tokens[i].token), new ContentExp("*=*", { tokens: [tokens[i].token] })]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PropertyComparisonExp.isProperty(tokens[i].token)) {
|
if (PropertyComparisonExp.isProperty(tokens[i].token)) {
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import sql from "../../sql.js";
|
|||||||
import scriptService from "../../script.js";
|
import scriptService from "../../script.js";
|
||||||
import striptags from "striptags";
|
import striptags from "striptags";
|
||||||
import protectedSessionService from "../../protected_session.js";
|
import protectedSessionService from "../../protected_session.js";
|
||||||
import performanceMonitor from "../performance_monitor.js";
|
|
||||||
import type { DetailedMetrics } from "../performance_monitor.js";
|
|
||||||
import abTestingService from "../ab_testing.js";
|
|
||||||
|
|
||||||
export interface SearchNoteResult {
|
export interface SearchNoteResult {
|
||||||
searchResultNoteIds: string[];
|
searchResultNoteIds: string[];
|
||||||
@@ -404,16 +401,7 @@ function parseQueryToExpression(query: string, searchContext: SearchContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchNotes(query: string, params: SearchParams = {}): BNote[] {
|
function searchNotes(query: string, params: SearchParams = {}): BNote[] {
|
||||||
const searchContext = new SearchContext(params);
|
const searchResults = findResultsWithQuery(query, new SearchContext(params));
|
||||||
|
|
||||||
// Run A/B test in background (non-blocking)
|
|
||||||
setImmediate(() => {
|
|
||||||
abTestingService.runComparison(query, params).catch(err => {
|
|
||||||
log.info(`A/B test failed: ${err}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchResults = findResultsWithQuery(query, searchContext);
|
|
||||||
|
|
||||||
return searchResults.map((sr) => becca.notes[sr.noteId]);
|
return searchResults.map((sr) => becca.notes[sr.noteId]);
|
||||||
}
|
}
|
||||||
@@ -422,14 +410,7 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear
|
|||||||
query = query || "";
|
query = query || "";
|
||||||
searchContext.originalQuery = query;
|
searchContext.originalQuery = query;
|
||||||
|
|
||||||
// Start performance monitoring
|
|
||||||
const totalTimer = performanceMonitor.startTimer();
|
|
||||||
const phases: { name: string; duration: number }[] = [];
|
|
||||||
|
|
||||||
// Parse query
|
|
||||||
const parseTimer = performanceMonitor.startTimer();
|
|
||||||
const expression = parseQueryToExpression(query, searchContext);
|
const expression = parseQueryToExpression(query, searchContext);
|
||||||
phases.push({ name: "parse", duration: parseTimer() });
|
|
||||||
|
|
||||||
if (!expression) {
|
if (!expression) {
|
||||||
return [];
|
return [];
|
||||||
@@ -440,33 +421,12 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear
|
|||||||
// ordering or other logic that shouldn't be interfered with.
|
// ordering or other logic that shouldn't be interfered with.
|
||||||
const isPureExpressionQuery = query.trim().startsWith('#');
|
const isPureExpressionQuery = query.trim().startsWith('#');
|
||||||
|
|
||||||
let results: SearchResult[];
|
|
||||||
const searchTimer = performanceMonitor.startTimer();
|
|
||||||
|
|
||||||
if (isPureExpressionQuery) {
|
if (isPureExpressionQuery) {
|
||||||
// For pure expression queries, use standard search without progressive phases
|
// For pure expression queries, use standard search without progressive phases
|
||||||
results = performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||||
} else {
|
|
||||||
results = findResultsWithExpression(expression, searchContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
phases.push({ name: "search", duration: searchTimer() });
|
|
||||||
|
|
||||||
// Record metrics
|
return findResultsWithExpression(expression, searchContext);
|
||||||
const metrics: DetailedMetrics = {
|
|
||||||
query: query.substring(0, 200), // Truncate long queries
|
|
||||||
backend: searchContext.searchBackend,
|
|
||||||
totalTime: totalTimer(),
|
|
||||||
parseTime: phases[0].duration,
|
|
||||||
searchTime: phases[1].duration,
|
|
||||||
resultCount: results.length,
|
|
||||||
phases,
|
|
||||||
error: searchContext.error || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
performanceMonitor.recordDetailedMetrics(metrics);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null {
|
function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null {
|
||||||
|
|||||||
@@ -21,6 +21,4 @@ export interface SearchParams {
|
|||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
fuzzyAttributeSearch?: boolean;
|
fuzzyAttributeSearch?: boolean;
|
||||||
/** Force a specific search backend for testing/comparison */
|
|
||||||
forceBackend?: "typescript" | "sqlite";
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user