Compare commits

..

81 Commits

Author SHA1 Message Date
Adorian Doran
638cb4281e style/center pane: optimize the identification of options pages 2025-10-25 11:28:26 +03:00
Adorian Doran
1568908982 style/center pane: allow distinct background colors for note splits 2025-10-25 11:12:00 +03:00
Adorian Doran
4459561308 Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements 2025-10-25 10:20:28 +03:00
Elian Doran
b8e17959ae Translations update from Hosted Weblate (#7512) 2025-10-25 09:19:43 +03:00
Sarah Hussein
c16a135efc Translated using Weblate (Arabic)
Currently translated at 55.4% (81 of 146 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/ar/
2025-10-25 06:18:55 +00:00
Sarah Hussein
cbc756ba06 Translated using Weblate (Arabic)
Currently translated at 85.7% (332 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ar/
2025-10-25 06:18:54 +00:00
Sarah Hussein
64daeb0826 Translated using Weblate (Arabic)
Currently translated at 65.0% (1055 of 1621 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ar/
2025-10-25 06:18:53 +00:00
Hosted Weblate
e15839db47 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2025-10-25 06:18:53 +00:00
Elian Doran
dcdffed003 chore(deps): remove unused types for session-file-store 2025-10-25 09:18:41 +03:00
Elian Doran
354f1d65c1 chore(deps): update dependency eslint-plugin-react-hooks to v7.0.1 (#7491) 2025-10-25 09:14:40 +03:00
Elian Doran
9310315c6a chore(deps): update dependency @types/tabulator-tables to v6.3.0 (#7499) 2025-10-25 09:13:38 +03:00
Elian Doran
b3bc0572e5 chore(deps): update ckeditor5 config packages to v12.2.0 (#7498) 2025-10-25 09:12:04 +03:00
Elian Doran
253ce1f223 fix(deps): update dependency preact-render-to-string to v6.6.3 (#7497) 2025-10-25 09:11:45 +03:00
Elian Doran
2f3bf94b47 fix(deps): update dependency mind-elixir to v5.3.4 (#7496) 2025-10-25 09:11:26 +03:00
Elian Doran
d802caa03b chore(deps): update dependency turndown to v7.2.2 (#7494) 2025-10-25 09:10:35 +03:00
renovate[bot]
e69751a8b3 fix(deps): update dependency preact-render-to-string to v6.6.3 2025-10-25 06:10:07 +00:00
Elian Doran
0760ea22fb chore(deps): update dependency lint-staged to v16.2.6 (#7493) 2025-10-25 09:09:27 +03:00
Elian Doran
8a8f407e99 chore(deps): update dependency happy-dom to v20.0.8 (#7492) 2025-10-25 09:09:09 +03:00
renovate[bot]
e030dd96da chore(deps): update dependency eslint-plugin-react-hooks to v7.0.1 2025-10-25 06:08:45 +00:00
Elian Doran
01abfc2528 chore(deps): update dependency @types/yargs to v17.0.34 (#7490) 2025-10-25 09:08:36 +03:00
Elian Doran
042b929dc5 chore(deps): update dependency @types/serve-static to v1.15.10 (#7488) 2025-10-25 09:08:03 +03:00
Elian Doran
ab1d5e31fb chore(deps): update dependency @types/cookie-parser to v1.4.10 (#7486) 2025-10-25 09:07:41 +03:00
Elian Doran
d073e4c37f chore(deps): update dependency @types/archiver to v6.0.4 (#7485) 2025-10-25 09:07:29 +03:00
Elian Doran
d60d965a42 chore(deps): update dependency @smithy/middleware-retry to v4.4.5 (#7484) 2025-10-25 09:07:13 +03:00
Elian Doran
1c87cfbbd9 chore(deps): update dependency ini to v6 (#7507) 2025-10-25 09:06:21 +03:00
Elian Doran
fee333512a chore(deps): update dependency openai to v6.7.0 (#7502) 2025-10-25 09:05:53 +03:00
Elian Doran
38a3f46506 chore(deps): update node.js to v22.21.0 (#7503) 2025-10-25 09:05:35 +03:00
Elian Doran
bf7506fcd8 chore(deps): update pnpm to v10.19.0 (#7504) 2025-10-25 09:05:05 +03:00
Elian Doran
6fbba426de fix(deps): update codemirror (#7505) 2025-10-25 09:04:29 +03:00
Elian Doran
d5bdec13b5 fix(deps): update dependency react-i18next to v16.2.0 (#7506) 2025-10-25 09:04:02 +03:00
Elian Doran
cc1b6eb42d chore(deps): update github artifact actions (major) (#7508) 2025-10-25 09:03:36 +03:00
renovate[bot]
8baf496f96 chore(deps): update github artifact actions 2025-10-25 01:23:17 +00:00
renovate[bot]
23a20c4490 chore(deps): update dependency ini to v6 2025-10-25 01:23:11 +00:00
renovate[bot]
c8b98f2db6 fix(deps): update dependency react-i18next to v16.2.0 2025-10-25 01:22:39 +00:00
renovate[bot]
3f36f515db fix(deps): update codemirror 2025-10-25 01:22:07 +00:00
renovate[bot]
892eb5b95d chore(deps): update pnpm to v10.19.0 2025-10-25 01:21:37 +00:00
renovate[bot]
62a69a0da0 chore(deps): update node.js to v22.21.0 2025-10-25 01:21:29 +00:00
renovate[bot]
3588e38543 chore(deps): update dependency openai to v6.7.0 2025-10-25 01:21:24 +00:00
renovate[bot]
41450ab85a chore(deps): update dependency @types/tabulator-tables to v6.3.0 2025-10-25 01:19:49 +00:00
renovate[bot]
0526d99560 chore(deps): update ckeditor5 config packages to v12.2.0 2025-10-25 01:19:14 +00:00
renovate[bot]
557d576b85 fix(deps): update dependency mind-elixir to v5.3.4 2025-10-25 01:18:06 +00:00
renovate[bot]
041c961cfa chore(deps): update dependency turndown to v7.2.2 2025-10-25 01:16:54 +00:00
renovate[bot]
dcc35bd507 chore(deps): update dependency lint-staged to v16.2.6 2025-10-25 01:16:19 +00:00
renovate[bot]
09c3e5b56e chore(deps): update dependency happy-dom to v20.0.8 2025-10-25 01:15:41 +00:00
renovate[bot]
950793377d chore(deps): update dependency @types/yargs to v17.0.34 2025-10-25 01:13:27 +00:00
renovate[bot]
7dac61dc26 chore(deps): update dependency @types/serve-static to v1.15.10 2025-10-25 01:11:50 +00:00
renovate[bot]
42dcb8f141 chore(deps): update dependency @types/cookie-parser to v1.4.10 2025-10-25 01:10:08 +00:00
renovate[bot]
43dc8a4b87 chore(deps): update dependency @types/archiver to v6.0.4 2025-10-25 01:09:20 +00:00
renovate[bot]
35316a4c45 chore(deps): update dependency @smithy/middleware-retry to v4.4.5 2025-10-25 01:08:32 +00:00
Elian Doran
1366489f99 Translations update from Hosted Weblate (#7479) 2025-10-24 23:31:57 +03:00
brtkcs
31ee78b1aa Translated using Weblate (Hungarian)
Currently translated at 21.2% (31 of 146 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/hu/
2025-10-24 20:14:05 +00:00
brtkcs
808ba75ee0 Translated using Weblate (Hungarian)
Currently translated at 27.1% (32 of 118 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/hu/
2025-10-24 20:14:04 +00:00
brtkcs
ac1399a139 Translated using Weblate (Hungarian)
Currently translated at 8.2% (32 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/hu/
2025-10-24 20:14:04 +00:00
brtkcs
1e4793351a Translated using Weblate (Hungarian)
Currently translated at 1.9% (32 of 1621 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/hu/
2025-10-24 20:14:03 +00:00
Sarah Hussein
f502fe41c7 Translated using Weblate (Arabic)
Currently translated at 52.7% (77 of 146 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/ar/
2025-10-24 20:14:02 +00:00
brtkcs
0ec0091357 Translated using Weblate (Hungarian)
Currently translated at 19.1% (28 of 146 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/hu/
2025-10-24 20:14:02 +00:00
brtkcs
0e2196f872 Translated using Weblate (Hungarian)
Currently translated at 24.5% (29 of 118 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/hu/
2025-10-24 20:14:01 +00:00
Sarah Hussein
32dee254cd Translated using Weblate (Arabic)
Currently translated at 82.4% (319 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ar/
2025-10-24 20:14:01 +00:00
Sarah Hussein
d4a6a297f4 Translated using Weblate (Arabic)
Currently translated at 64.3% (1043 of 1621 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ar/
2025-10-24 20:14:00 +00:00
brtkcs
a64d8cd8e2 Translated using Weblate (Hungarian)
Currently translated at 7.4% (29 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/hu/
2025-10-24 20:14:00 +00:00
brtkcs
bf4cfb9c02 Translated using Weblate (Hungarian)
Currently translated at 1.7% (29 of 1621 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/hu/
2025-10-24 20:13:59 +00:00
Manfred Manni
a99dfecf43 Translated using Weblate (German)
Currently translated at 100.0% (387 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/de/
2025-10-24 20:13:59 +00:00
Manfred Manni
1530d96eca Translated using Weblate (German)
Currently translated at 99.8% (1619 of 1621 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/de/
2025-10-24 20:13:58 +00:00
Elian Doran
5dc066f4c6 chore(dev): add work-around to run on Ubuntu
See https://github.com/electron/electron/issues/42510.
2025-10-24 23:03:20 +03:00
Adorian Doran
3341e59a80 Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements 2025-10-24 02:43:52 +03:00
Adorian Doran
74a805056b style/settings: use Mica for settings pages 2025-10-24 01:03:28 +03:00
Adorian Doran
f42e870de1 style/dropdowns: increase the radius of the backdrop blur 2025-10-23 20:28:36 +03:00
Adorian Doran
ca3964f8b7 style/floating buttons: tweak position 2025-10-23 20:18:52 +03:00
Adorian Doran
ddafda5f4e style/quick edit dialog: tweak the colors for the dark color scheme 2025-10-23 20:01:29 +03:00
Adorian Doran
40b08e1828 Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements 2025-10-23 19:43:17 +03:00
Adorian Doran
5141f0a0d5 style/quick edit dialog: tweak the colors for the dark color scheme 2025-10-23 19:43:06 +03:00
Adorian Doran
8c165c0401 style/quick edit dialog: add support for the dark color scheme 2025-10-23 10:57:01 +03:00
Adorian Doran
b4dd40e128 style/quick edit dialog: refactor 2025-10-23 10:48:04 +03:00
Adorian Doran
535b960b76 style/quick edit dialog: ignore monochromatic custom colors 2025-10-23 10:44:23 +03:00
Adorian Doran
b58f37cd4a style/quick edit dialog: tint the dialog background, border and promoted attributes card according to the note's custom color 2025-10-23 10:18:52 +03:00
Adorian Doran
a01fb39599 client/quick edit dialog: make available the CSS variables with the custom color of the edited note 2025-10-23 10:16:18 +03:00
Adorian Doran
be15934b22 Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements 2025-10-23 10:03:03 +03:00
Adorian Doran
96b3464f00 Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements 2025-10-22 21:53:07 +03:00
Adorian Doran
2470b0b334 style: tweak the appearance of the promoted attributes cards 2025-10-22 20:06:06 +03:00
Adorian Doran
4344687303 style: tweak the appearance of the promoted attributes cards 2025-10-22 19:58:42 +03:00
Adorian Doran
b4fe46eba3 style: tweak the appearance of option cards 2025-10-22 19:41:49 +03:00
62 changed files with 734 additions and 3355 deletions

View File

@@ -86,12 +86,12 @@ jobs:
- name: Upload Playwright trace
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: Playwright trace (${{ matrix.dockerfile }})
path: test-output/playwright/output
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: Playwright report (${{ matrix.dockerfile }})
@@ -209,7 +209,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
path: /tmp/digests/*
@@ -223,7 +223,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -89,7 +89,7 @@ jobs:
name: Nightly Build
- name: Publish artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: ${{ github.event_name == 'pull_request' }}
with:
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}

View File

@@ -35,7 +35,7 @@ jobs:
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: e2e report
path: apps/server-e2e/test-output

View File

@@ -73,7 +73,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Upload the artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
path: apps/desktop/upload/*.*
@@ -100,7 +100,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Upload the artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: release-server-linux-${{ matrix.arch }}
path: upload/*.*
@@ -120,7 +120,7 @@ jobs:
docs/Release Notes
- name: Download all artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
merge-multiple: true
pattern: release-*

View File

@@ -39,7 +39,7 @@
"@stylistic/eslint-plugin": "5.5.0",
"@types/express": "5.0.3",
"@types/node": "22.18.12",
"@types/yargs": "17.0.33",
"@types/yargs": "17.0.34",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.38.0",
"eslint-plugin-simple-import-sort": "12.1.1",

View File

@@ -55,11 +55,11 @@
"mark.js": "8.11.1",
"marked": "16.4.1",
"mermaid": "11.12.0",
"mind-elixir": "5.3.3",
"mind-elixir": "5.3.4",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.27.2",
"react-i18next": "16.1.2",
"react-i18next": "16.2.0",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
@@ -74,9 +74,9 @@
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.1",
"@types/tabulator-tables": "6.2.11",
"@types/tabulator-tables": "6.3.0",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.0.7",
"happy-dom": "20.0.8",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
}

View File

@@ -166,6 +166,9 @@
--protected-session-active-icon-color: #8edd8e;
--sync-status-error-pulse-color: #f47871;
--center-pane-vert-layout-background-color-bgfx: #0c0c0c69;
--center-pane-horiz-layout-background-color-bgfx: #1e1e1ec7;
--right-pane-heading-color: gray;
--root-background: var(--left-pane-background-color);
@@ -192,9 +195,9 @@
--badge-background-color: #ffffff1a;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #000000b3;
--promoted-attribute-card-background-color: #ffffff21;
--promoted-attribute-card-shadow: none;
--floating-button-shadow-color: #00000080;
--floating-button-background-color: #494949d2;
--floating-button-color: var(--button-text-color);
@@ -227,8 +230,8 @@
--card-background-color: #ffffff12;
--card-background-hover-color: #3c3c3c;
--card-background-press-color: #464646;
--card-border-color: #222222;
--card-box-shadow: 0 0 12px rgba(0, 0, 0, 0.15);
--card-border-color: transparent;
--card-box-shadow: none;
--calendar-color: var(--menu-text-color);
--calendar-weekday-labels-color: var(--muted-text-color);
@@ -294,4 +297,10 @@ body ::-webkit-calendar-picker-indicator {
body .todo-list input[type="checkbox"]:not(:checked):before {
border-color: var(--muted-text-color) !important;
}
.tinted-quick-edit-dialog {
--modal-background-color: hsl(var(--custom-color-hue), 8.8%, 11.2%);
--modal-border-color: hsl(var(--custom-color-hue), 9.4%, 25.1%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
}

View File

@@ -159,6 +159,9 @@
--protected-session-active-icon-color: #16b516;
--sync-status-error-pulse-color: #ff5528;
--center-pane-vert-layout-background-color-bgfx: #ffffff75;
--center-pane-horiz-layout-background-color-bgfx: #ffffffd6;
--right-pane-heading-color: gray;
--root-background: var(--left-pane-background-color);
@@ -185,8 +188,8 @@
--badge-background-color: #00000011;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #00000033;
--promoted-attribute-card-background-color: #00000014;
--promoted-attribute-card-shadow: none;
--floating-button-shadow-color: #00000042;
--floating-button-background-color: #eaeaeacc;
@@ -223,12 +226,12 @@
--code-block-box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.2);
--card-background-color: var(--accented-background-color);
--card-background-color: #0000000d;
--card-background-hover-color: #f9f9f9;
--card-background-press-color: #efefef;
--card-border-color: #eaeaea;
--card-border-color: transparent;
--card-shadow-color: rgba(0, 0, 0, 0.1);
--card-box-shadow: 0 0 12px var(--card-shadow-color);
--card-box-shadow: none;
--calendar-color: var(--menu-text-color);
--calendar-weekday-labels-color: var(--muted-text-color);
@@ -270,4 +273,10 @@
* The --custom-color-hue variable contains the hue of the user-selected note color.
* This value is unset for gray tones. */
--custom-bg-color: hsl(var(--custom-color-hue), 37%, 89%, 1);
}
.tinted-quick-edit-dialog {
--modal-background-color: hsl(var(--custom-color-hue), 56%, 96%);
--modal-border-color: hsl(var(--custom-color-hue), 33%, 41%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);
}

View File

@@ -148,7 +148,7 @@ div.note-detail-empty {
--options-card-min-width: 500px;
--options-card-max-width: 900px;
--options-card-padding: 17px;
--options-title-font-size: 1rem;
--options-title-font-size: .75rem;
--options-title-offset: 13px;
}
/* Create a gap at the top of the option pages */
@@ -173,8 +173,7 @@ div.note-detail-empty {
}
.options-section:not(.tn-no-card) {
margin: auto;
border-radius: 12px;
border-radius: 8px;
border: 1px solid var(--card-border-color) !important;
box-shadow: var(--card-box-shadow);
background: var(--card-background-color);
@@ -182,7 +181,7 @@ div.note-detail-empty {
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
}
body.desktop .option-section:not(.tn-no-card) {
body.desktop .options-section:not(.tn-no-card) {
min-width: var(--options-card-min-width);
max-width: var(--options-card-max-width);
}
@@ -193,9 +192,16 @@ body.desktop .option-section:not(.tn-no-card) {
padding-bottom: var(--default-padding);
}
.options-section:not(.tn-no-card) h4,
.options-section:not(.tn-no-card) h5 {
text-transform: uppercase;
letter-spacing: .4pt;
}
.options-section:not(.tn-no-card) h4 {
font-size: var(--options-title-font-size);
font-weight: bold;
font-weight: 600;
color: var(--launcher-pane-text-color);
margin-top: calc(-1 * var(--options-card-padding) - var(--options-title-font-size) - var(--options-title-offset)) !important;
margin-bottom: calc(var(--options-title-offset) + var(--options-card-padding)) !important;

View File

@@ -34,6 +34,7 @@
div.promoted-attributes-container {
margin-top: 8px;
margin-bottom: 8px;
margin-inline-start: 12px;
}
/*

View File

@@ -8,7 +8,7 @@
}
:root {
--dropdown-backdrop-filter: blur(10px) saturate(6);
--dropdown-backdrop-filter: blur(20px) saturate(6);
--dropdown-border-radius: 10px;
}
@@ -43,13 +43,19 @@ body.background-effects.platform-win32 {
--tab-background-color: var(--window-background-color-bgfx);
--new-tab-button-background: var(--window-background-color-bgfx);
--active-tab-background-color: var(--launcher-pane-horiz-background-color);
--root-background: transparent;
}
body.background-effects.platform-win32.layout-vertical {
--left-pane-background-color: var(--window-background-color-bgfx);
--center-pane-background-color-bgfx: var(--center-pane-vert-layout-background-color-bgfx);
--background-material: mica;
}
body.background-effects.platform-win32.layout-horizontal {
--center-pane-background-color-bgfx: var(--center-pane-horiz-layout-background-color-bgfx);
}
body.background-effects.platform-win32,
body.background-effects.platform-win32 #root-widget {
background: var(--window-background-color-bgfx) !important;
@@ -59,6 +65,12 @@ body.background-effects.platform-win32.layout-horizontal #horizontal-main-contai
body.background-effects.platform-win32.layout-vertical #vertical-main-container {
background-color: var(--root-background);
}
/* Settings page */
body.background-effects.platform-win32 #center-pane .note-split.options {
--note-split-background-color: var(--center-pane-background-color-bgfx);
}
/* #endregion */
/* Matches when the left pane is collapsed */
@@ -1171,21 +1183,19 @@ body.layout-vertical .tab-row-widget-is-sorting .note-tab.note-tab-is-dragging .
* CENTER PANE
*/
#center-pane {
background: var(--main-background-color);
}
.vertical-layout #center-pane {
/* The first visible note split */
.vertical-layout #center-pane .note-split:not(.visible ~ .visible) {
border-radius: var(--center-pane-border-radius) 0 0 0;
}
.note-split {
#center-pane .note-split {
padding-top: 2px;
background-color: var(--note-split-background-color, var(--main-background-color));
animation: note-entrance 100ms linear;
/* will-change: opacity; -- causes some weird artifacts to the note menu in split view */
}
.split-note-container-widget > .gutter {
#center-pane .split-note-container-widget > .gutter {
background: var(--root-background) !important;
transition: background 150ms ease-out;
}
@@ -1328,8 +1338,7 @@ div.promoted-attribute-cell {
--pa-card-padding-inline-end: 2px;
--input-background-color: transparent;
box-shadow: 1px 1px 2px var(--promoted-attribute-card-shadow-color);
box-shadow: var(--promoted-attribute-card-shadow);
display: inline-flex;
margin: 0;
border-radius: 8px;

View File

@@ -12,6 +12,9 @@
"toast": {
"critical-error": {
"title": "خطأ فادح"
},
"widget-error": {
"title": "فشل في البدء بعنصر الواجهة"
}
},
"add_link": {
@@ -26,7 +29,8 @@
"edit_branch_prefix": "تعديل بادئة الفرع",
"prefix": "البادئة: ",
"save": "حفظ",
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة"
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة",
"branch_prefix_saved": "تم حفظ بادئة الفرع."
},
"bulk_actions": {
"bulk_actions": "اجراءات جماعية",
@@ -83,7 +87,8 @@
"workspace_calendar_root": "‎تحديد جذر التقويم لكل مساحة عمل",
"hide_highlight_widget": "اخفاء عنصر واجهة قائمة التمييزات",
"is_owned_by_note": "تخص الملاحظة",
"and_more": "... و {{count}}مرات اكثر."
"and_more": "... و {{count}}مرات اكثر.",
"related_notes_title": "ملاحظات اخرى بنفس التسمية"
},
"rename_label": {
"to": "الى",
@@ -127,7 +132,9 @@
"delete_attachment": "حذف المرفق",
"upload_new_revision": "رفع مراجعة جديدة",
"copy_link_to_clipboard": "نسخ الرابط الى الحافظة",
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة"
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة",
"delete_success": "تم حذف المرفق \"{{title}}\" .",
"enter_new_name": "ادخل اسم مرفق جديد"
},
"calendar": {
"week": "أسبوع",
@@ -259,7 +266,8 @@
"note_paths": {
"search": "بحث",
"archived": "مؤرشف",
"title": "مسارات الملاحظة"
"title": "مسارات الملاحظة",
"clone_button": "جار نسخ الملاحظة الى مكان جديد..."
},
"script_executor": {
"query": "استعلام",
@@ -372,7 +380,8 @@
"export_note_title": "تصدير الملاحظة",
"export_status": "حالة التصدير",
"export_finished_successfully": "اكتمل التصدير بنجاح.",
"export_in_progress": "جار التصدير: {{progressCount}}"
"export_in_progress": "جار التصدير: {{progressCount}}",
"choose_export_type": "اختر نوع التصدير اولا من فضلك"
},
"help": {
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
@@ -402,7 +411,10 @@
"movingCloningNotes": "نقل/ استنساخ الملاحظات",
"deleteNotes": "حذف الملاحظة/ الشجرة الفرعية",
"collapseWholeTree": "طي شجرة الملاحظة باكملها",
"followLink": "اتبع تلرابط تحت المؤشر"
"followLink": "اتبع تلرابط تحت المؤشر",
"onlyInDesktop": "في سطح المكتب فقط(Electron build)",
"createEditLink": "انشاء/ تحرير رابط خارجي",
"quickSearch": "الانتقال الى مربع البحث السريع"
},
"import": {
"options": "خيارات",
@@ -465,7 +477,13 @@
"delete_all_button": "حذف كل المراجعات",
"settings": "اعدادات مراجعة الملاحظة",
"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": {
"title": "عنوان",
@@ -479,13 +497,15 @@
"sorting_direction": "اتجاه الترتيب",
"natural_sort": "الترتيب الطبيعي",
"natural_sort_language": "لغات الترتيب الطبيعي",
"sort_children_by": "ترتيب العناصر الفرعية حسب..."
"sort_children_by": "ترتيب العناصر الفرعية حسب...",
"sort_folders_at_top": "ترتيب المجلدات في الاعلى"
},
"recent_changes": {
"undelete_link": "الغاء الحذف",
"title": "التغيرات الاخيرة",
"no_changes_message": "لايوجد تغيير لحد الان...",
"erase_notes_button": "مسح الملاحظات المحذوفة الان"
"erase_notes_button": "مسح الملاحظات المحذوفة الان",
"deleted_notes_message": "تم حذف الملاحظات نهائيا."
},
"edited_notes": {
"deleted": "(حذف)",
@@ -705,7 +725,9 @@
"default_token_name": "رمز جديد",
"rename_token_title": "اعادة تسمية الرمز",
"rename_token": "اعادة تسمية هذا الرمز",
"create_token": "انشاء رمز PEAPI جديد"
"create_token": "انشاء رمز PEAPI جديد",
"new_token_title": "رمز ETAPI جديد",
"token_created_title": "انشاء رمز ETAPI"
},
"password": {
"heading": "كلمة المرور",
@@ -811,7 +833,8 @@
"help_on_links": "مساعدة حول الارتباطات التشعبية",
"notes_to_clone": "ملاحظات للنسخ",
"target_parent_note": "الملاحظة الاصلية الهدف",
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة"
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة",
"no_path_to_clone_to": "لايوجد مسار لنسخ المحتوى الية."
},
"table_of_contents": {
"unit": "عناوين",
@@ -1029,7 +1052,8 @@
},
"delete_note": {
"delete_note": "حذف الملاحظة",
"delete_matched_notes": "حف الملاحظات المطابقة"
"delete_matched_notes": "حف الملاحظات المطابقة",
"delete_matched_notes_description": "سوف يؤدي هذا الى حذف الملاحظات المطابقة."
},
"rename_note": {
"rename_note": "اعادة تسمية الملاحظة",
@@ -1312,7 +1336,8 @@
"notes_to_move": "الملاحظات المراد نقلها",
"target_parent_note": "ملاحظة الاصل الهدف",
"dialog_title": "انقل الملاحظات الى...",
"move_button": "نقل الىالملاحظة المحددة"
"move_button": "نقل الىالملاحظة المحددة",
"error_no_path": "لايوجد مسار لنقل العنصر الية."
},
"delete_revisions": {
"delete_note_revisions": "حذف مراجعات الملاحظة"
@@ -1363,7 +1388,8 @@
"save_attributes": "حفظ السمات <enter>",
"add_a_new_attribute": "اضافة سمة جديدة",
"add_new_label_definition": "اضافة تعريف لتسمية جديدة",
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة"
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة",
"add_new_relation": "اضافة علاقة جديدة <kbd data-command=\"addNewRelation\">"
},
"zen_mode": {
"button_exit": "الخروج من وضع Zen"
@@ -1434,5 +1460,8 @@
},
"png_export_button": {
"button_title": "تصدير المخطط كملف PNG"
},
"protected_session_status": {
"inactive": "انقر للدخول الى جلسة محمية"
}
}

View File

@@ -184,7 +184,8 @@
},
"import-status": "Importstatus",
"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": {
"dialog_title": "Notiz beifügen",
@@ -1521,7 +1522,9 @@
"window-on-top": "Dieses Fenster immer oben halten"
},
"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": {
"placeholder": "Titel der Notiz hier eingeben…"

View File

@@ -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"
}
}

View File

@@ -6,7 +6,7 @@
.floating-buttons-children,
.show-floating-buttons {
position: absolute;
top: var(--floating-buttons-vert-offset, 10px);
top: var(--floating-buttons-vert-offset, 14px);
inset-inline-end: var(--floating-buttons-horiz-offset, 10px);
display: flex;
flex-direction: row;

View File

@@ -57,17 +57,19 @@ const TPL = /*html*/`\
}
</style>
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">
<!-- This is where the first child will be injected -->
<div class="quick-edit-dialog-wrapper">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">
<!-- This is where the first child will be injected -->
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- This is where all but the first child will be injected. -->
<div class="modal-body">
<!-- This is where all but the first child will be injected. -->
</div>
</div>
</div>
</div>
@@ -79,6 +81,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
private noteContext: NoteContext;
private $modalHeader!: JQuery<HTMLElement>;
private $modalBody!: JQuery<HTMLElement>;
private $wrapper!: JQuery<HTMLDivElement>;
constructor() {
super();
@@ -93,6 +96,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
const $newWidget = $(TPL);
this.$modalHeader = $newWidget.find(".modal-title");
this.$modalBody = $newWidget.find(".modal-body");
this.$wrapper = $newWidget.find(".quick-edit-dialog-wrapper");
const children = this.$widget.children();
this.$modalHeader.append(children[0]);
@@ -112,6 +116,21 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
}
});
const colorClass = this.noteContext.note?.getColorClass();
const wrapperElement = this.$wrapper.get(0)!;
if (colorClass) {
wrapperElement.className = "quick-edit-dialog-wrapper " + colorClass;
} else {
wrapperElement.className = "quick-edit-dialog-wrapper";
}
const customHue = getComputedStyle(wrapperElement).getPropertyValue("--custom-color-hue");
if (customHue) {
/* Apply the tinted-dialog class only if the custom color CSS class specifies a hue */
wrapperElement.classList.add("tinted-quick-edit-dialog");
}
const activeEl = document.activeElement;
if (activeEl && "blur" in activeEl) {
(activeEl as HTMLElement).blur();

View File

@@ -61,7 +61,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
this.$widget.addClass(utils.getNoteTypeClass(note.type));
this.$widget.addClass(utils.getMimeTypeClass(note.mime));
this.$widget.toggleClass("options", note.isOptions());
this.$widget.toggleClass("protected", note.isProtected);
const noteLanguage = note?.getLabelValue("language");

View File

@@ -101,7 +101,7 @@ function TokenList({ tokens }: { tokens: EtapiToken[] }) {
return (
tokens.length ? (
<div style={{ overflow: "auto", height: "500px"}}>
<div style={{ overflow: "auto"}}>
<table className="table table-stripped">
<thead>
<tr>

View File

@@ -13,7 +13,7 @@
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/mime-types": "3.0.1",
"@types/yargs": "17.0.33"
"@types/yargs": "17.0.34"
},
"scripts": {
"dev": "tsx src/main.ts",

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-bullseye-slim AS builder
FROM node:22.21.0-bullseye-slim AS builder
RUN corepack enable
# 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
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-bullseye-slim
FROM node:22.21.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine AS builder
FROM node:22.21.0-alpine AS builder
RUN corepack enable
# 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
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-alpine
FROM node:22.21.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine AS builder
FROM node:22.21.0-alpine AS builder
RUN corepack enable
# 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
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-alpine
FROM node:22.21.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-bullseye-slim AS builder
FROM node:22.21.0-bullseye-slim AS builder
RUN corepack enable
# 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
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-bullseye-slim
FROM node:22.21.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -36,11 +36,11 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
"@types/archiver": "6.0.3",
"@types/archiver": "6.0.4",
"@types/better-sqlite3": "7.6.13",
"@types/cls-hooked": "4.3.9",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.9",
"@types/cookie-parser": "1.4.10",
"@types/debounce": "1.2.4",
"@types/ejs": "3.1.5",
"@types/escape-html": "1.0.4",
@@ -56,8 +56,7 @@
"@types/sanitize-html": "2.16.0",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "1.15.9",
"@types/session-file-store": "1.2.5",
"@types/serve-static": "1.15.10",
"@types/stream-throttle": "0.1.4",
"@types/supertest": "6.0.3",
"@types/swagger-ui-express": "4.1.8",
@@ -100,7 +99,7 @@
"i18next": "25.6.0",
"i18next-fs-backend": "2.6.0",
"image-type": "6.0.0",
"ini": "5.0.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.0",
@@ -110,7 +109,7 @@
"multer": "2.0.2",
"normalize-strings": "1.1.1",
"ollama": "0.6.0",
"openai": "6.6.0",
"openai": "6.7.0",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@@ -125,7 +124,7 @@
"swagger-ui-express": "5.0.1",
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turndown": "7.2.1",
"turndown": "7.2.2",
"unescape": "1.0.1",
"vite": "7.1.11",
"ws": "8.18.3",

View File

@@ -146,228 +146,9 @@ CREATE INDEX IDX_notes_blobId on notes (blobId);
CREATE INDEX IDX_revisions_blobId on revisions (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 (
id TEXT PRIMARY KEY,
data TEXT,
expires INTEGER
);
-- FTS5 Full-Text Search Support
-- Create FTS5 virtual table with trigram tokenizer
-- Trigram tokenizer provides language-agnostic substring matching:
-- 1. Fast substring matching (50-100x speedup for LIKE queries without wildcards)
-- 2. Case-insensitive search without custom collation
-- 3. No language-specific stemming assumptions (works for all languages)
-- 4. Boolean operators (AND, OR, NOT) and phrase matching with quotes
--
-- IMPORTANT: Trigram requires minimum 3-character tokens for matching
-- detail='none' reduces index size by ~50% while maintaining MATCH/rank performance
-- (loses position info for highlight() function, but snippet() still works)
CREATE VIRTUAL TABLE notes_fts USING fts5(
noteId UNINDEXED,
title,
content,
tokenize = 'trigram',
detail = 'none'
);
-- 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;

View File

@@ -84,7 +84,9 @@
"show-backend-log": "فتح صفحة \"سجل الخلفية\"",
"edit-readonly-note": "تعديل ملاحظة القراءة فقط",
"attributes-labels-and-relations": "سمات ( تسميات و علاقات)",
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة"
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة",
"show-help": "فتح دليل التعليمات",
"copy-without-formatting": "نسخ النص المحدد بدون تنسيق"
},
"setup_sync-from-server": {
"note": "ملاحظة:",
@@ -196,7 +198,8 @@
"expand": "توسيع",
"site-theme": "المظهر العام للموقع",
"image_alt": "صورة المقال",
"on-this-page": "في هذه السفحة"
"on-this-page": "في هذه السفحة",
"last-updated": "اخر تحديث {{- date}}"
},
"hidden_subtree_templates": {
"description": "الوصف",
@@ -258,7 +261,8 @@
},
"share_page": {
"parent": "الأصل:",
"child-notes": "الملاحظات الفرعية:"
"child-notes": "الملاحظات الفرعية:",
"no-content": "لاتحتوي هذة الملاحظة على محتوى."
},
"notes": {
"duplicate-note-suffix": "(مكرر)",
@@ -339,7 +343,24 @@
"toggle-system-tray-icon": "تبديل ايقونة علبة النظام",
"switch-to-first-tab": "التبديل الى التبويب الاول",
"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": {
"title": "غير موجود",
@@ -348,6 +369,7 @@
"weekdayNumber": "الاسبوع{رقم الاسيوع}",
"quarterNumber": "الربع {رقم الربع}",
"pdf": {
"export_filter": "مستند PDF (.pdf)"
"export_filter": "مستند PDF (.pdf)",
"unable-to-export-title": "تعذر التصدير كملف PDF"
}
}

View File

@@ -274,7 +274,8 @@
"export_filter": "PDF Dokument (*.pdf)",
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
"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": {
"tooltip": "Trilium Notes",

View File

@@ -23,6 +23,14 @@
"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",
"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"
}
}

View File

@@ -1,553 +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...");
// Verify SQLite version supports trigram tokenizer (requires 3.34.0+)
const sqliteVersion = sql.getValue<string>(`SELECT sqlite_version()`);
const [major, minor, patch] = sqliteVersion.split('.').map(Number);
const versionNumber = major * 10000 + minor * 100 + (patch || 0);
const requiredVersion = 3 * 10000 + 34 * 100 + 0; // 3.34.0
if (versionNumber < requiredVersion) {
log.error(`SQLite version ${sqliteVersion} does not support trigram tokenizer (requires 3.34.0+)`);
log.info("Skipping FTS5 trigram migration - will use fallback search implementation");
return; // Skip FTS5 setup, rely on fallback search
}
log.info(`SQLite version ${sqliteVersion} confirmed - trigram tokenizer available`);
// 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
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 with trigram tokenizer
-- Trigram tokenizer provides language-agnostic substring matching:
-- 1. Fast substring matching (50-100x speedup for LIKE queries without wildcards)
-- 2. Case-insensitive search without custom collation
-- 3. No language-specific stemming assumptions (works for all languages)
-- 4. Boolean operators (AND, OR, NOT) and phrase matching with quotes
--
-- IMPORTANT: Trigram requires minimum 3-character tokens for matching
-- detail='none' reduces index size by ~50% while maintaining MATCH/rank performance
-- (loses position info for highlight() function, but snippet() still works)
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
noteId UNINDEXED,
title,
content,
tokenize = 'trigram',
detail = 'none'
);
`);
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");
}

View File

@@ -1,47 +0,0 @@
/**
* Migration to clean up custom SQLite search implementation
*
* This migration removes tables and triggers created by migration 0235
* which implemented a custom SQLite-based search system. That system
* has been replaced by FTS5 with trigram tokenizer (migration 0234),
* making these custom tables redundant.
*
* Tables removed:
* - note_search_content: Stored normalized note content for custom search
* - note_tokens: Stored tokenized words for custom token-based search
*
* This migration is safe to run on databases that:
* 1. Never ran migration 0235 (tables don't exist)
* 2. Already ran migration 0235 (tables will be dropped)
*/
import sql from "../services/sql.js";
import log from "../services/log.js";
export default function cleanupSqliteSearch() {
log.info("Starting SQLite custom search cleanup migration...");
try {
sql.transactional(() => {
// Drop custom search tables if they exist
log.info("Dropping note_search_content table...");
sql.executeScript(`DROP TABLE IF EXISTS note_search_content`);
log.info("Dropping note_tokens table...");
sql.executeScript(`DROP TABLE IF EXISTS note_tokens`);
// Clean up any entity changes for these tables
// This prevents sync issues and cleans up change tracking
log.info("Cleaning up entity changes for removed tables...");
sql.execute(`
DELETE FROM entity_changes
WHERE entityName IN ('note_search_content', 'note_tokens')
`);
log.info("SQLite custom search cleanup completed successfully");
});
} catch (error) {
log.error(`Error during SQLite search cleanup: ${error}`);
throw new Error(`Failed to clean up SQLite search tables: ${error}`);
}
}

View File

@@ -6,16 +6,6 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Clean up custom SQLite search tables (replaced by FTS5 trigram)
{
version: 236,
module: async () => import("./0236__cleanup_sqlite_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
{
version: 233,

View File

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

View File

@@ -10,8 +10,6 @@ import cls from "../../services/cls.js";
import attributeFormatter from "../../services/attribute_formatter.js";
import ValidationError from "../../errors/validation_error.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 {
const note = becca.getNoteOrThrow(req.params.noteId);
@@ -131,86 +129,11 @@ function searchTemplates() {
.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 {
searchFromNote,
searchAndExecute,
getRelatedNotes,
quickSearch,
search,
searchTemplates,
syncFtsIndex,
rebuildFtsIndex,
getFtsIndexStats
searchTemplates
};

View File

@@ -183,7 +183,7 @@ export function createUploadMiddleware(): RequestHandler {
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
multerOptions.limits = {
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024 * 1024
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
};
}

View File

@@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
import dataDir from "./data_dir.js";
import { AppInfo } from "@triliumnext/commons";
const APP_DB_VERSION = 235;
const APP_DB_VERSION = 233;
const SYNC_VERSION = 36;
const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@@ -214,14 +214,6 @@ function createNewNote(params: NoteParams): {
prefix: params.prefix || "",
isExpanded: !!params.isExpanded
}).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 {
if (!isEntityEventsDisabled) {
// re-enable entity events only if they were previously enabled

View File

@@ -19,7 +19,6 @@ import {
fuzzyMatchWord,
FUZZY_SEARCH_CONFIG
} from "../utils/text_utils.js";
import ftsSearchService, { FTSError, FTSNotAvailableError, FTSQueryError } from "../fts_search.js";
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
@@ -78,138 +77,6 @@ class NoteContentFulltextExp extends Expression {
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>(`
SELECT noteId, type, mime, content, isProtected
FROM notes JOIN blobs USING (blobId)
@@ -222,39 +89,6 @@ class NoteContentFulltextExp extends Expression {
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) {
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
return;

View File

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

View File

@@ -1,690 +0,0 @@
/**
* FTS5 Search Service
*
* Encapsulates all FTS5-specific operations for full-text searching.
* Provides efficient text search using SQLite's FTS5 extension with:
* - Trigram tokenization for fast substring 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");
}
// Trigram tokenizer requires minimum 3 characters
const shortTokens = tokens.filter(token => token.length < 3);
if (shortTokens.length > 0) {
const shortList = shortTokens.join(', ');
log.info(`Tokens shorter than 3 characters detected (${shortList}) - cannot use trigram FTS5`);
throw new FTSNotAvailableError(
`Trigram tokenizer requires tokens of at least 3 characters. Short tokens: ${shortList}`
);
}
// 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;

View File

@@ -62,10 +62,6 @@ class NoteSet {
return newNoteSet;
}
getNoteIds(): Set<string> {
return new Set(this.noteIdSet);
}
}
export default NoteSet;

View File

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

View File

@@ -401,8 +401,7 @@ function parseQueryToExpression(query: string, searchContext: SearchContext) {
}
function searchNotes(query: string, params: SearchParams = {}): BNote[] {
const searchContext = new SearchContext(params);
const searchResults = findResultsWithQuery(query, searchContext);
const searchResults = findResultsWithQuery(query, new SearchContext(params));
return searchResults.map((sr) => becca.notes[sr.noteId]);
}
@@ -418,20 +417,16 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear
}
// If the query starts with '#', it's a pure expression query.
// Don't use progressive search for these as they may have complex
// Don't use progressive search for these as they may have complex
// ordering or other logic that shouldn't be interfered with.
const isPureExpressionQuery = query.trim().startsWith('#');
let results: SearchResult[];
if (isPureExpressionQuery) {
// For pure expression queries, use standard search without progressive phases
results = performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
} else {
results = findResultsWithExpression(expression, searchContext);
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
}
return results;
return findResultsWithExpression(expression, searchContext);
}
function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null {

View File

@@ -1,113 +0,0 @@
/**
* Tests for SQLite custom functions service
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { SqliteFunctionsService, getSqliteFunctionsService } from './sqlite_functions.js';
describe('SqliteFunctionsService', () => {
let db: Database.Database;
let service: SqliteFunctionsService;
beforeEach(() => {
// Create in-memory database for testing
db = new Database(':memory:');
service = getSqliteFunctionsService();
// Reset registration state
service.unregister();
});
afterEach(() => {
db.close();
});
describe('Service Registration', () => {
it('should register functions successfully', () => {
const result = service.registerFunctions(db);
expect(result).toBe(true);
expect(service.isRegistered()).toBe(true);
});
it('should not re-register if already registered', () => {
service.registerFunctions(db);
const result = service.registerFunctions(db);
expect(result).toBe(true); // Still returns true but doesn't re-register
expect(service.isRegistered()).toBe(true);
});
it('should handle registration errors gracefully', () => {
// Close the database to cause registration to fail
db.close();
const result = service.registerFunctions(db);
expect(result).toBe(false);
expect(service.isRegistered()).toBe(false);
});
});
describe('edit_distance function', () => {
beforeEach(() => {
service.registerFunctions(db);
});
it('should calculate edit distance correctly', () => {
const tests = [
['hello', 'hello', 0],
['hello', 'hallo', 1],
['hello', 'help', 2],
['hello', 'world', 4],
['', '', 0],
['abc', '', 3],
['', 'abc', 3],
];
for (const [str1, str2, expected] of tests) {
const result = db.prepare('SELECT edit_distance(?, ?, 5) as distance').get(str1, str2) as any;
expect(result.distance).toBe((expected as number) <= 5 ? (expected as number) : 6);
}
});
it('should respect max distance threshold', () => {
const result = db.prepare('SELECT edit_distance(?, ?, ?) as distance')
.get('hello', 'world', 2) as any;
expect(result.distance).toBe(3); // Returns maxDistance + 1 when exceeded
});
it('should handle null inputs', () => {
const result = db.prepare('SELECT edit_distance(?, ?, 2) as distance').get(null, 'test') as any;
expect(result.distance).toBe(3); // Treats null as empty string, distance exceeds max
});
});
describe('regex_match function', () => {
beforeEach(() => {
service.registerFunctions(db);
});
it('should match regex patterns correctly', () => {
const tests = [
['hello world', 'hello', 1],
['hello world', 'HELLO', 1], // Case insensitive by default
['hello world', '^hello', 1],
['hello world', 'world$', 1],
['hello world', 'foo', 0],
['test@example.com', '\\w+@\\w+\\.\\w+', 1],
];
for (const [text, pattern, expected] of tests) {
const result = db.prepare("SELECT regex_match(?, ?, 'i') as match").get(text, pattern) as any;
expect(result.match).toBe(expected);
}
});
it('should handle invalid regex gracefully', () => {
const result = db.prepare("SELECT regex_match(?, ?, 'i') as match").get('test', '[invalid') as any;
expect(result.match).toBe(null); // Returns null for invalid regex
});
it('should handle null inputs', () => {
const result = db.prepare("SELECT regex_match(?, ?, 'i') as match").get(null, 'test') as any;
expect(result.match).toBe(0);
});
});
});

View File

@@ -1,284 +0,0 @@
/**
* SQLite Custom Functions Service
*
* This service manages custom SQLite functions for general database operations.
* Functions are registered with better-sqlite3 to provide native-speed operations
* directly within SQL queries.
*
* These functions are used by:
* - Fuzzy search fallback (edit_distance)
* - Regular expression matching (regex_match)
*/
import type { Database } from "better-sqlite3";
import log from "../log.js";
/**
* Configuration for fuzzy search operations
*/
const FUZZY_CONFIG = {
MAX_EDIT_DISTANCE: 2,
MIN_TOKEN_LENGTH: 3,
MAX_STRING_LENGTH: 1000, // Performance guard for edit distance
} as const;
/**
* Interface for registering a custom SQL function
*/
interface SQLiteFunction {
name: string;
implementation: (...args: any[]) => any;
options?: {
deterministic?: boolean;
varargs?: boolean;
directOnly?: boolean;
};
}
/**
* Manages registration and lifecycle of custom SQLite functions
*/
export class SqliteFunctionsService {
private static instance: SqliteFunctionsService | null = null;
private registered = false;
private functions: SQLiteFunction[] = [];
private constructor() {
// Initialize the function definitions
this.initializeFunctions();
}
/**
* Get singleton instance of the service
*/
static getInstance(): SqliteFunctionsService {
if (!SqliteFunctionsService.instance) {
SqliteFunctionsService.instance = new SqliteFunctionsService();
}
return SqliteFunctionsService.instance;
}
/**
* Initialize all custom function definitions
*/
private initializeFunctions(): void {
// Bind all methods to preserve 'this' context
this.functions = [
{
name: "edit_distance",
implementation: this.editDistance.bind(this),
options: {
deterministic: true,
varargs: true // Changed to true to handle variable arguments
}
},
{
name: "regex_match",
implementation: this.regexMatch.bind(this),
options: {
deterministic: true,
varargs: true // Changed to true to handle variable arguments
}
}
];
}
/**
* Register all custom functions with the database connection
*
* @param db The better-sqlite3 database connection
* @returns true if registration was successful, false otherwise
*/
registerFunctions(db: Database): boolean {
if (this.registered) {
log.info("SQLite custom functions already registered");
return true;
}
try {
// Test if the database connection is valid first
// This will throw if the database is closed
db.pragma("user_version");
log.info("Registering SQLite custom functions...");
let successCount = 0;
for (const func of this.functions) {
try {
db.function(func.name, func.options || {}, func.implementation);
log.info(`Registered SQLite function: ${func.name}`);
successCount++;
} catch (error) {
log.error(`Failed to register SQLite function ${func.name}: ${error}`);
// Continue registering other functions even if one fails
}
}
// Only mark as registered if at least some functions were registered
if (successCount > 0) {
this.registered = true;
log.info(`SQLite custom functions registration completed (${successCount}/${this.functions.length})`);
return true;
} else {
log.error("No SQLite functions could be registered");
return false;
}
} catch (error) {
log.error(`Failed to register SQLite custom functions: ${error}`);
return false;
}
}
/**
* Unregister all custom functions (for cleanup/testing)
* Note: better-sqlite3 doesn't provide a way to unregister functions,
* so this just resets the internal state
*/
unregister(): void {
this.registered = false;
}
/**
* Check if functions are currently registered
*/
isRegistered(): boolean {
return this.registered;
}
// ===== Function Implementations =====
/**
* Calculate Levenshtein edit distance between two strings
* Optimized with early termination and single-array approach
*
* SQLite will pass 2 or 3 arguments:
* - 2 args: str1, str2 (uses default maxDistance)
* - 3 args: str1, str2, maxDistance
*
* @returns Edit distance or maxDistance + 1 if exceeded
*/
private editDistance(...args: any[]): number {
// Handle variable arguments from SQLite
let str1: string | null | undefined = args[0];
let str2: string | null | undefined = args[1];
let maxDistance: number = args.length > 2 ? args[2] : FUZZY_CONFIG.MAX_EDIT_DISTANCE;
// Handle null/undefined inputs
if (!str1 || typeof str1 !== 'string') str1 = '';
if (!str2 || typeof str2 !== 'string') str2 = '';
// Validate and sanitize maxDistance
if (typeof maxDistance !== 'number' || !Number.isFinite(maxDistance)) {
maxDistance = FUZZY_CONFIG.MAX_EDIT_DISTANCE;
} else {
// Ensure it's a positive integer
maxDistance = Math.max(0, Math.floor(maxDistance));
}
const len1 = str1.length;
const len2 = str2.length;
// Performance guard for very long strings
if (len1 > FUZZY_CONFIG.MAX_STRING_LENGTH || len2 > FUZZY_CONFIG.MAX_STRING_LENGTH) {
return Math.abs(len1 - len2) <= maxDistance ? Math.abs(len1 - len2) : maxDistance + 1;
}
// Early termination: length difference exceeds max
if (Math.abs(len1 - len2) > maxDistance) {
return maxDistance + 1;
}
// Handle edge cases
if (len1 === 0) return len2 <= maxDistance ? len2 : maxDistance + 1;
if (len2 === 0) return len1 <= maxDistance ? len1 : maxDistance + 1;
// Single-array optimization for memory efficiency
let previousRow = Array.from({ length: len2 + 1 }, (_, i) => i);
let currentRow = new Array(len2 + 1);
for (let i = 1; i <= len1; i++) {
currentRow[0] = i;
let minInRow = i;
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
currentRow[j] = Math.min(
previousRow[j] + 1, // deletion
currentRow[j - 1] + 1, // insertion
previousRow[j - 1] + cost // substitution
);
if (currentRow[j] < minInRow) {
minInRow = currentRow[j];
}
}
// Early termination: minimum distance in row exceeds threshold
if (minInRow > maxDistance) {
return maxDistance + 1;
}
// Swap arrays for next iteration
[previousRow, currentRow] = [currentRow, previousRow];
}
const result = previousRow[len2];
return result <= maxDistance ? result : maxDistance + 1;
}
/**
* Test if a string matches a JavaScript regular expression
*
* SQLite will pass 2 or 3 arguments:
* - 2 args: text, pattern (uses default flags 'i')
* - 3 args: text, pattern, flags
*
* @returns 1 if match, 0 if no match, null on error
*/
private regexMatch(...args: any[]): number | null {
// Handle variable arguments from SQLite
let text: string | null | undefined = args[0];
let pattern: string | null | undefined = args[1];
let flags: string = args.length > 2 ? args[2] : 'i';
if (!text || !pattern) {
return 0;
}
if (typeof text !== 'string' || typeof pattern !== 'string') {
return null;
}
try {
// Validate flags
const validFlags = ['i', 'g', 'm', 's', 'u', 'y'];
const flagsArray = (flags || '').split('');
if (!flagsArray.every(f => validFlags.includes(f))) {
flags = 'i'; // Fall back to case-insensitive
}
const regex = new RegExp(pattern, flags);
return regex.test(text) ? 1 : 0;
} catch (error) {
// Invalid regex pattern
log.error(`Invalid regex pattern in SQL: ${pattern} - ${error}`);
return null;
}
}
}
// Export singleton instance getter
export function getSqliteFunctionsService(): SqliteFunctionsService {
return SqliteFunctionsService.getInstance();
}
/**
* Initialize SQLite custom functions with the given database connection
* This should be called once during application startup after the database is opened
*
* @param db The better-sqlite3 database connection
* @returns true if successful, false otherwise
*/
export function initializeSqliteFunctions(db: Database): boolean {
const service = getSqliteFunctionsService();
return service.registerFunctions(db);
}

View File

@@ -14,7 +14,6 @@ import ws from "./ws.js";
import becca_loader from "../becca/becca_loader.js";
import entity_changes from "./entity_changes.js";
import config from "./config.js";
import { initializeSqliteFunctions } from "./search/sqlite_functions.js";
const dbOpts: Database.Options = {
nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined
@@ -50,33 +49,12 @@ function rebuildIntegrationTestDatabase(dbPath?: string) {
// This allows a database that is read normally but is kept in memory and discards all modifications.
dbConnection = buildIntegrationTestDatabase(dbPath);
statementCache = {};
// Re-register custom SQLite functions after rebuilding the database
try {
initializeSqliteFunctions(dbConnection);
} catch (error) {
log.error(`Failed to re-initialize SQLite custom functions after rebuild: ${error}`);
}
}
if (!process.env.TRILIUM_INTEGRATION_TEST) {
dbConnection.pragma("journal_mode = WAL");
}
// Initialize custom SQLite functions for search operations
// This must happen after the database connection is established
try {
const functionsRegistered = initializeSqliteFunctions(dbConnection);
if (functionsRegistered) {
log.info("SQLite custom search functions initialized successfully");
} else {
log.info("SQLite custom search functions initialization failed - search will use fallback methods");
}
} catch (error) {
log.error(`Failed to initialize SQLite custom functions: ${error}`);
// Continue without custom functions - triggers will use LOWER() as fallback
}
const LOG_ALL_QUERIES = false;
type Params = any;
@@ -389,10 +367,6 @@ function disableSlowQueryLogging<T>(cb: () => T) {
}
}
function getDbConnection(): DatabaseType {
return dbConnection;
}
export default {
insert,
replace,
@@ -460,6 +434,5 @@ export default {
fillParamList,
copyDatabase,
disableSlowQueryLogging,
rebuildIntegrationTestDatabase,
getDbConnection
rebuildIntegrationTestDatabase
};

View File

@@ -67,21 +67,6 @@ async function initDbConnection() {
PRIMARY KEY (tmpID)
);`)
// Register SQLite search functions after database is ready
try {
const { getSqliteFunctionsService } = await import("./search/sqlite_functions.js");
const functionsService = getSqliteFunctionsService();
const db = sql.getDbConnection();
if (functionsService.registerFunctions(db)) {
log.info("SQLite search functions registered successfully");
} else {
log.info("SQLite search functions registration skipped (already registered)");
}
} catch (error) {
log.error(`Failed to register SQLite search functions: ${error}`);
}
dbReady.resolve();
}

View File

@@ -12,8 +12,8 @@
"i18next-http-backend": "3.0.2",
"preact": "10.27.2",
"preact-iso": "2.11.0",
"preact-render-to-string": "6.6.2",
"react-i18next": "16.1.2"
"preact-render-to-string": "6.6.3",
"react-i18next": "16.2.0"
},
"devDependencies": {
"@preact/preset-vite": "2.10.2",

View File

@@ -7,7 +7,8 @@
},
"download_helper_server_hosted": {
"title": "استضافة مدفوعة",
"download_triliumcc": "بدلا من ذلك، راجع trillum. cc"
"download_triliumcc": "بدلا من ذلك، راجع trillum. cc",
"download_pikapod": "اعدلد على PikaPods"
},
"get-started": {
"architecture": "المعمارية:",
@@ -22,7 +23,8 @@
"organization_benefits": {
"title": "تنظيم",
"note_structure_title": "هيكيلية الملاحظة",
"hoisting_title": "مساحات العمل والتركيز على الملاحظة"
"hoisting_title": "مساحات العمل والتركيز على الملاحظة",
"attributes_title": "العلاقات وجداول الملاحظة"
},
"productivity_benefits": {
"sync_title": "‎المزامنة",
@@ -30,7 +32,8 @@
"protected_notes_title": "الملاحظات المحمية",
"search_title": "البحث القوي",
"web_clipper_title": "اداة قص الويب",
"title": "الانتاجية والسلامة"
"title": "الانتاجية والسلامة",
"jump_to_title": "الاوامر والبحث السريع"
},
"note_types": {
"canvas_title": "مساحة العمل",
@@ -66,14 +69,16 @@
"paypal": "PayPal",
"title": "ادعمنا",
"financial_donations_title": "التبرعات المالية",
"github_sponsors": "الرعاة على GitHub"
"github_sponsors": "الرعاة على GitHub",
"buy_me_a_coffee": "Buy Me A Coffee"
},
"download_helper_desktop_windows": {
"download_scoop": "Scoop",
"download_exe": "تحميل ملف التثبيت (exe.)",
"title_x64": "ويندوز 64 بت",
"download_zip": "النسخة المحمولة بصيغة zip",
"title_arm64": "نظام ويندوز عاى ARM"
"title_arm64": "نظام ويندوز عاى ARM",
"quick_start": "للتثبيت باستخدام Winget:"
},
"download_helper_desktop_linux": {
"download_deb": ".deb",
@@ -112,6 +117,11 @@
"download_dmg": "تحميل ملف التثبيت (dmg.)",
"download_homebrew_cask": "Homebrew Cask",
"download_zip": "النسخة المحمولة بصيغة zip",
"title_x64": "نظام macOS لاصدار intel"
"title_x64": "نظام macOS لاصدار intel",
"title_arm64": "نظام macOS لمعالجة اجهزة Apple Silicon",
"quick_start": "للتثبيت بواسطة Homebrew:"
},
"contribute": {
"title": "طرق اخرى للمساهمة"
}
}

View File

@@ -1 +1,44 @@
{}
{
"get-started": {
"title": "Kezdés",
"desktop_title": "Töltsd le az asztali alkalmazást (v{{version}})",
"architecture": "Szerkezet:",
"older_releases": "Régebbi kiadások megtekintése",
"server_title": "Szerver beállítása több eszközről történő hozzáféréshez"
},
"hero_section": {
"title": "Rendszerezd a gondolataidat. Építsd fel a személyes tudásbázisodat.",
"subtitle": "A Trilium egy nyílt forráskódú megoldás jegyzeteléshez és személyes tudásbázis rendszerezéséhez. Használd helyben az asztali gépeden, vagy szinkronizáld a saját szervereddel, hogy jegyzeteid mindenhol elérhetőek legyenek.",
"get_started": "Kezdés",
"github": "GitHub",
"dockerhub": "Docker Hub",
"screenshot_alt": "A Trilium Notes asztali alkalmazás képernyőképe"
},
"organization_benefits": {
"title": "Szervezet",
"note_structure_title": "Jegyzet szerkezet",
"note_structure_description": "A jegyzetek hierarchikusan rendezhetők. Nincs szükség mappákra, mivel minden jegyzet tartalmazhat aljegyzeteket. Egyetlen jegyzet több helyre is hozzáadható a hierarchiában.",
"attributes_title": "Jegyzetcímkék és kapcsolatok",
"attributes_description": "Használjon kapcsolatokat a jegyzetek között, vagy adjon hozzá címkéket az egyszerű kategorizálás érdekében. Használjon kiemelt attribútumokat strukturált információk beviteléhez, amelyek táblázatokban és táblákon használhatók.",
"hoisting_title": "Munkaterületek és kiemelés",
"hoisting_description": "Könnyedén különítse el a személyes és munkahelyi jegyzeteit úgy, hogy csoportosítja őket egy munkaterület alatt, amely a jegyzetfát csak egy adott jegyzetkészlet megjelenítésére összpontosítja."
},
"productivity_benefits": {
"title": "Termelékenység és biztonság",
"revisions_title": "Megjegyzés felülvizsgálatai",
"revisions_content": "A jegyzetek rendszeresen tárolódnak a háttérben, és a revíziók felhasználhatók felülvizsgálatra vagy véletlen változások visszavonására. A revíziók igény szerint is létrehozhatók.",
"sync_title": "Szinkronizálás",
"sync_content": "Használjon saját maga által üzemeltetett vagy felhő alapu példányt, hogy könnyen szinkronizálja a jegyzeteket több eszközön keresztül, és hozzáférjen a mobiltelefonjáról egy PWA segítségével.",
"protected_notes_title": "Védett jegyzetek",
"protected_notes_content": "Védje az érzékeny személyes adatokat a jegyzetek titkosításával és a jelszóval védett munkamenet mögötti zárolással.",
"jump_to_title": "Gyors keresés és parancsok",
"jump_to_content": "Ugorj gyorsan a jegyzetekhez vagy a felhasználói felület parancsaihoz a hierarchia területén, keresve a címüket, a fuzzy illesztéssel, hogy megmagyarázza az elírásokat vagy az enyhe különbségeket.",
"search_title": "Erőteljes keresés",
"search_content": "Vagy keressen szöveget a jegyzetekben, és szűkítse le a keresést a szülői jegyzet szűrésével vagy mélységével."
},
"note_types": {
"file_description": "Beágyazott multimédiás fájlokat, például PDF-eket, képeket, videókat, alkalmazáson belüli előnézettel.",
"canvas_title": "Vászon",
"canvas_description": "Rendezzen formákat, képeket és szöveget egy végtelen vászonra, ugyanazzal a technológiával mint ami excalidraw.com mögött van. Ideális diagramokhoz, vázlatokhoz és vizuális tervezéshez."
}
}

6
docs/README-ar.md vendored
View File

@@ -61,8 +61,8 @@ quick overview:
Docker](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [ترقية تريليوم
للملاحظات](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Basic Concepts and
Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [مفاهيم ومميزات
اساسية](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of Personal Knowledge
Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
@@ -110,7 +110,7 @@ quick overview:
location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced
showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* [واجهة REST API](https://triliumnext.github.io/Docs/Wiki/etapi) للأتمتة
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile
frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for

135
docs/README-hu.md vendored
View File

@@ -9,85 +9,85 @@
<hr />
# Trilium Notes
# Trilium Jegyzetek
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran)
![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)\
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/trilium)
![GitHub Downloads (all assets, all
releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)\
![GitHub szponzorok](https://img.shields.io/github/sponsors/eliandoran)
![LiberaPay támogatók](https://img.shields.io/liberapay/patrons/ElianDoran)\
![Docker pull-ok](https://img.shields.io/docker/pulls/triliumnext/trilium)
![GitHub letöltések (összes tartalom, összes
kiadás)](https://img.shields.io/github/downloads/triliumnext/trilium/total)\
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[![Translation
status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[![Fordítás
állapota](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) |
[Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md)
| [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) |
[Spanish](./docs/README-es.md)
[angol](./README.md) | [egyszerűsített kínai](./docs/README-ZH_CN.md) |
[hagyományos kínai](./docs/README-ZH_TW.md) | [orosz](./docs/README-ru.md) |
[japán](./docs/README-ja.md) | [olasz](./docs/README-it.md) |
[spanyol](./docs/README-es.md)
Trilium Notes is a free and open-source, cross-platform hierarchical note taking
application with focus on building large personal knowledge bases.
A Trilium Notes egy ingyenes és nyílt forráskódú, platformfüggetlen hierarchikus
jegyzetelő alkalmazás, amely nagy személyes tudásbázisok építésére összpontosít.
See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for
quick overview:
A gyors áttekintésért lásd a
[képernyőképeket](https://triliumnext.github.io/Docs/Wiki/screenshot-tour):
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## ⏬ Download
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest)
stable version, recommended for most users.
## ⏬ Letöltés
- [Legújabb kiadás](https://github.com/TriliumNext/Trilium/releases/latest)
stabil verzió, a legtöbb felhasználó számára ajánlott.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly)
unstable development version, updated daily with the latest features and
fixes.
instabil fejlesztői verzió, naponta frissül a legújabb funkciókkal és
javításokkal.
## 📚 Documentation
## 📚 Dokumentáció
**Visit our comprehensive documentation at
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
**Tekintse meg átfogó dokumentációnkat a [docs.triliumnotes.org]
címen(https://docs.triliumnotes.org/)**
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **In-App Help**: Press `F1` within Trilium to access the same documentation
directly in the application
- **GitHub**: Navigate through the [User
Guide](./docs/User%20Guide/User%20Guide/) in this repository
Dokumentációink többféle formátumban is elérhetők:
- **Online dokumentáció**: Böngéssze a teljes dokumentációt a
[docs.triliumnotes.org](https://docs.triliumnotes.org/) címen.
- **Alkalmazáson belüli súgó**: Nyomja meg az `F1` billentyűt a Triliumon belül,
hogy ugyanazt a dokumentációt közvetlenül az alkalmazásban is elérje.
- **GitHub**: Navigálj a [Felhasználói
útmutatóban](./docs/User%20Guide/User%20Guide/) ebben a repository-ban
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation
Instructions](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
### Gyorslinkek
- [Kezdő lépések útmutatója](https://docs.triliumnotes.org/)
- [Telepítési
utasítások](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker
Setup](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading
TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Basic Concepts and
Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of Personal Knowledge
Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
beállítás](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [TriliumNext
Frissítése](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Alapfogalmak és
jellemzők](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [A személyes tudásbázis
mintái](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
## 🎁 Features
## 🎁 Jellemzők
* Notes can be arranged into arbitrarily deep tree. Single note can be placed
into multiple places in the tree (see
[cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
* Rich WYSIWYG note editor including e.g. tables, images and
[math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown
* A jegyzetek önkényesen mély fába rendezhetők. Egyetlen jegyzetet lehet
elhelyezni a többi fába (lásd
[klónozás](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
* Gazdag WYSIWYG jegyzetszerkesztő, beleértve például az asztalokat, képeket és
[math](https://triliumnext.github.io/Docs/Wiki/text-notes) markdownt
[autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
* Support for editing [notes with source
code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax
highlighting
* Fast and easy [navigation between
notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text
search and [note
hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note
versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be
used for note organization, querying and advanced
[scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* UI available in English, German, Spanish, French, Romanian, and Chinese
(simplified and traditional)
* Szerkesztés támogatása [jegyzetek
forráskóddal](https://triliumnext.github.io/Docs/Wiki/code-notes), beleértve a
szintaxis kiemelését
* Gyors és egyszerű [navigáció a jegyzetek
között](https://triliumnext.github.io/Docs/Wiki/note-navigation), teljes
szöveges keresés és
[megjegyzés](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Zökkenőmentes
[jegyzetváltozat](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Megjegyzés [attribútumok](https://triliumnext.github.io/Docs/Wiki/attributes)
használható jegyzetszervezéshez, lekérdezéshez és speciális
[szkriptelés](https://triliumnext.github.io/Docs/Wiki/scripts)
* UI elérhető angol, német, spanyol, francia, román és kínai (egyszerűsített és
hagyományos)
* Direct [OpenID and TOTP
integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md)
for more secure login
@@ -105,11 +105,12 @@ Our documentation is available in multiple formats:
* [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and
[link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing
notes and their relations
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
* [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with
location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced
showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* Elmetérképek, [Mind Elixir](https://docs.mind-elixir.com/) alapján
* [Geo térképek](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md)
helymeghatározó tűkkel és GPX nyomokkal
* [Szkriptelés](https://triliumnext.github.io/Docs/Wiki/scripts) - lásd:
[Speciális
bemutatók](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile

View File

@@ -51,7 +51,7 @@
"eslint": "9.38.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.2.2",
"eslint-plugin-react-hooks": "7.0.0",
"eslint-plugin-react-hooks": "7.0.1",
"happy-dom": "~20.0.0",
"jiti": "2.6.1",
"jsonc-eslint-parser": "2.4.1",
@@ -80,7 +80,7 @@
"url": "https://github.com/TriliumNext/Trilium/issues"
},
"homepage": "https://triliumnotes.org",
"packageManager": "pnpm@10.18.3",
"packageManager": "pnpm@10.19.0",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

View File

@@ -32,7 +32,7 @@
"eslint": "9.38.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.2.5",
"lint-staged": "16.2.6",
"stylelint": "16.25.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -33,7 +33,7 @@
"eslint": "9.38.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.2.5",
"lint-staged": "16.2.6",
"stylelint": "16.25.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -35,7 +35,7 @@
"eslint": "9.38.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.2.5",
"lint-staged": "16.2.6",
"stylelint": "16.25.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -36,7 +36,7 @@
"eslint": "9.38.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.2.5",
"lint-staged": "16.2.6",
"stylelint": "16.25.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -35,7 +35,7 @@
"eslint": "9.38.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.2.5",
"lint-staged": "16.2.6",
"stylelint": "16.25.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -15,7 +15,7 @@
"ckeditor5-premium-features": "47.1.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.4",
"@smithy/middleware-retry": "4.4.5",
"@types/jquery": "3.5.33"
}
}

View File

@@ -5,12 +5,12 @@
"type": "module",
"main": "./src/index.ts",
"dependencies": {
"@codemirror/commands": "6.9.0",
"@codemirror/commands": "6.10.0",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-markdown": "6.4.0",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-xml": "6.1.0",

View File

@@ -22,7 +22,7 @@
"gfm"
],
"devDependencies": {
"turndown": "7.2.1",
"turndown": "7.2.2",
"turndown-attendant": "0.0.3"
}
}

821
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import { getElectronPath, isNixOS } from "./utils.mjs";
const LD_LIBRARY_PATH = isNixOS() && execSync("nix eval --raw nixpkgs#gcc.cc.lib").toString("utf-8") + "/lib";
execSync(`${getElectronPath()} ${process.argv[2]}`, {
execSync(`${getElectronPath()} ${process.argv[2]} --no-sandbox`, {
stdio: "inherit",
env: {
...process.env,