mirror of
https://github.com/zadam/trilium.git
synced 2025-10-29 09:16:45 +01:00
Compare commits
250 Commits
feat/rice-
...
tree-activ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914483151a | ||
|
|
5db4c39051 | ||
|
|
0ad95d00dc | ||
|
|
5b7e9d4c12 | ||
|
|
bee2fdb22f | ||
|
|
5c46a0dfa8 | ||
|
|
69b262040a | ||
|
|
8731fa6c31 | ||
|
|
f4e8fc4d83 | ||
|
|
dd5b3a3c1c | ||
|
|
17319d25e8 | ||
|
|
2f189b6961 | ||
|
|
b1f8d44576 | ||
|
|
7f22532a0a | ||
|
|
c7beb87980 | ||
|
|
5cd1fd53d4 | ||
|
|
2eadbe3f01 | ||
|
|
4e7493f648 | ||
|
|
b9d54a44f6 | ||
|
|
a1ad8be02b | ||
|
|
b02514f395 | ||
|
|
dcef3f2be5 | ||
|
|
585fdabd27 | ||
|
|
71fcb77a22 | ||
|
|
33ecf6aa6d | ||
|
|
1f75de83c6 | ||
|
|
31b52f72d2 | ||
|
|
01aaf81196 | ||
|
|
3ecfdd62e8 | ||
|
|
3c74d0714a | ||
|
|
f58d9adff2 | ||
|
|
0eecf5b132 | ||
|
|
9e3cca333a | ||
|
|
81c233463e | ||
|
|
87946e7e85 | ||
|
|
c3768a051d | ||
|
|
c579cd3ce7 | ||
|
|
945e2625d3 | ||
|
|
ff36414a55 | ||
|
|
8f184c5b10 | ||
|
|
c027a2bbfa | ||
|
|
91adc2258d | ||
|
|
6701e83927 | ||
|
|
3f54e589d8 | ||
|
|
f65be73f71 | ||
|
|
346e9282bd | ||
|
|
8f8ea7adc3 | ||
|
|
4affd3a955 | ||
|
|
bcce05cc4d | ||
|
|
ac16c42e23 | ||
|
|
5025329e92 | ||
|
|
507910b0ce | ||
|
|
b59fab9dba | ||
|
|
ac7e4580f6 | ||
|
|
27d1044ba8 | ||
|
|
96c949b2fc | ||
|
|
927cd0255e | ||
|
|
c2c8417c42 | ||
|
|
3bb224e682 | ||
|
|
6f126ea17b | ||
|
|
61a5cf1452 | ||
|
|
14b8d0a47e | ||
|
|
12df6a0d6e | ||
|
|
21d243eec1 | ||
|
|
161238ca11 | ||
|
|
4d5267e18b | ||
|
|
0fa52907b3 | ||
|
|
c4f57f3d15 | ||
|
|
6bde264156 | ||
|
|
4f72f81a95 | ||
|
|
c212c5d6ff | ||
|
|
f24880d42c | ||
|
|
ee9bf1d47b | ||
|
|
b069fab82f | ||
|
|
d5ce01a65b | ||
|
|
dbfa94a9ee | ||
|
|
86aaa97809 | ||
|
|
c4c8fe23a9 | ||
|
|
715fe77db3 | ||
|
|
40f5abd6e3 | ||
|
|
f3f7e5900b | ||
|
|
f4402a6d81 | ||
|
|
6966efd374 | ||
|
|
cd3e025fdc | ||
|
|
a224b774d3 | ||
|
|
f20078f3b0 | ||
|
|
56019e5449 | ||
|
|
7dd517d8f7 | ||
|
|
b2f1b3c910 | ||
|
|
2197fae700 | ||
|
|
3661733f07 | ||
|
|
52a6f2597e | ||
|
|
d8e9cad23d | ||
|
|
6ed333d222 | ||
|
|
ba26c478d6 | ||
|
|
055fcb7b2a | ||
|
|
f4468706ef | ||
|
|
212956201a | ||
|
|
1182592fc5 | ||
|
|
d534db29c9 | ||
|
|
40edd42740 | ||
|
|
d2c7011735 | ||
|
|
a050d1741b | ||
|
|
18982865da | ||
|
|
3aa810fed7 | ||
|
|
c5ecc22c67 | ||
|
|
252f8ccb1f | ||
|
|
e1bb704383 | ||
|
|
dce0d9400b | ||
|
|
615c783fe3 | ||
|
|
f29411baf7 | ||
|
|
be5e70130c | ||
|
|
9ba1e9d732 | ||
|
|
e1dc4d1433 | ||
|
|
d0d268496c | ||
|
|
8a6950c945 | ||
|
|
477592d176 | ||
|
|
7e5c2ed79d | ||
|
|
bc580f2a88 | ||
|
|
71cd92e0b5 | ||
|
|
a4d92e12be | ||
|
|
c40279b480 | ||
|
|
4c7e7c157c | ||
|
|
c08386450a | ||
|
|
eb93762ecc | ||
|
|
2697f9a25d | ||
|
|
9515e2099b | ||
|
|
966c08da87 | ||
|
|
ea04446e81 | ||
|
|
e4f806ed14 | ||
|
|
49cf7ae1a3 | ||
|
|
1a6f5a027f | ||
|
|
f4796f0f9e | ||
|
|
30480b2c23 | ||
|
|
b7b1d17817 | ||
|
|
c4e5494c14 | ||
|
|
b0f63c02c9 | ||
|
|
2480509811 | ||
|
|
7872193ed0 | ||
|
|
1a68bdfe02 | ||
|
|
14e06c4555 | ||
|
|
b8e17959ae | ||
|
|
c16a135efc | ||
|
|
cbc756ba06 | ||
|
|
64daeb0826 | ||
|
|
e15839db47 | ||
|
|
dcdffed003 | ||
|
|
48e85fad43 | ||
|
|
189071deb8 | ||
|
|
354f1d65c1 | ||
|
|
b78893b106 | ||
|
|
9310315c6a | ||
|
|
1794f8546d | ||
|
|
b3bc0572e5 | ||
|
|
253ce1f223 | ||
|
|
2f3bf94b47 | ||
|
|
d802caa03b | ||
|
|
e69751a8b3 | ||
|
|
0760ea22fb | ||
|
|
8a8f407e99 | ||
|
|
e030dd96da | ||
|
|
01abfc2528 | ||
|
|
042b929dc5 | ||
|
|
ab1d5e31fb | ||
|
|
d073e4c37f | ||
|
|
d60d965a42 | ||
|
|
1c87cfbbd9 | ||
|
|
fee333512a | ||
|
|
38a3f46506 | ||
|
|
bf7506fcd8 | ||
|
|
6fbba426de | ||
|
|
d5bdec13b5 | ||
|
|
cc1b6eb42d | ||
|
|
8baf496f96 | ||
|
|
23a20c4490 | ||
|
|
c8b98f2db6 | ||
|
|
3f36f515db | ||
|
|
892eb5b95d | ||
|
|
62a69a0da0 | ||
|
|
3588e38543 | ||
|
|
41450ab85a | ||
|
|
0526d99560 | ||
|
|
557d576b85 | ||
|
|
041c961cfa | ||
|
|
dcc35bd507 | ||
|
|
09c3e5b56e | ||
|
|
950793377d | ||
|
|
7dac61dc26 | ||
|
|
42dcb8f141 | ||
|
|
43dc8a4b87 | ||
|
|
35316a4c45 | ||
|
|
1366489f99 | ||
|
|
0c399a676a | ||
|
|
31ee78b1aa | ||
|
|
808ba75ee0 | ||
|
|
ac1399a139 | ||
|
|
1e4793351a | ||
|
|
f502fe41c7 | ||
|
|
0ec0091357 | ||
|
|
0e2196f872 | ||
|
|
32dee254cd | ||
|
|
d4a6a297f4 | ||
|
|
a64d8cd8e2 | ||
|
|
bf4cfb9c02 | ||
|
|
a99dfecf43 | ||
|
|
1530d96eca | ||
|
|
5dc066f4c6 | ||
|
|
395f33cd5b | ||
|
|
21b20cf575 | ||
|
|
e3dd25b591 | ||
|
|
b9a4e7ab11 | ||
|
|
6ae67c410c | ||
|
|
4ef7667484 | ||
|
|
3660e2f127 | ||
|
|
357d294f2d | ||
|
|
bb636128b0 | ||
|
|
aa102ab393 | ||
|
|
ea53665e64 | ||
|
|
9cf7fa1997 | ||
|
|
fded714f18 | ||
|
|
06de06b501 | ||
|
|
9abdbbbc5b | ||
|
|
3ebfee8bd2 | ||
|
|
6d446c5b27 | ||
|
|
3a55490bbf | ||
|
|
bc4643fed2 | ||
|
|
a2110ca631 | ||
|
|
413137ac64 | ||
|
|
9bc966491d | ||
|
|
61dbc15fc6 | ||
|
|
b475037127 | ||
|
|
35622a2122 | ||
|
|
77e4c3d0ec | ||
|
|
8523050ab2 | ||
|
|
0efdf65202 | ||
|
|
acb0991d05 | ||
|
|
a9f68f5487 | ||
|
|
55bb2fdb9b | ||
|
|
e529633b8b | ||
|
|
dfd575b6eb | ||
|
|
c5196721d4 | ||
|
|
968c75b618 | ||
|
|
01beebf660 | ||
|
|
d3115e834a | ||
|
|
01a552ceb5 | ||
|
|
d8958adea5 | ||
|
|
4d5e866db6 | ||
|
|
f189deb415 | ||
|
|
9c460dbc87 | ||
|
|
2c6ba9ba2c |
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -12,7 +12,7 @@ runs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Install Node.js dependencies for the TypeScript script
|
||||
|
||||
2
.github/workflows/dev.yml
vendored
2
.github/workflows/dev.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
16
.github/workflows/main-docker.yml
vendored
16
.github/workflows/main-docker.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install npm dependencies
|
||||
@@ -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 }})
|
||||
@@ -116,10 +116,10 @@ jobs:
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm64
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
- dockerfile: Dockerfile.legacy
|
||||
platform: linux/arm/v7
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
- dockerfile: Dockerfile.legacy
|
||||
platform: linux/arm/v8
|
||||
image: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.image }}
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -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-*
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -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 }}
|
||||
|
||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -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
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -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-*
|
||||
|
||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@stylistic/eslint-plugin": "5.5.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.18.12",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/express": "5.0.5",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/yargs": "17.0.34",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.4.1",
|
||||
"mermaid": "11.12.0",
|
||||
"mind-elixir": "5.3.3",
|
||||
"mermaid": "11.12.1",
|
||||
"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.1",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -218,12 +218,12 @@ export type CommandMappings = {
|
||||
/** Works only in the electron context menu. */
|
||||
replaceMisspelling: CommandData;
|
||||
|
||||
importMarkdownInline: CommandData;
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
|
||||
@@ -417,7 +417,7 @@ export default class FNote {
|
||||
return notePaths;
|
||||
}
|
||||
|
||||
getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] {
|
||||
getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] {
|
||||
const isHoistedRoot = hoistedNoteId === "root";
|
||||
|
||||
const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({
|
||||
@@ -428,7 +428,23 @@ export default class FNote {
|
||||
isHidden: path.includes("_hidden")
|
||||
}));
|
||||
|
||||
// Calculate the length of the prefix match between two arrays
|
||||
const prefixMatchLength = (path: string[], target: string[]) => {
|
||||
const diffIndex = path.findIndex((seg, i) => seg !== target[i]);
|
||||
return diffIndex === -1 ? Math.min(path.length, target.length) : diffIndex;
|
||||
};
|
||||
|
||||
notePaths.sort((a, b) => {
|
||||
if (activeNotePath) {
|
||||
const activeSegments = activeNotePath.split('/');
|
||||
const aOverlap = prefixMatchLength(a.notePath, activeSegments);
|
||||
const bOverlap = prefixMatchLength(b.notePath, activeSegments);
|
||||
// Paths with more matching prefix segments are prioritized
|
||||
// when the match count is equal, other criteria are used for sorting
|
||||
if (bOverlap !== aOverlap) {
|
||||
return bOverlap - aOverlap;
|
||||
}
|
||||
}
|
||||
if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
|
||||
return a.isInHoistedSubTree ? -1 : 1;
|
||||
} else if (a.isArchived !== b.isArchived) {
|
||||
@@ -449,10 +465,11 @@ export default class FNote {
|
||||
* Returns the note path considered to be the "best"
|
||||
*
|
||||
* @param {string} [hoistedNoteId='root']
|
||||
* @param {string|null} [activeNotePath=null]
|
||||
* @return {string[]} array of noteIds constituting the particular note path
|
||||
*/
|
||||
getBestNotePath(hoistedNoteId = "root") {
|
||||
return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
|
||||
getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) {
|
||||
return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,20 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
|
||||
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
|
||||
}
|
||||
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
|
||||
containerRef.current?.replaceChildren(...$renderedContent);
|
||||
const container = containerRef.current!;
|
||||
container.replaceChildren(...$renderedContent);
|
||||
|
||||
// Wait for all images to load.
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
await Promise.all(
|
||||
images.map(img => {
|
||||
if (img.complete) return Promise.resolve();
|
||||
return new Promise<void>(resolve => {
|
||||
img.addEventListener("load", () => resolve(), { once: true });
|
||||
img.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
load().then(() => requestAnimationFrame(onReady))
|
||||
|
||||
@@ -20,9 +20,6 @@ function setupGlobs() {
|
||||
window.glob.froca = froca;
|
||||
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
|
||||
|
||||
// for CKEditor integration (button on block toolbar)
|
||||
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
|
||||
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
const string = String(msg).toLowerCase();
|
||||
|
||||
|
||||
@@ -26,21 +26,12 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
|
||||
}
|
||||
|
||||
const path = notePath.split("/").reverse();
|
||||
|
||||
if (!path.includes("root")) {
|
||||
path.push("root");
|
||||
}
|
||||
|
||||
const effectivePathSegments: string[] = [];
|
||||
let childNoteId: string | null = null;
|
||||
let i = 0;
|
||||
|
||||
while (true) {
|
||||
if (i >= path.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentNoteId = path[i++];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const parentNoteId = path[i];
|
||||
|
||||
if (childNoteId !== null) {
|
||||
const child = await froca.getNote(childNoteId, !logErrors);
|
||||
@@ -65,7 +56,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parents.some((p) => p.noteId === parentNoteId)) {
|
||||
if (!parents.some(p => p.noteId === parentNoteId) || (i === path.length - 1 && parentNoteId !== 'root')) {
|
||||
if (logErrors) {
|
||||
const parent = froca.getNoteFromCache(parentNoteId);
|
||||
|
||||
@@ -77,7 +68,8 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
|
||||
);
|
||||
}
|
||||
|
||||
const bestNotePath = child.getBestNotePath(hoistedNoteId);
|
||||
const activeNotePath = appContext.tabManager.getActiveContextNotePath();
|
||||
const bestNotePath = child.getBestNotePath(hoistedNoteId, activeNotePath);
|
||||
|
||||
if (bestNotePath) {
|
||||
const pathToRoot = bestNotePath.reverse().slice(1);
|
||||
@@ -108,7 +100,9 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
|
||||
if (!note) {
|
||||
throw new Error(`Unable to find note: ${notePath}.`);
|
||||
}
|
||||
const bestNotePath = note.getBestNotePath(hoistedNoteId);
|
||||
|
||||
const activeNotePath = appContext.tabManager.getActiveContextNotePath();
|
||||
const bestNotePath = note.getBestNotePath(hoistedNoteId, activeNotePath);
|
||||
|
||||
if (!bestNotePath) {
|
||||
throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`);
|
||||
|
||||
@@ -9,16 +9,6 @@ async function ensureJQuery() {
|
||||
(window as any).$ = $;
|
||||
}
|
||||
|
||||
async function applyMath() {
|
||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
||||
if (!anyMathBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
|
||||
renderMathInElement(document.getElementById("content"));
|
||||
}
|
||||
|
||||
async function formatCodeBlocks() {
|
||||
const anyCodeBlock = document.querySelector("#content pre");
|
||||
if (!anyCodeBlock) {
|
||||
@@ -31,54 +21,4 @@ async function formatCodeBlocks() {
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
||||
*/
|
||||
async function fetchNote(noteId: string | null = null) {
|
||||
if (!noteId) {
|
||||
noteId = document.body.getAttribute("data-note-id");
|
||||
}
|
||||
|
||||
const resp = await fetch(`api/notes/${noteId}`);
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
|
||||
if (toggleMenuButton && layout) {
|
||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
value: fetchNote
|
||||
});
|
||||
|
||||
@@ -2034,9 +2034,9 @@ body.zen #right-pane,
|
||||
body.zen #mobile-sidebar-wrapper,
|
||||
body.zen .tab-row-container,
|
||||
body.zen .tab-row-widget,
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .note-icon-widget,
|
||||
body.zen .title-row .icon-action,
|
||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||
|
||||
@@ -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": "انقر للدخول الى جلسة محمية"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "删除此笔记的所有修订版本",
|
||||
"delete_all_button": "删除所有修订版本",
|
||||
"help_title": "关于笔记修订版本的帮助",
|
||||
"revision_last_edited": "此修订版本上次编辑于 {{date}}",
|
||||
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
|
||||
"no_revisions": "此笔记暂无修订版本...",
|
||||
"restore_button": "恢复",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"homepage": "Startseite:",
|
||||
"app_version": "App-Version:",
|
||||
"db_version": "DB-Version:",
|
||||
"sync_version": "Synch-version:",
|
||||
"sync_version": "Sync-Version:",
|
||||
"build_date": "Build-Datum:",
|
||||
"build_revision": "Build-Revision:",
|
||||
"data_directory": "Datenverzeichnis:"
|
||||
@@ -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",
|
||||
@@ -259,7 +260,6 @@
|
||||
"delete_all_revisions": "Lösche alle Revisionen dieser Notiz",
|
||||
"delete_all_button": "Alle Revisionen löschen",
|
||||
"help_title": "Hilfe zu Notizrevisionen",
|
||||
"revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet",
|
||||
"confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?",
|
||||
"no_revisions": "Für diese Notiz gibt es noch keine Revisionen...",
|
||||
"confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.",
|
||||
@@ -647,7 +647,8 @@
|
||||
"logout": "Abmelden",
|
||||
"show-cheatsheet": "Cheatsheet anzeigen",
|
||||
"toggle-zen-mode": "Zen Modus",
|
||||
"new-version-available": "Neues Update verfügbar"
|
||||
"new-version-available": "Neues Update verfügbar",
|
||||
"download-update": "Version {{latestVersion}} herunterladen"
|
||||
},
|
||||
"sync_status": {
|
||||
"unknown": "<p>Der Synchronisations-Status wird bekannt, sobald der nächste Synchronisierungsversuch gestartet wird.</p><p>Klicke, um eine Synchronisierung jetzt auszulösen.</p>",
|
||||
@@ -989,7 +990,7 @@
|
||||
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
||||
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
||||
"started": "Geschützte Sitzung gestartet.",
|
||||
"wrong_password": "Passwort flasch.",
|
||||
"wrong_password": "Passwort falsch.",
|
||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||
"unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.",
|
||||
"protecting-in-progress": "Schützen läuft: {{count}}",
|
||||
@@ -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…"
|
||||
@@ -1654,7 +1657,7 @@
|
||||
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
|
||||
"cut": "Ausschneiden",
|
||||
"copy": "Kopieren",
|
||||
"copy-link": "Link opieren",
|
||||
"copy-link": "Link kopieren",
|
||||
"paste": "Einfügen",
|
||||
"paste-as-plain-text": "Als unformatierten Text einfügen",
|
||||
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
|
||||
@@ -2079,6 +2082,7 @@
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Folie bearbeiten",
|
||||
"start-presentation": "Präsentation starten"
|
||||
"start-presentation": "Präsentation starten",
|
||||
"slide-overview": "Übersicht der Folien ein-/ausblenden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"export_status": "Export status",
|
||||
"export_in_progress": "Export in progress: {{progressCount}}",
|
||||
"export_finished_successfully": "Export finished successfully.",
|
||||
"format_pdf": "PDF - for printing or sharing purposes."
|
||||
"format_pdf": "PDF - for printing or sharing purposes.",
|
||||
"share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website."
|
||||
},
|
||||
"help": {
|
||||
"title": "Cheatsheet",
|
||||
@@ -260,7 +261,6 @@
|
||||
"delete_all_revisions": "Delete all revisions of this note",
|
||||
"delete_all_button": "Delete all revisions",
|
||||
"help_title": "Help on Note Revisions",
|
||||
"revision_last_edited": "This revision was last edited on {{date}}",
|
||||
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
||||
"no_revisions": "No revisions for this note yet...",
|
||||
"restore_button": "Restore",
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "Eliminar todas las revisiones de esta nota",
|
||||
"delete_all_button": "Eliminar todas las revisiones",
|
||||
"help_title": "Ayuda sobre revisiones de notas",
|
||||
"revision_last_edited": "Esta revisión se editó por última vez en {{date}}",
|
||||
"confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?",
|
||||
"no_revisions": "Aún no hay revisiones para esta nota...",
|
||||
"restore_button": "Restaurar",
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
"delete_all_revisions": "Supprimer toutes les versions de cette note",
|
||||
"delete_all_button": "Supprimer toutes les versions",
|
||||
"help_title": "Aide sur les versions de notes",
|
||||
"revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}",
|
||||
"confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?",
|
||||
"no_revisions": "Aucune version pour cette note pour l'instant...",
|
||||
"confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.",
|
||||
|
||||
5
apps/client/src/translations/hi/translation.json
Normal file
5
apps/client/src/translations/hi/translation.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "ट्रिलियम नोट्स के बारें में"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,50 @@
|
||||
{}
|
||||
{
|
||||
"about": {
|
||||
"title": "A Trilium Notes-ról",
|
||||
"homepage": "Kezdőlap:",
|
||||
"app_version": "Alkalmazás verziója:",
|
||||
"db_version": "Adatbázis verzió:",
|
||||
"sync_version": "Verzió szinkronizálás :",
|
||||
"build_revision": "Build revízió:",
|
||||
"data_directory": "Adatkönyvtár:",
|
||||
"build_date": "Build dátum:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritikus hiba",
|
||||
"message": "Kritikus hiba történt, amely megakadályozza a kliensalkalmazás indítását:\n\n{{message}}\n\nEzt valószínűleg egy váratlan szkripthiba okozza. Próbálja meg biztonságos módban elindítani az alkalmazást, és hárítsa el a problémát."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Nem sikerült inicializálni egy widgetet",
|
||||
"message-custom": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó egyéni widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}",
|
||||
"message-unknown": "Ismeretlen widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Link hozzáadása",
|
||||
"help_on_links": "Segítség a linkekhez",
|
||||
"note": "Jegyzet",
|
||||
"search_note": "név szerinti jegyzetkeresés",
|
||||
"link_title_mirrors": "A link cím tükrözi a jegyzet aktuális címét",
|
||||
"link_title_arbitrary": "link cím önkényesen módosítható",
|
||||
"link_title": "Link cím",
|
||||
"button_add_link": "Link hozzáadása"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Az elágazás előtagjának szerkesztése",
|
||||
"help_on_tree_prefix": "Segítség a fa előtagján",
|
||||
"prefix": "Az előtag: ",
|
||||
"save": "Mentés"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Tömeges akciók",
|
||||
"affected_notes": "Érintett jegyzetek",
|
||||
"labels": "Címkék",
|
||||
"relations": "Kapcsolatok",
|
||||
"notes": "Jegyzetek"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +867,6 @@
|
||||
"delete_all_revisions": "Elimina tutte le revisioni di questa nota",
|
||||
"delete_all_button": "Elimina tutte le revisioni",
|
||||
"help_title": "Aiuto sulle revisioni delle note",
|
||||
"revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}",
|
||||
"confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
|
||||
"no_revisions": "Ancora nessuna revisione per questa nota...",
|
||||
"restore_button": "Ripristina",
|
||||
|
||||
@@ -610,7 +610,6 @@
|
||||
"delete_all_revisions": "このノートの変更履歴をすべて削除",
|
||||
"delete_all_button": "変更履歴をすべて削除",
|
||||
"help_title": "変更履歴のヘルプ",
|
||||
"revision_last_edited": "この変更は{{date}}に行われました",
|
||||
"confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
|
||||
"no_revisions": "このノートに変更履歴はまだありません...",
|
||||
"restore_button": "復元",
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"critical-error": {
|
||||
"title": "Kritische Error",
|
||||
"message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Starten widget mislukt",
|
||||
"message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Custom script laden mislukt"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"delete_all_revisions": "Usuń wszystkie wersje tej notatki",
|
||||
"delete_all_button": "Usuń wszystkie wersje",
|
||||
"help_title": "Pomoc dotycząca wersji notatki",
|
||||
"revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}",
|
||||
"confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?",
|
||||
"no_revisions": "Brak wersji dla tej notatki...",
|
||||
"restore_button": "Przywróć",
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "Apagar todas as versões desta nota",
|
||||
"delete_all_button": "Apagar todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Quer apagar todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
|
||||
@@ -415,7 +415,6 @@
|
||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||
"delete_all_button": "Excluir todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
|
||||
@@ -1090,7 +1090,6 @@
|
||||
"preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
|
||||
"restore_button": "Restaurează",
|
||||
"revision_deleted": "Revizia notiței a fost ștearsă.",
|
||||
"revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}",
|
||||
"revision_restored": "Revizia notiței a fost restaurată.",
|
||||
"revisions_deleted": "Notița reviziei a fost ștearsă.",
|
||||
"maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.",
|
||||
|
||||
@@ -320,7 +320,8 @@
|
||||
"explodeArchivesTooltip": "Если этот флажок установлен, Trilium будет читать файлы <code>.zip</code>, <code>.enex</code> и <code>.opml</code> и создавать заметки из файлов внутри этих архивов. Если флажок не установлен, Trilium будет прикреплять сами архивы к заметке.",
|
||||
"explodeArchives": "Прочитать содержимое архивов <code>.zip</code>, <code>.enex</code> и <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Если этот параметр включен, Trilium попытается уменьшить размер импортируемых изображений путём масштабирования и оптимизации, что может повлиять на воспринимаемое качество изображения. Если этот параметр не установлен, изображения будут импортированы без изменений.</p><p>Это не относится к импорту файлов <code>.zip</code> с метаданными, поскольку предполагается, что эти файлы уже оптимизированы.</p>",
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных"
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных",
|
||||
"importZipRecommendation": "При импорте ZIP файла иерархия заметок будет отражена в структуре папок внутри архива."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Импорт Markdown",
|
||||
@@ -365,7 +366,6 @@
|
||||
"delete_all_button": "Удалить все версии",
|
||||
"help_title": "Помощь по версиям заметок",
|
||||
"confirm_delete_all": "Вы хотите удалить все версии этой заметки?",
|
||||
"revision_last_edited": "Эта версия последний раз редактировалась {{date}}",
|
||||
"confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.",
|
||||
"confirm_delete": "Вы хотите удалить эту версию?",
|
||||
"revisions_deleted": "Версии заметки были удалены.",
|
||||
@@ -980,7 +980,8 @@
|
||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК"
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||
"new-version-available": "Доступно обновление"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"backlink": "{{count}} ссылки",
|
||||
|
||||
@@ -256,7 +256,6 @@
|
||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||
"delete_all_button": "Obriši sve revizije",
|
||||
"help_title": "Pomoć za Revizije beleški",
|
||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||
"restore_button": "Vrati",
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
"delete_all_revisions": "刪除此筆記的所有歷史版本",
|
||||
"delete_all_button": "刪除所有歷史版本",
|
||||
"help_title": "關於筆記歷史版本的說明",
|
||||
"revision_last_edited": "此歷史版本上次於 {{date}} 編輯",
|
||||
"confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?",
|
||||
"no_revisions": "此筆記暫無歷史版本…",
|
||||
"confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。",
|
||||
|
||||
@@ -309,7 +309,6 @@
|
||||
"delete_all_revisions": "Видалити всі версії цієї нотатки",
|
||||
"delete_all_button": "Видалити всі версії",
|
||||
"help_title": "Довідка щодо Версій нотаток",
|
||||
"revision_last_edited": "Цю версію востаннє редагували {{date}}",
|
||||
"confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
|
||||
"no_revisions": "Поки що немає версій цієї нотатки...",
|
||||
"restore_button": "Відновити",
|
||||
|
||||
1
apps/client/src/types.d.ts
vendored
1
apps/client/src/types.d.ts
vendored
@@ -26,7 +26,6 @@ interface CustomGlobals {
|
||||
appContext: AppContext;
|
||||
froca: Froca;
|
||||
treeCache: Froca;
|
||||
importMarkdownInline: () => Promise<unknown>;
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
|
||||
@@ -79,7 +79,8 @@ export default function ExportDialog() {
|
||||
values={[
|
||||
{ value: "html", label: t("export.format_html_zip") },
|
||||
{ value: "markdown", label: t("export.format_markdown") },
|
||||
{ value: "opml", label: t("export.format_opml") }
|
||||
{ value: "opml", label: t("export.format_opml") },
|
||||
{ value: "share", label: t("export.share-format") }
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
@@ -14,39 +15,34 @@ interface RenderMarkdownResponse {
|
||||
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [ text, setText ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
const triggerImport = useCallback(() => {
|
||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
convertMarkdownToHtml(text);
|
||||
convertMarkdownToHtml(text, textTypeWidget);
|
||||
} else {
|
||||
setShown(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("importMarkdownInline", triggerImport);
|
||||
useTriliumEvent("pasteMarkdownIntoText", triggerImport);
|
||||
|
||||
async function sendForm() {
|
||||
await convertMarkdownToHtml(text);
|
||||
setText("");
|
||||
setShown(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />}
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
||||
onShown={() => markdownImportTextArea.current?.focus()}
|
||||
onHidden={() => setShown(false) }
|
||||
onHidden={async () => {
|
||||
if (textTypeWidget) {
|
||||
await convertMarkdownToHtml(text, textTypeWidget);
|
||||
}
|
||||
setShown(false);
|
||||
setText("");
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<p>{t("markdown_import.modal_body_text")}</p>
|
||||
@@ -56,26 +52,17 @@ export default function MarkdownImportDialog() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
sendForm();
|
||||
setShown(false);
|
||||
}
|
||||
}}></textarea>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string) {
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
|
||||
if (!textEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewFragment = textEditor.data.processor.toView(htmlContent);
|
||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
||||
|
||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
||||
textEditor.editing.view.focus();
|
||||
|
||||
await textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
|
||||
toast.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
@@ -155,6 +155,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Avoid not showing recent notes when creating a new empty tab.
|
||||
if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return super.handleEventInChildren(name, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,11 +140,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
|
||||
<FormList onSelect={onSelect} fullHeight>
|
||||
{revisions.map((item) =>
|
||||
<FormListItem
|
||||
title={t("revisions.revision_last_edited", { date: item.dateLastEdited })}
|
||||
value={item.revisionId}
|
||||
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
||||
>
|
||||
{item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||
</FormListItem>
|
||||
)}
|
||||
</FormList>);
|
||||
|
||||
@@ -147,6 +147,12 @@ const categories: Category[] = [
|
||||
];
|
||||
|
||||
const icons: Icon[] = [
|
||||
{
|
||||
name: "empty",
|
||||
slug: "empty",
|
||||
category_id: 113,
|
||||
type_of_icon: "REGULAR"
|
||||
},
|
||||
{
|
||||
name: "child",
|
||||
slug: "child-regular",
|
||||
|
||||
@@ -56,4 +56,16 @@
|
||||
|
||||
.note-icon-widget .icon-list span:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
border: 1px dashed var(--muted-text-color);
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
@@ -264,7 +264,6 @@
|
||||
position: absolute;
|
||||
inset-inline-end: 5px;
|
||||
bottom: 5px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.style-resolver {
|
||||
|
||||
@@ -329,6 +329,30 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
});
|
||||
}
|
||||
|
||||
async addHtmlToEditor(html: string) {
|
||||
await this.initialized;
|
||||
|
||||
const editor = this.watchdog.editor;
|
||||
if (!editor) return;
|
||||
|
||||
editor.model.change((writer) => {
|
||||
const viewFragment = editor.data.processor.toView(html);
|
||||
const modelFragment = editor.data.toModel(viewFragment);
|
||||
const insertPosition = editor.model.document.selection.getLastPosition();
|
||||
|
||||
if (insertPosition) {
|
||||
const range = editor.model.insertContent(modelFragment, insertPosition);
|
||||
|
||||
if (range) {
|
||||
writer.setSelection(range.end);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
editor.editing.view.focus();
|
||||
}
|
||||
|
||||
addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
@@ -385,6 +409,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText });
|
||||
}
|
||||
|
||||
pasteMarkdownIntoTextCommand() {
|
||||
this.triggerCommand("showPasteMarkdownDialog", { textTypeWidget: this });
|
||||
}
|
||||
|
||||
getSelectedText() {
|
||||
const range = this.watchdog.editor?.model.document.selection.getFirstRange();
|
||||
let text = "";
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"@electron-forge/cli": "7.10.2",
|
||||
"@electron-forge/maker-deb": "7.10.2",
|
||||
"@electron-forge/maker-dmg": "7.10.2",
|
||||
|
||||
@@ -11,6 +11,7 @@ async function main() {
|
||||
// Copy assets.
|
||||
build.copy("src/assets", "assets/");
|
||||
build.copy("/apps/server/src/assets", "assets/");
|
||||
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||
|
||||
// Copy node modules dependencies
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"fs-extra": "11.3.2"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js
|
||||
import debounce from "@triliumnext/client/src/services/debounce.js";
|
||||
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
|
||||
import cls from "@triliumnext/server/src/services/cls.js";
|
||||
import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js";
|
||||
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js";
|
||||
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
|
||||
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
|
||||
|
||||
@@ -75,7 +75,7 @@ async function setOptions() {
|
||||
optionsService.setOption("compressImages", "false");
|
||||
}
|
||||
|
||||
async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set<string>) {
|
||||
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {
|
||||
const zipFilePath = "output.zip";
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-bullseye-slim AS builder
|
||||
FROM node:24.10.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# 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:24.10.0-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine AS builder
|
||||
FROM node:24.10.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# 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:24.10.0-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine AS builder
|
||||
FROM node:24.10.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# 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:24.10.0-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
28
apps/server/Dockerfile.legacy
Normal file
28
apps/server/Dockerfile.legacy
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:22.21.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
WORKDIR /usr/src/app/build
|
||||
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:24.10.0-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gosu && \
|
||||
rm -rf \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/cache/apt/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./dist /usr/src/app
|
||||
RUN rm -rf /usr/src/app/node_modules/better-sqlite3
|
||||
COPY --from=builder /usr/src/app/node_modules/better-sqlite3 /usr/src/app/node_modules/better-sqlite3
|
||||
COPY ./start-docker.sh /usr/src/app
|
||||
|
||||
# Configure container
|
||||
EXPOSE 8080
|
||||
CMD [ "sh", "./start-docker.sh" ]
|
||||
HEALTHCHECK --start-period=10s CMD exec gosu node node /usr/src/app/docker_healthcheck.cjs
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-bullseye-slim AS builder
|
||||
FROM node:24.10.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# 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:24.10.0-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -36,11 +36,12 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@types/archiver": "7.0.0",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/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,18 +57,17 @@
|
||||
"@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": "2.2.0",
|
||||
"@types/stream-throttle": "0.1.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/turndown": "5.0.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.0",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"debounce": "2.2.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "3.1.10",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
@@ -100,7 +100,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 +110,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,9 +125,9 @@
|
||||
"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",
|
||||
"vite": "7.1.12",
|
||||
"ws": "8.18.3",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ async function main() {
|
||||
|
||||
// Copy assets
|
||||
build.copy("src/assets", "assets/");
|
||||
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||
|
||||
// Copy node modules dependencies
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
return childBranches;
|
||||
}
|
||||
|
||||
get encodedTitle() {
|
||||
return encodeURIComponent(this.title);
|
||||
}
|
||||
|
||||
getVisibleChildBranches() {
|
||||
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
|
||||
}
|
||||
|
||||
getVisibleChildNotes() {
|
||||
return this.getVisibleChildBranches().map((branch) => branch.getNote());
|
||||
}
|
||||
|
||||
hasVisibleChildren() {
|
||||
return this.getVisibleChildNotes().length > 0;
|
||||
}
|
||||
|
||||
get shareId() {
|
||||
return this.noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
||||
* @param attributeId - the id of the attribute owned by this note
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
@@ -149,8 +150,8 @@ function register(router: Router) {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const format = req.query.format || "html";
|
||||
|
||||
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
|
||||
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`);
|
||||
}
|
||||
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
@@ -159,7 +160,7 @@ function register(router: Router) {
|
||||
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
||||
const branch = note.getParentBranches()[0];
|
||||
|
||||
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
|
||||
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
|
||||
const taskContext = new TaskContext(taskId, "export", null);
|
||||
|
||||
try {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown")) {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
|
||||
zipExportService.exportToZip(taskContext, branch, format, res);
|
||||
} else if (type === "single") {
|
||||
if (format !== "html" && format !== "markdown") {
|
||||
|
||||
@@ -98,9 +98,6 @@ async function importNotesToBranch(req: Request) {
|
||||
// import has deactivated note events so becca is not updated, instead we force it to reload
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) {
|
||||
AND (noteId NOT LIKE '_%')
|
||||
UNION ALL
|
||||
SELECT noteId FROM revisions
|
||||
WHERE revisions.dateLastEdited LIKE :date
|
||||
WHERE revisions.dateCreated LIKE :date
|
||||
)
|
||||
ORDER BY isDeleted
|
||||
LIMIT 50`,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ async function register(app: express.Application) {
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
}
|
||||
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
|
||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
|
||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||
}
|
||||
|
||||
export function getShareThemeAssetDir() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const srcRoot = path.join(__dirname, "..", "..");
|
||||
return path.join(srcRoot, "../../packages/share-theme/dist");
|
||||
} else {
|
||||
const resourceDir = getResourceDir();
|
||||
return path.join(resourceDir, "share-theme/assets");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import auth from "../services/auth.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 2500;
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||
export const router = express.Router();
|
||||
|
||||
// TODO: Deduplicate with etapi_utils.ts afterwards.
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = 236;
|
||||
const APP_DB_VERSION = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||
const note = branch.getNote();
|
||||
|
||||
if (note.type === "image" || note.type === "file") {
|
||||
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||
let payload, extension, mime;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
import html from "html";
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import mimeTypes from "mime-types";
|
||||
import mdService from "./markdown.js";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||
import HtmlExportProvider from "./zip/html.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import MarkdownExportProvider from "./zip/markdown.js";
|
||||
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown", "share"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
});
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
const provider = buildProvider();
|
||||
|
||||
const noteIdToMeta: Record<string, NoteMeta> = {};
|
||||
|
||||
function buildProvider() {
|
||||
const providerData: ZipExportProviderData = {
|
||||
getNoteTargetUrl,
|
||||
archive,
|
||||
branch,
|
||||
rewriteFn
|
||||
};
|
||||
switch (format) {
|
||||
case "html":
|
||||
return new HtmlExportProvider(providerData);
|
||||
case "markdown":
|
||||
return new MarkdownExportProvider(providerData);
|
||||
case "share":
|
||||
return new ShareThemeExportProvider(providerData);
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
||||
const lcFileName = fileName.toLowerCase();
|
||||
|
||||
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
let fileName = baseFileName.trim();
|
||||
if (!fileName) {
|
||||
fileName = "note";
|
||||
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
newExtension = "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
newExtension = "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
newExtension = "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
newExtension = "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
newExtension = null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
newExtension = "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
newExtension = "txt";
|
||||
} else {
|
||||
newExtension = mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||
|
||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
||||
fileName += `.${newExtension}`;
|
||||
}
|
||||
|
||||
|
||||
return getUniqueFilename(existingFileNames, fileName);
|
||||
}
|
||||
|
||||
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const notePath = parentMeta.notePath.concat([note.noteId]);
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
|
||||
const extension = provider.mapExtension("text", "text/html", "", format);
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
|
||||
|
||||
const meta: NoteMeta = {
|
||||
isClone: true,
|
||||
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName,
|
||||
type: "text", // export will have text description
|
||||
format: format
|
||||
format: (format === "markdown" ? "markdown" : "html")
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
if (note.type === "text") {
|
||||
meta.format = format;
|
||||
meta.format = (format === "markdown" ? "markdown" : "html");
|
||||
}
|
||||
|
||||
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
||||
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
note.sortChildren();
|
||||
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
||||
|
||||
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
|
||||
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
|
||||
if (format !== "share") {
|
||||
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
|
||||
}
|
||||
|
||||
// if it's a leaf, then we'll export it even if it's empty
|
||||
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
|
||||
if (shouldIncludeFile) {
|
||||
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
||||
}
|
||||
|
||||
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
return url;
|
||||
}
|
||||
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
|
||||
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
||||
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (["html", "markdown"].includes(noteMeta?.format || "")) {
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||
if (isText) {
|
||||
content = content.toString();
|
||||
content = rewriteFn(content, noteMeta);
|
||||
}
|
||||
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
content = provider.prepareContent(title, content, noteMeta, note, branch);
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
|
||||
} else if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
||||
@@ -377,7 +325,7 @@ ${markdownContent}`;
|
||||
|
||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
|
||||
content = prepareContent(noteMeta.title, content, noteMeta);
|
||||
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||
|
||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
|
||||
@@ -393,7 +341,7 @@ ${markdownContent}`;
|
||||
}
|
||||
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + noteMeta.dataFileName,
|
||||
@@ -429,138 +377,21 @@ ${markdownContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
function saveNavigationInner(meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += saveNavigationInner(child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${saveNavigationInner(rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
|
||||
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
|
||||
}
|
||||
provider.prepareMeta(metaFile);
|
||||
|
||||
try {
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
let navigationMeta: NoteMeta | null = null;
|
||||
let indexMeta: NoteMeta | null = null;
|
||||
let cssMeta: NoteMeta | null = null;
|
||||
|
||||
if (format === "html") {
|
||||
navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(navigationMeta);
|
||||
|
||||
indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(indexMeta);
|
||||
|
||||
cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
|
||||
metaFile.files.push(cssMeta);
|
||||
}
|
||||
|
||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||
// filter out relations which are not inside this export
|
||||
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
||||
@@ -584,34 +415,6 @@ ${markdownContent}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
if (format === "html") {
|
||||
if (!navigationMeta || !indexMeta || !cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
saveNavigation(rootMeta, navigationMeta);
|
||||
saveIndex(rootMeta, indexMeta);
|
||||
saveCss(rootMeta, cssMeta);
|
||||
}
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
taskContext.taskSucceeded(null);
|
||||
} catch (e: unknown) {
|
||||
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
||||
log.error(message);
|
||||
@@ -623,9 +426,30 @@ ${markdownContent}`;
|
||||
res.status(500).send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
provider.afterDone(rootMeta);
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
|
||||
|
||||
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Archiver } from "archiver";
|
||||
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import mimeTypes from "mime-types";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export type ExportFormat = "html" | "markdown" | "share";
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
export interface ZipExportProviderData {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
}
|
||||
|
||||
export abstract class ZipExportProvider {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
|
||||
constructor(data: ZipExportProviderData) {
|
||||
this.branch = data.branch;
|
||||
this.getNoteTargetUrl = data.getNoteTargetUrl;
|
||||
this.archive = data.archive;
|
||||
this.zipExportOptions = data.zipExportOptions;
|
||||
this.rewriteFn = data.rewriteFn;
|
||||
}
|
||||
|
||||
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||
abstract afterDone(rootMeta: NoteMeta): void;
|
||||
|
||||
/**
|
||||
* Determines the extension of the resulting file for a specific note type.
|
||||
*
|
||||
* @param type the type of the note.
|
||||
* @param mime the mime type of the note.
|
||||
* @param existingExtension the existing extension, including the leading period character.
|
||||
* @param format the format requested for export (e.g. HTML, Markdown).
|
||||
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
|
||||
*/
|
||||
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
return "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
return "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
return "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
return "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
return null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
return "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
return "txt";
|
||||
} else {
|
||||
return mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
176
apps/server/src/services/export/zip/html.ts
Normal file
176
apps/server/src/services/export/zip/html.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type NoteMeta from "../../meta/note_meta.js";
|
||||
import { escapeHtml, getResourceDir, isDev } from "../../utils";
|
||||
import html from "html";
|
||||
import { ZipExportProvider } from "./abstract_provider.js";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export default class HtmlExportProvider extends ZipExportProvider {
|
||||
|
||||
private navigationMeta: NoteMeta | null = null;
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
private cssMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile) {
|
||||
this.navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
metaFile.files.push(this.navigationMeta);
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
metaFile.files.push(this.indexMeta);
|
||||
|
||||
this.cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
metaFile.files.push(this.cssMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (content.length < 100_000) {
|
||||
content = html.prettyPrint(content, { indent_size: 2 })
|
||||
}
|
||||
content = this.rewriteFn(content as string, noteMeta);
|
||||
return content;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta) {
|
||||
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
this.#saveNavigation(rootMeta, this.navigationMeta);
|
||||
this.#saveIndex(rootMeta, this.indexMeta);
|
||||
this.#saveCss(rootMeta, this.cssMeta);
|
||||
}
|
||||
|
||||
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += this.#saveNavigationInner(rootMeta, child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
const cssContent = fs.readFileSync(cssFile, "utf-8");
|
||||
this.archive.append(cssContent, { name: cssMeta.dataFileName });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
27
apps/server/src/services/export/zip/markdown.ts
Normal file
27
apps/server/src/services/export/zip/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import NoteMeta from "../../meta/note_meta"
|
||||
import { ZipExportProvider } from "./abstract_provider.js"
|
||||
import mdService from "../markdown.js";
|
||||
|
||||
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||
|
||||
prepareMeta() { }
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
markdownContent = this.rewriteFn(markdownContent, noteMeta);
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone() { }
|
||||
|
||||
}
|
||||
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { join } from "path";
|
||||
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
|
||||
import { RESOURCE_DIR } from "../../resource_dir";
|
||||
import { getResourceDir, isDev } from "../../utils";
|
||||
import fs, { readdirSync } from "fs";
|
||||
import { renderNoteForExport } from "../../../share/content_renderer";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import { getShareThemeAssetDir } from "../../../routes/assets";
|
||||
|
||||
const shareThemeAssetDir = getShareThemeAssetDir();
|
||||
|
||||
export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
private assetsMeta: NoteMeta[] = [];
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile: NoteMetaFile): void {
|
||||
|
||||
const assets = [
|
||||
"icon-color.svg"
|
||||
];
|
||||
|
||||
for (const file of readdirSync(shareThemeAssetDir)) {
|
||||
assets.push(`assets/${file}`);
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetMeta = {
|
||||
noImport: true,
|
||||
dataFileName: asset
|
||||
};
|
||||
this.assetsMeta.push(assetMeta);
|
||||
metaFile.files.push(assetMeta);
|
||||
}
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
const basePath = "../".repeat(noteMeta.notePath.length - 1);
|
||||
|
||||
if (note) {
|
||||
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
|
||||
if (typeof content === "string") {
|
||||
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
|
||||
if (match.includes("/assets/")) return match;
|
||||
return `href="#root/${id}"`;
|
||||
});
|
||||
content = this.rewriteFn(content, noteMeta);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta): void {
|
||||
this.#saveAssets(rootMeta, this.assetsMeta);
|
||||
this.#saveIndex(rootMeta);
|
||||
}
|
||||
|
||||
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
|
||||
if (mime.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "html";
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta) {
|
||||
if (!this.indexMeta?.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.branch.getNote();
|
||||
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||
for (const assetMeta of assetsMeta) {
|
||||
if (!assetMeta.dataFileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
|
||||
this.archive.append(cssContent, { name: assetMeta.dataFileName });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getShareThemeAssets(nameWithExtension: string) {
|
||||
let path: string | undefined;
|
||||
if (nameWithExtension === "icon-color.svg") {
|
||||
path = join(RESOURCE_DIR, "images", nameWithExtension);
|
||||
} else if (nameWithExtension.startsWith("assets")) {
|
||||
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
|
||||
} else if (isDev) {
|
||||
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
|
||||
} else {
|
||||
path = join(getResourceDir(), "public", "src", nameWithExtension);
|
||||
}
|
||||
|
||||
return fs.readFileSync(path);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
import type AttachmentMeta from "./attachment_meta.js";
|
||||
import type AttributeMeta from "./attribute_meta.js";
|
||||
import type { ExportFormat } from "../export/zip/abstract_provider.js";
|
||||
|
||||
export interface NoteMetaFile {
|
||||
formatVersion: number;
|
||||
@@ -19,7 +20,7 @@ export default interface NoteMeta {
|
||||
type?: NoteType;
|
||||
mime?: string;
|
||||
/** 'html' or 'markdown', applicable to text notes only */
|
||||
format?: "html" | "markdown";
|
||||
format?: ExportFormat;
|
||||
dataFileName?: string;
|
||||
dirFileName?: string;
|
||||
/** this file should not be imported (e.g., HTML navigation) */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,147 +77,15 @@ class NoteContentFulltextExp extends Expression {
|
||||
|
||||
const resultNoteSet = new NoteSet();
|
||||
|
||||
// Try to use FTS5 if available for better performance
|
||||
if (ftsSearchService.checkFTS5Availability() && this.canUseFTS5()) {
|
||||
try {
|
||||
// Check if we need to search protected notes
|
||||
const searchProtected = protectedSessionService.isProtectedSessionAvailable();
|
||||
|
||||
const noteIdSet = inputNoteSet.getNoteIds();
|
||||
|
||||
// Determine which FTS5 method to use based on operator
|
||||
let ftsResults;
|
||||
if (this.operator === "*=*" || this.operator === "*=" || this.operator === "=*") {
|
||||
// Substring operators use LIKE queries (optimized by trigram index)
|
||||
// Do NOT pass a limit - we want all results to match traditional search behavior
|
||||
ftsResults = ftsSearchService.searchWithLike(
|
||||
this.tokens,
|
||||
this.operator,
|
||||
noteIdSet.size > 0 ? noteIdSet : undefined,
|
||||
{
|
||||
includeSnippets: false,
|
||||
searchProtected: false
|
||||
// No limit specified - return all results
|
||||
},
|
||||
searchContext // Pass context to track internal timing
|
||||
);
|
||||
} else {
|
||||
// Other operators use MATCH syntax
|
||||
ftsResults = ftsSearchService.searchSync(
|
||||
this.tokens,
|
||||
this.operator,
|
||||
noteIdSet.size > 0 ? noteIdSet : undefined,
|
||||
{
|
||||
includeSnippets: false,
|
||||
searchProtected: false // FTS5 doesn't index protected notes
|
||||
},
|
||||
searchContext // Pass context to track internal timing
|
||||
);
|
||||
}
|
||||
|
||||
// Add FTS results to note set
|
||||
for (const result of ftsResults) {
|
||||
if (becca.notes[result.noteId]) {
|
||||
resultNoteSet.add(becca.notes[result.noteId]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND isDeleted = 0
|
||||
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
|
||||
this.findInText(row, inputNoteSet, resultNoteSet);
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current search can use FTS5
|
||||
*/
|
||||
private canUseFTS5(): boolean {
|
||||
// FTS5 doesn't support regex searches well
|
||||
if (this.operator === "%=") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, we'll use FTS5 for most text searches
|
||||
// but keep the original implementation for complex cases
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes search with fallback for special cases
|
||||
*/
|
||||
private executeWithFallback(inputNoteSet: NoteSet, resultNoteSet: NoteSet, searchContext: SearchContext): NoteSet {
|
||||
// Keep existing results from FTS5 and add additional results from fallback
|
||||
for (const row of sql.iterateRows<SearchRow>(`
|
||||
SELECT noteId, type, mime, content, isProtected
|
||||
FROM notes JOIN blobs USING (blobId)
|
||||
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND isDeleted = 0
|
||||
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
|
||||
if (this.operator === "%=" || this.flatText) {
|
||||
// Only process for special cases
|
||||
this.findInText(row, inputNoteSet, resultNoteSet);
|
||||
}
|
||||
this.findInText(row, inputNoteSet, resultNoteSet);
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,953 +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;
|
||||
skipDiagnostics?: boolean; // Skip diagnostic queries for performance measurements
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Substring operators (*=*, *=, =*) use LIKE queries now, not MATCH
|
||||
if (operator === "*=*" || operator === "*=" || operator === "=*") {
|
||||
throw new Error("Substring operators should use searchWithLike(), not MATCH queries");
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
// Only handle operators that work with MATCH
|
||||
switch (operator) {
|
||||
case "=": // Exact phrase match
|
||||
return `"${sanitizedTokens.join(" ")}"`;
|
||||
|
||||
case "!=": // Does not contain
|
||||
return `NOT (${sanitizedTokens.join(" OR ")})`;
|
||||
|
||||
case "~=": // Fuzzy match (use OR)
|
||||
case "~*":
|
||||
return sanitizedTokens.join(" OR ");
|
||||
|
||||
case "%=": // Regex - fallback to custom function
|
||||
log.error(`Regex search operator ${operator} not supported in FTS5`);
|
||||
throw new FTSNotAvailableError("Regex search not supported in FTS5");
|
||||
|
||||
default:
|
||||
throw new FTSQueryError(`Unsupported MATCH operator: ${operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes LIKE wildcards (% and _) in user input to treat them as literals
|
||||
* @param str - User input string
|
||||
* @returns String with LIKE wildcards escaped
|
||||
*/
|
||||
private escapeLikeWildcards(str: string): string {
|
||||
return str.replace(/[%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs substring search using LIKE queries optimized by trigram index
|
||||
* This is used for *=*, *=, and =* operators with detail='none'
|
||||
*
|
||||
* @param tokens - Search tokens
|
||||
* @param operator - Search operator (*=*, *=, =*)
|
||||
* @param noteIds - Optional set of note IDs to filter
|
||||
* @param options - Search options
|
||||
* @param searchContext - Optional search context to track internal timing
|
||||
* @returns Array of search results (noteIds only, no scoring)
|
||||
*/
|
||||
searchWithLike(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {},
|
||||
searchContext?: any
|
||||
): FTSSearchResult[] {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
throw new FTSNotAvailableError();
|
||||
}
|
||||
|
||||
// Normalize tokens to lowercase for case-insensitive search
|
||||
const normalizedTokens = tokens.map(t => t.toLowerCase());
|
||||
|
||||
// Validate token lengths to prevent memory issues
|
||||
const MAX_TOKEN_LENGTH = 1000;
|
||||
const longTokens = normalizedTokens.filter(t => t.length > MAX_TOKEN_LENGTH);
|
||||
if (longTokens.length > 0) {
|
||||
throw new FTSQueryError(
|
||||
`Search tokens too long (max ${MAX_TOKEN_LENGTH} characters). ` +
|
||||
`Long tokens: ${longTokens.map(t => t.substring(0, 50) + '...').join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
limit, // No default limit - return all results
|
||||
offset = 0,
|
||||
skipDiagnostics = false
|
||||
} = options;
|
||||
|
||||
// Run diagnostics BEFORE the actual search (not counted in performance timing)
|
||||
if (!skipDiagnostics) {
|
||||
log.info('[FTS-DIAGNOSTICS] Running index completeness checks (not counted in search timing)...');
|
||||
const totalInFts = sql.getValue<number>(`SELECT COUNT(*) FROM notes_fts`);
|
||||
const totalNotes = 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
|
||||
`);
|
||||
|
||||
if (totalInFts < totalNotes) {
|
||||
log.warn(`[FTS-DIAGNOSTICS] FTS index incomplete: ${totalInFts} indexed out of ${totalNotes} total notes. Run syncMissingNotes().`);
|
||||
} else {
|
||||
log.info(`[FTS-DIAGNOSTICS] FTS index complete: ${totalInFts} notes indexed`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start timing for actual search (excludes diagnostics)
|
||||
const searchStartTime = Date.now();
|
||||
|
||||
// Optimization: If noteIds set is very large, skip filtering to avoid expensive IN clauses
|
||||
// The FTS table already excludes protected notes, so we can search all notes
|
||||
const LARGE_SET_THRESHOLD = 1000;
|
||||
const isLargeNoteSet = noteIds && noteIds.size > LARGE_SET_THRESHOLD;
|
||||
|
||||
if (isLargeNoteSet) {
|
||||
log.info(`[FTS-OPTIMIZATION] Large noteIds set (${noteIds!.size} notes) - skipping IN clause filter, searching all FTS notes`);
|
||||
}
|
||||
|
||||
// Only filter noteIds if the set is small enough to benefit from it
|
||||
const shouldFilterByNoteIds = noteIds && noteIds.size > 0 && !isLargeNoteSet;
|
||||
const nonProtectedNoteIds = shouldFilterByNoteIds
|
||||
? this.filterNonProtectedNoteIds(noteIds)
|
||||
: [];
|
||||
|
||||
let whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build LIKE conditions for each token - search BOTH title and content
|
||||
switch (operator) {
|
||||
case "*=*": // Contains (substring)
|
||||
normalizedTokens.forEach(token => {
|
||||
// Search in BOTH title and content with escaped wildcards
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = this.escapeLikeWildcards(token);
|
||||
params.push(`%${escapedToken}%`, `%${escapedToken}%`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "*=": // Ends with
|
||||
normalizedTokens.forEach(token => {
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = this.escapeLikeWildcards(token);
|
||||
params.push(`%${escapedToken}`, `%${escapedToken}`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "=*": // Starts with
|
||||
normalizedTokens.forEach(token => {
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = this.escapeLikeWildcards(token);
|
||||
params.push(`${escapedToken}%`, `${escapedToken}%`);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new FTSQueryError(`Unsupported LIKE operator: ${operator}`);
|
||||
}
|
||||
|
||||
// Validate that we have search criteria
|
||||
if (whereConditions.length === 0 && nonProtectedNoteIds.length === 0) {
|
||||
throw new FTSQueryError("No search criteria provided (empty tokens and no note filter)");
|
||||
}
|
||||
|
||||
// SQLite parameter limit handling (999 params max)
|
||||
const MAX_PARAMS_PER_QUERY = 900; // Leave margin for other params
|
||||
|
||||
// Add noteId filter if provided
|
||||
if (nonProtectedNoteIds.length > 0) {
|
||||
const tokenParamCount = params.length;
|
||||
const additionalParams = 2; // For limit and offset
|
||||
|
||||
if (nonProtectedNoteIds.length <= MAX_PARAMS_PER_QUERY - tokenParamCount - additionalParams) {
|
||||
// Normal case: all IDs fit in one query
|
||||
whereConditions.push(`noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`);
|
||||
params.push(...nonProtectedNoteIds);
|
||||
} else {
|
||||
// Large noteIds set: split into chunks and execute multiple queries
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < nonProtectedNoteIds.length; i += MAX_PARAMS_PER_QUERY) {
|
||||
chunks.push(nonProtectedNoteIds.slice(i, i + MAX_PARAMS_PER_QUERY));
|
||||
}
|
||||
|
||||
log.info(`Large noteIds set detected (${nonProtectedNoteIds.length} notes), splitting into ${chunks.length} chunks`);
|
||||
|
||||
// Execute a query for each chunk and combine results
|
||||
const allResults: FTSSearchResult[] = [];
|
||||
let remainingLimit = limit !== undefined ? limit : Number.MAX_SAFE_INTEGER;
|
||||
let currentOffset = offset;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (remainingLimit <= 0) break;
|
||||
|
||||
const chunkWhereConditions = [...whereConditions];
|
||||
const chunkParams: any[] = [...params];
|
||||
|
||||
chunkWhereConditions.push(`noteId IN (${chunk.map(() => '?').join(',')})`);
|
||||
chunkParams.push(...chunk);
|
||||
|
||||
// Build chunk query
|
||||
const chunkQuery = `
|
||||
SELECT noteId, title
|
||||
FROM notes_fts
|
||||
WHERE ${chunkWhereConditions.join(' AND ')}
|
||||
${remainingLimit !== Number.MAX_SAFE_INTEGER ? 'LIMIT ?' : ''}
|
||||
${currentOffset > 0 ? 'OFFSET ?' : ''}
|
||||
`;
|
||||
|
||||
if (remainingLimit !== Number.MAX_SAFE_INTEGER) chunkParams.push(remainingLimit);
|
||||
if (currentOffset > 0) chunkParams.push(currentOffset);
|
||||
|
||||
const chunkResults = sql.getRows<{ noteId: string; title: string }>(chunkQuery, chunkParams);
|
||||
allResults.push(...chunkResults.map(row => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 1.0
|
||||
})));
|
||||
|
||||
if (remainingLimit !== Number.MAX_SAFE_INTEGER) {
|
||||
remainingLimit -= chunkResults.length;
|
||||
}
|
||||
currentOffset = 0; // Only apply offset to first chunk
|
||||
}
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 LIKE search (chunked) returned ${allResults.length} results in ${searchTime}ms (excluding diagnostics)`);
|
||||
|
||||
// Track internal search time on context for performance comparison
|
||||
if (searchContext) {
|
||||
searchContext.ftsInternalSearchTime = searchTime;
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
}
|
||||
|
||||
// Build query - LIKE queries are automatically optimized by trigram index
|
||||
// Only add LIMIT/OFFSET if specified
|
||||
const query = `
|
||||
SELECT noteId, title
|
||||
FROM notes_fts
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
${limit !== undefined ? 'LIMIT ?' : ''}
|
||||
${offset > 0 ? 'OFFSET ?' : ''}
|
||||
`;
|
||||
|
||||
// Only add limit/offset params if specified
|
||||
if (limit !== undefined) params.push(limit);
|
||||
if (offset > 0) params.push(offset);
|
||||
|
||||
// Log the search parameters
|
||||
log.info(`FTS5 LIKE search: tokens=[${normalizedTokens.join(', ')}], operator=${operator}, limit=${limit || 'none'}, offset=${offset}`);
|
||||
|
||||
const rows = sql.getRows<{ noteId: string; title: string }>(query, params);
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 LIKE search returned ${rows.length} results in ${searchTime}ms (excluding diagnostics)`);
|
||||
|
||||
// Track internal search time on context for performance comparison
|
||||
if (searchContext) {
|
||||
searchContext.ftsInternalSearchTime = searchTime;
|
||||
}
|
||||
|
||||
return rows.map(row => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 1.0 // LIKE queries don't have ranking
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`FTS5 LIKE search error: ${error}`);
|
||||
throw new FTSQueryError(
|
||||
`FTS5 LIKE search failed: ${error.message}`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param searchContext - Optional search context to track internal timing
|
||||
* @returns Array of search results
|
||||
*/
|
||||
searchSync(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {},
|
||||
searchContext?: any
|
||||
): 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 {
|
||||
// Start timing for actual search
|
||||
const searchStartTime = Date.now();
|
||||
|
||||
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];
|
||||
|
||||
// Optimization: If noteIds set is very large, skip filtering to avoid expensive IN clauses
|
||||
// The FTS table already excludes protected notes, so we can search all notes
|
||||
const LARGE_SET_THRESHOLD = 1000;
|
||||
const isLargeNoteSet = noteIds && noteIds.size > LARGE_SET_THRESHOLD;
|
||||
|
||||
if (isLargeNoteSet) {
|
||||
log.info(`[FTS-OPTIMIZATION] Large noteIds set (${noteIds!.size} notes) - skipping IN clause filter, searching all FTS notes`);
|
||||
}
|
||||
|
||||
// Filter by noteIds if provided and set is small enough
|
||||
const shouldFilterByNoteIds = noteIds && noteIds.size > 0 && !isLargeNoteSet;
|
||||
if (shouldFilterByNoteIds) {
|
||||
// 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);
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 MATCH search returned ${results.length} results in ${searchTime}ms`);
|
||||
|
||||
// Track internal search time on context for performance comparison
|
||||
if (searchContext) {
|
||||
searchContext.ftsInternalSearchTime = searchTime;
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
} catch (error: any) {
|
||||
// Provide structured error information
|
||||
if (error instanceof FTSError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.error(`FTS5 search error: ${error}`);
|
||||
|
||||
// Determine if this is a recoverable error
|
||||
const isRecoverable =
|
||||
error.message?.includes('syntax error') ||
|
||||
error.message?.includes('malformed MATCH') ||
|
||||
error.message?.includes('no such table');
|
||||
|
||||
throw new FTSQueryError(
|
||||
`FTS5 search failed: ${error.message}. ${isRecoverable ? 'Falling back to standard search.' : ''}`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out protected note IDs from the given set
|
||||
*/
|
||||
private filterNonProtectedNoteIds(noteIds: Set<string>): string[] {
|
||||
const noteIdList = Array.from(noteIds);
|
||||
const placeholders = noteIdList.map(() => '?').join(',');
|
||||
|
||||
const nonProtectedNotes = sql.getColumn<string>(`
|
||||
SELECT noteId
|
||||
FROM notes
|
||||
WHERE noteId IN (${placeholders})
|
||||
AND isProtected = 0
|
||||
`, noteIdList);
|
||||
|
||||
return nonProtectedNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches protected notes separately (not in FTS index)
|
||||
* This is a fallback method for protected notes
|
||||
*/
|
||||
searchProtectedNotesSync(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {}
|
||||
): FTSSearchResult[] {
|
||||
if (!protectedSessionService.isProtectedSessionAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
limit = FTS_CONFIG.DEFAULT_LIMIT,
|
||||
offset = 0
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Build query for protected notes only
|
||||
let whereConditions = [`n.isProtected = 1`, `n.isDeleted = 0`];
|
||||
const params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.size > 0) {
|
||||
const noteIdList = Array.from(noteIds);
|
||||
whereConditions.push(`n.noteId IN (${noteIdList.map(() => '?').join(',')})`);
|
||||
params.push(...noteIdList);
|
||||
}
|
||||
|
||||
// Get protected notes
|
||||
const protectedNotes = sql.getRows<{
|
||||
noteId: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
}>(`
|
||||
SELECT n.noteId, n.title, b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
LIMIT ? OFFSET ?
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
const results: FTSSearchResult[] = [];
|
||||
|
||||
for (const note of protectedNotes) {
|
||||
if (!note.content) continue;
|
||||
|
||||
try {
|
||||
// Decrypt content
|
||||
const decryptedContent = protectedSessionService.decryptString(note.content);
|
||||
if (!decryptedContent) continue;
|
||||
|
||||
// Simple token matching for protected notes
|
||||
const contentLower = decryptedContent.toLowerCase();
|
||||
const titleLower = note.title.toLowerCase();
|
||||
let matches = false;
|
||||
|
||||
switch (operator) {
|
||||
case "=": // Exact match
|
||||
const phrase = tokens.join(' ').toLowerCase();
|
||||
matches = contentLower.includes(phrase) || titleLower.includes(phrase);
|
||||
break;
|
||||
case "*=*": // Contains all tokens
|
||||
matches = tokens.every(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
break;
|
||||
case "~=": // Contains any token
|
||||
case "~*":
|
||||
matches = tokens.some(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
break;
|
||||
default:
|
||||
matches = tokens.every(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
results.push({
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
score: 1.0, // Simple scoring for protected notes
|
||||
snippet: this.generateSnippet(decryptedContent)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.info(`Could not decrypt protected note ${note.noteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error: any) {
|
||||
log.error(`Protected notes search error: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a snippet from content
|
||||
*/
|
||||
private generateSnippet(content: string, maxLength: number = 30): string {
|
||||
// Strip HTML tags for snippet
|
||||
const plainText = striptags(content);
|
||||
const normalized = normalize(plainText);
|
||||
|
||||
if (normalized.length <= maxLength * 10) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Extract snippet around first occurrence
|
||||
return normalized.substring(0, maxLength * 10) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the FTS index for a specific note (synchronous)
|
||||
*
|
||||
* @param noteId - The note ID to update
|
||||
* @param title - The note title
|
||||
* @param content - The note content
|
||||
*/
|
||||
updateNoteIndex(noteId: string, title: string, content: string): void {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sql.transactional(() => {
|
||||
// Delete existing entry
|
||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
||||
|
||||
// Insert new entry
|
||||
sql.execute(`
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
VALUES (?, ?, ?)
|
||||
`, [noteId, title, content]);
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Failed to update FTS index for note ${noteId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a note from the FTS index (synchronous)
|
||||
*
|
||||
* @param noteId - The note ID to remove
|
||||
*/
|
||||
removeNoteFromIndex(noteId: string): void {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
||||
} catch (error) {
|
||||
log.error(`Failed to remove note ${noteId} from FTS index: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs missing notes to the FTS index (synchronous)
|
||||
* This is useful after bulk operations like imports where triggers might not fire
|
||||
*
|
||||
* @param noteIds - Optional array of specific note IDs to sync. If not provided, syncs all missing notes.
|
||||
* @returns The number of notes that were synced
|
||||
*/
|
||||
syncMissingNotes(noteIds?: string[]): number {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
log.error("Cannot sync FTS index - FTS5 not available");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
let syncedCount = 0;
|
||||
|
||||
sql.transactional(() => {
|
||||
let query: string;
|
||||
let params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.length > 0) {
|
||||
// Sync specific notes that are missing from FTS
|
||||
const placeholders = noteIds.map(() => '?').join(',');
|
||||
query = `
|
||||
WITH missing_notes AS (
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.noteId IN (${placeholders})
|
||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
AND b.content IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
||||
)
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT noteId, title, content FROM missing_notes
|
||||
`;
|
||||
params = noteIds;
|
||||
} else {
|
||||
// Sync all missing notes
|
||||
query = `
|
||||
WITH missing_notes AS (
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
AND b.content IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
||||
)
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT noteId, title, content FROM missing_notes
|
||||
`;
|
||||
}
|
||||
|
||||
const result = sql.execute(query, params);
|
||||
syncedCount = result.changes;
|
||||
|
||||
if (syncedCount > 0) {
|
||||
log.info(`Synced ${syncedCount} missing notes to FTS index`);
|
||||
// Optimize if we synced a significant number of notes
|
||||
if (syncedCount > 100) {
|
||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
log.error(`Failed to sync missing notes to FTS index: ${error}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the entire FTS index (synchronous)
|
||||
* This is useful for maintenance or after bulk operations
|
||||
*/
|
||||
rebuildIndex(): void {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
log.error("Cannot rebuild FTS index - FTS5 not available");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Rebuilding FTS5 index...");
|
||||
|
||||
try {
|
||||
sql.transactional(() => {
|
||||
// Clear existing index
|
||||
sql.execute(`DELETE FROM notes_fts`);
|
||||
|
||||
// Rebuild from notes
|
||||
sql.execute(`
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
`);
|
||||
|
||||
// Optimize the FTS table
|
||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||
});
|
||||
|
||||
log.info("FTS5 index rebuild completed");
|
||||
} catch (error) {
|
||||
log.error(`Failed to rebuild FTS index: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets statistics about the FTS index (synchronous)
|
||||
* Includes fallback when dbstat is not available
|
||||
*/
|
||||
getIndexStats(): {
|
||||
totalDocuments: number;
|
||||
indexSize: number;
|
||||
isOptimized: boolean;
|
||||
dbstatAvailable: boolean;
|
||||
} {
|
||||
if (!this.checkFTS5Availability()) {
|
||||
return {
|
||||
totalDocuments: 0,
|
||||
indexSize: 0,
|
||||
isOptimized: false,
|
||||
dbstatAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
const totalDocuments = sql.getValue<number>(`
|
||||
SELECT COUNT(*) FROM notes_fts
|
||||
`) || 0;
|
||||
|
||||
let indexSize = 0;
|
||||
let dbstatAvailable = false;
|
||||
|
||||
try {
|
||||
// Try to get index size from dbstat
|
||||
// dbstat is a virtual table that may not be available in all SQLite builds
|
||||
indexSize = sql.getValue<number>(`
|
||||
SELECT SUM(pgsize)
|
||||
FROM dbstat
|
||||
WHERE name LIKE 'notes_fts%'
|
||||
`) || 0;
|
||||
dbstatAvailable = true;
|
||||
} catch (error: any) {
|
||||
// dbstat not available, use fallback
|
||||
if (error.message?.includes('no such table: dbstat')) {
|
||||
log.info("dbstat virtual table not available, using fallback for index size estimation");
|
||||
|
||||
// Fallback: Estimate based on number of documents and average content size
|
||||
try {
|
||||
const avgContentSize = sql.getValue<number>(`
|
||||
SELECT AVG(LENGTH(content) + LENGTH(title))
|
||||
FROM notes_fts
|
||||
LIMIT 1000
|
||||
`) || 0;
|
||||
|
||||
// Rough estimate: avg size * document count * overhead factor
|
||||
indexSize = Math.round(avgContentSize * totalDocuments * 1.5);
|
||||
} catch (fallbackError) {
|
||||
log.info(`Could not estimate index size: ${fallbackError}`);
|
||||
indexSize = 0;
|
||||
}
|
||||
} else {
|
||||
log.error(`Error accessing dbstat: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalDocuments,
|
||||
indexSize,
|
||||
isOptimized: true, // FTS5 manages optimization internally
|
||||
dbstatAvailable
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ftsSearchService = new FTSSearchService();
|
||||
|
||||
export default ftsSearchService;
|
||||
@@ -62,10 +62,6 @@ class NoteSet {
|
||||
|
||||
return newNoteSet;
|
||||
}
|
||||
|
||||
getNoteIds(): Set<string> {
|
||||
return new Set(this.noteIdSet);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteSet;
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Performance monitoring utilities for search operations
|
||||
*/
|
||||
|
||||
import log from "../log.js";
|
||||
import optionService from "../options.js";
|
||||
|
||||
export interface SearchMetrics {
|
||||
query: string;
|
||||
backend: "typescript" | "sqlite";
|
||||
totalTime: number;
|
||||
parseTime?: number;
|
||||
searchTime?: number;
|
||||
resultCount: number;
|
||||
memoryUsed?: number;
|
||||
cacheHit?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DetailedMetrics extends SearchMetrics {
|
||||
phases?: {
|
||||
name: string;
|
||||
duration: number;
|
||||
}[];
|
||||
sqliteStats?: {
|
||||
rowsScanned?: number;
|
||||
indexUsed?: boolean;
|
||||
tempBTreeUsed?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchPerformanceAverages {
|
||||
avgTime: number;
|
||||
avgResults: number;
|
||||
totalQueries: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: SearchMetrics[] = [];
|
||||
private maxMetricsStored = 1000;
|
||||
private metricsEnabled = false;
|
||||
|
||||
constructor() {
|
||||
// Check if performance logging is enabled
|
||||
this.updateSettings();
|
||||
}
|
||||
|
||||
updateSettings() {
|
||||
try {
|
||||
this.metricsEnabled = optionService.getOptionBool("searchSqlitePerformanceLogging");
|
||||
} catch {
|
||||
this.metricsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
startTimer(): () => number {
|
||||
const startTime = process.hrtime.bigint();
|
||||
return () => {
|
||||
const endTime = process.hrtime.bigint();
|
||||
return Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
recordMetrics(metrics: SearchMetrics) {
|
||||
if (!this.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.metrics.push(metrics);
|
||||
|
||||
// Keep only the last N metrics
|
||||
if (this.metrics.length > this.maxMetricsStored) {
|
||||
this.metrics = this.metrics.slice(-this.maxMetricsStored);
|
||||
}
|
||||
|
||||
// Log significant performance differences
|
||||
if (metrics.totalTime > 1000) {
|
||||
log.info(`Slow search query detected: ${metrics.totalTime.toFixed(2)}ms for query "${metrics.query.substring(0, 100)}"`);
|
||||
}
|
||||
|
||||
// Log to debug for analysis
|
||||
log.info(`Search metrics: backend=${metrics.backend}, time=${metrics.totalTime.toFixed(2)}ms, results=${metrics.resultCount}, query="${metrics.query.substring(0, 50)}"`);
|
||||
}
|
||||
|
||||
recordDetailedMetrics(metrics: DetailedMetrics) {
|
||||
if (!this.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordMetrics(metrics);
|
||||
|
||||
// Log detailed phase information
|
||||
if (metrics.phases) {
|
||||
const phaseLog = metrics.phases
|
||||
.map(p => `${p.name}=${p.duration.toFixed(2)}ms`)
|
||||
.join(", ");
|
||||
log.info(`Search phases: ${phaseLog}`);
|
||||
}
|
||||
|
||||
// Log SQLite specific stats
|
||||
if (metrics.sqliteStats) {
|
||||
log.info(`SQLite stats: rows_scanned=${metrics.sqliteStats.rowsScanned}, index_used=${metrics.sqliteStats.indexUsed}`);
|
||||
}
|
||||
}
|
||||
|
||||
getRecentMetrics(count: number = 100): SearchMetrics[] {
|
||||
return this.metrics.slice(-count);
|
||||
}
|
||||
|
||||
getAverageMetrics(backend?: "typescript" | "sqlite"): SearchPerformanceAverages | null {
|
||||
let relevantMetrics = this.metrics;
|
||||
|
||||
if (backend) {
|
||||
relevantMetrics = this.metrics.filter(m => m.backend === backend);
|
||||
}
|
||||
|
||||
if (relevantMetrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalTime = relevantMetrics.reduce((sum, m) => sum + m.totalTime, 0);
|
||||
const totalResults = relevantMetrics.reduce((sum, m) => sum + m.resultCount, 0);
|
||||
const errorCount = relevantMetrics.filter(m => m.error).length;
|
||||
|
||||
return {
|
||||
avgTime: totalTime / relevantMetrics.length,
|
||||
avgResults: totalResults / relevantMetrics.length,
|
||||
totalQueries: relevantMetrics.length,
|
||||
errorRate: errorCount / relevantMetrics.length
|
||||
};
|
||||
}
|
||||
|
||||
compareBackends(): {
|
||||
typescript: SearchPerformanceAverages;
|
||||
sqlite: SearchPerformanceAverages;
|
||||
recommendation?: string;
|
||||
} {
|
||||
const tsMetrics = this.getAverageMetrics("typescript");
|
||||
const sqliteMetrics = this.getAverageMetrics("sqlite");
|
||||
|
||||
let recommendation: string | undefined;
|
||||
|
||||
if (tsMetrics && sqliteMetrics) {
|
||||
const speedupFactor = tsMetrics.avgTime / sqliteMetrics.avgTime;
|
||||
|
||||
if (speedupFactor > 1.5) {
|
||||
recommendation = `SQLite is ${speedupFactor.toFixed(1)}x faster on average`;
|
||||
} else if (speedupFactor < 0.67) {
|
||||
recommendation = `TypeScript is ${(1/speedupFactor).toFixed(1)}x faster on average`;
|
||||
} else {
|
||||
recommendation = "Both backends perform similarly";
|
||||
}
|
||||
|
||||
// Consider error rates
|
||||
if (sqliteMetrics.errorRate > tsMetrics.errorRate + 0.1) {
|
||||
recommendation += " (but SQLite has higher error rate)";
|
||||
} else if (tsMetrics.errorRate > sqliteMetrics.errorRate + 0.1) {
|
||||
recommendation += " (but TypeScript has higher error rate)";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
typescript: tsMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
||||
sqlite: sqliteMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
||||
recommendation
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
export default performanceMonitor;
|
||||
@@ -24,7 +24,6 @@ class SearchContext {
|
||||
fulltextQuery: string;
|
||||
dbLoadNeeded: boolean;
|
||||
error: string | null;
|
||||
ftsInternalSearchTime: number | null; // Time spent in actual FTS search (excluding diagnostics)
|
||||
|
||||
constructor(params: SearchParams = {}) {
|
||||
this.fastSearch = !!params.fastSearch;
|
||||
@@ -55,7 +54,6 @@ class SearchContext {
|
||||
// and some extra data needs to be loaded before executing
|
||||
this.dbLoadNeeded = false;
|
||||
this.error = null;
|
||||
this.ftsInternalSearchTime = null;
|
||||
}
|
||||
|
||||
addError(error: string) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import sql from "../../sql.js";
|
||||
import scriptService from "../../script.js";
|
||||
import striptags from "striptags";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import ftsSearchService from "../fts_search.js";
|
||||
|
||||
export interface SearchNoteResult {
|
||||
searchResultNoteIds: string[];
|
||||
@@ -402,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]);
|
||||
}
|
||||
@@ -419,90 +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('#');
|
||||
|
||||
// Performance comparison for quick-search (fastSearch === false)
|
||||
const isQuickSearch = searchContext.fastSearch === false;
|
||||
let results: SearchResult[];
|
||||
let ftsTime = 0;
|
||||
let traditionalTime = 0;
|
||||
|
||||
|
||||
if (isPureExpressionQuery) {
|
||||
// For pure expression queries, use standard search without progressive phases
|
||||
results = performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||
} else {
|
||||
// For quick-search, run both FTS5 and traditional search to compare
|
||||
if (isQuickSearch) {
|
||||
log.info(`[QUICK-SEARCH-COMPARISON] Starting comparison for query: "${query}"`);
|
||||
|
||||
// Time FTS5 search (normal path)
|
||||
const ftsStartTime = Date.now();
|
||||
results = findResultsWithExpression(expression, searchContext);
|
||||
ftsTime = Date.now() - ftsStartTime;
|
||||
|
||||
// Time traditional search (with FTS5 disabled)
|
||||
const traditionalStartTime = Date.now();
|
||||
|
||||
// Create a new search context with FTS5 disabled
|
||||
const traditionalContext = new SearchContext({
|
||||
fastSearch: false,
|
||||
includeArchivedNotes: false,
|
||||
includeHiddenNotes: true,
|
||||
fuzzyAttributeSearch: true,
|
||||
ignoreInternalAttributes: true,
|
||||
ancestorNoteId: searchContext.ancestorNoteId
|
||||
});
|
||||
|
||||
// Temporarily disable FTS5 to force traditional search
|
||||
const originalFtsAvailable = (ftsSearchService as any).isFTS5Available;
|
||||
(ftsSearchService as any).isFTS5Available = false;
|
||||
|
||||
const traditionalResults = findResultsWithExpression(expression, traditionalContext);
|
||||
traditionalTime = Date.now() - traditionalStartTime;
|
||||
|
||||
// Restore FTS5 availability
|
||||
(ftsSearchService as any).isFTS5Available = originalFtsAvailable;
|
||||
|
||||
// Log performance comparison
|
||||
// Use internal FTS search time (excluding diagnostics) if available
|
||||
const ftsInternalTime = searchContext.ftsInternalSearchTime ?? ftsTime;
|
||||
const speedup = traditionalTime > 0 ? (traditionalTime / ftsInternalTime).toFixed(2) : "N/A";
|
||||
log.info(`[QUICK-SEARCH-COMPARISON] ===== Results for query: "${query}" =====`);
|
||||
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 search: ${ftsInternalTime}ms (excluding diagnostics), found ${results.length} results`);
|
||||
log.info(`[QUICK-SEARCH-COMPARISON] Traditional search: ${traditionalTime}ms, found ${traditionalResults.length} results`);
|
||||
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 is ${speedup}x faster (saved ${traditionalTime - ftsInternalTime}ms)`);
|
||||
|
||||
// Check if results match
|
||||
const ftsNoteIds = new Set(results.map(r => r.noteId));
|
||||
const traditionalNoteIds = new Set(traditionalResults.map(r => r.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] ========================================`);
|
||||
} 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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { parse, HTMLElement, TextNode } from "node-html-parser";
|
||||
import { parse, HTMLElement, TextNode, Options } from "node-html-parser";
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import escapeHtml from "escape-html";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import { t } from "i18next";
|
||||
import SBranch from "./shaca/entities/sbranch.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import ejs from "ejs";
|
||||
import log from "../services/log.js";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
|
||||
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
|
||||
const templateCache: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Represents the output of the content renderer.
|
||||
@@ -16,7 +29,192 @@ export interface Result {
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote) {
|
||||
interface Subroot {
|
||||
note?: SNote | BNote;
|
||||
branch?: SBranch | BBranch
|
||||
}
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
|
||||
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (note instanceof BNote) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
}
|
||||
}
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) {
|
||||
const subRoot: Subroot = {
|
||||
branch: parentBranch,
|
||||
note: parentBranch.getNote()
|
||||
};
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: parentBranch.noteId,
|
||||
cssToLoad: [
|
||||
`${basePath}assets/styles.css`,
|
||||
`${basePath}assets/scripts.css`,
|
||||
],
|
||||
jsToLoad: [
|
||||
`${basePath}assets/scripts.js`
|
||||
],
|
||||
logoUrl: `${basePath}icon-color.svg`,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNoteContent(note: SNote) {
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
const ancestors: string[] = [];
|
||||
let notePointer = note;
|
||||
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
|
||||
const pointerParent = notePointer.parents[0];
|
||||
if (!pointerParent) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(pointerParent.noteId);
|
||||
notePointer = pointerParent;
|
||||
}
|
||||
|
||||
// Determine CSS to load.
|
||||
const cssToLoad: string[] = [];
|
||||
if (!note.isLabelTruthy("shareOmitDefaultCss")) {
|
||||
cssToLoad.push(`assets/styles.css`);
|
||||
cssToLoad.push(`assets/scripts.css`);
|
||||
}
|
||||
for (const cssRelation of note.getRelations("shareCss")) {
|
||||
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
|
||||
}
|
||||
|
||||
// Determine JS to load.
|
||||
const jsToLoad: string[] = [
|
||||
"assets/scripts.js"
|
||||
];
|
||||
for (const jsRelation of note.getRelations("shareJs")) {
|
||||
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
|
||||
}
|
||||
|
||||
const customLogoId = note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: "_share",
|
||||
cssToLoad,
|
||||
jsToLoad,
|
||||
logoUrl,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
subRoot: Subroot;
|
||||
rootNoteId: string;
|
||||
cssToLoad: string[];
|
||||
jsToLoad: string[];
|
||||
logoUrl: string;
|
||||
ancestors: string[];
|
||||
}
|
||||
|
||||
function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) {
|
||||
const { header, content, isEmpty } = getContent(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
assetPath: shareAdjustedAssetPath,
|
||||
assetUrlFragment,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils,
|
||||
...renderArgs
|
||||
};
|
||||
|
||||
// Check if the user has their own template.
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
return ejs.render(content, opts, { includer });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render with the default view otherwise.
|
||||
const templatePath = getDefaultTemplatePath("page");
|
||||
return ejs.render(readTemplate(templatePath), opts, {
|
||||
includer: (path) => {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return { template: readTemplate(getDefaultTemplatePath(path)) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultTemplatePath(template: string) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: join(getResourceDir(), `share-theme/templates/${template}.ejs`);
|
||||
}
|
||||
|
||||
function readTemplate(path: string) {
|
||||
const cachedTemplate = templateCache.get(path);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
const templateString = readFileSync(path, "utf-8");
|
||||
templateCache.set(path, templateString);
|
||||
return templateString;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote | BNote) {
|
||||
if (note.isProtected) {
|
||||
return {
|
||||
header: "",
|
||||
@@ -65,9 +263,12 @@ function renderIndex(result: Result) {
|
||||
result.content += "</ul>";
|
||||
}
|
||||
|
||||
function renderText(result: Result, note: SNote) {
|
||||
function renderText(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") return;
|
||||
const document = parse(result.content || "");
|
||||
const parseOpts: Partial<Options> = {
|
||||
blockTextElements: {}
|
||||
}
|
||||
const document = parse(result.content || "", parseOpts);
|
||||
|
||||
// Process include notes.
|
||||
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
|
||||
@@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) {
|
||||
const includedResult = getContent(note);
|
||||
if (typeof includedResult.content !== "string") continue;
|
||||
|
||||
const includedDocument = parse(includedResult.content).childNodes;
|
||||
const includedDocument = parse(includedResult.content, parseOpts).childNodes;
|
||||
if (includedDocument) {
|
||||
includeNoteEl.replaceWith(...includedDocument);
|
||||
}
|
||||
@@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) {
|
||||
result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
|
||||
|
||||
if (!result.isEmpty) {
|
||||
// Process attachment links.
|
||||
for (const linkEl of document.querySelectorAll("a")) {
|
||||
const href = linkEl.getAttribute("href");
|
||||
|
||||
@@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) {
|
||||
}
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (result.content.includes(`<span class="math-tex">`)) {
|
||||
result.header += `
|
||||
<script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script>
|
||||
<link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css">
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
renderMathInElement(document.getElementById('content'));
|
||||
});
|
||||
</script>`;
|
||||
// Apply syntax highlight.
|
||||
for (const codeEl of document.querySelectorAll("pre code")) {
|
||||
const highlightResult = highlightAuto(codeEl.innerText);
|
||||
codeEl.innerHTML = highlightResult.value;
|
||||
codeEl.classList.add("hljs");
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (note.hasLabel("shareIndex")) {
|
||||
renderIndex(result);
|
||||
}
|
||||
@@ -174,7 +370,7 @@ export function renderCode(result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMermaid(result: Result, note: SNote) {
|
||||
function renderMermaid(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") {
|
||||
return;
|
||||
}
|
||||
@@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderImage(result: Result, note: SNote) {
|
||||
function renderImage(result: Result, note: SNote | BNote) {
|
||||
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
|
||||
}
|
||||
|
||||
function renderFile(note: SNote, result: Result) {
|
||||
function renderFile(note: SNote | BNote, result: Result) {
|
||||
if (note.mime === "application/pdf") {
|
||||
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user