diff --git a/.gitignore b/.gitignore index cf2fa89dd..ecb8c9edf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ apps/*/out upload .rollup.cache -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +/result \ No newline at end of file diff --git a/README.md b/README.md index ce0b930bc..f62084f7d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q * [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown) * [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content * Customizable UI (sidebar buttons, user-defined widgets, ...) +* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json) ✨ Check out the following third-party resources/communities for more TriliumNext related goodies: diff --git a/_regroup/package.json b/_regroup/package.json index 80e210e1d..2157ad9df 100644 --- a/_regroup/package.json +++ b/_regroup/package.json @@ -40,8 +40,8 @@ "@types/express": "5.0.1", "@types/node": "22.15.29", "@types/yargs": "17.0.33", - "@vitest/coverage-v8": "3.1.4", - "eslint": "9.27.0", + "@vitest/coverage-v8": "3.2.1", + "eslint": "9.28.0", "eslint-plugin-simple-import-sort": "12.1.1", "esm": "3.2.25", "jsdoc": "4.0.4", diff --git a/_regroup/test-etapi/_login.http b/_regroup/test-etapi/_login.http deleted file mode 100644 index 9976e7cd4..000000000 --- a/_regroup/test-etapi/_login.http +++ /dev/null @@ -1,12 +0,0 @@ -POST {{triliumHost}}/etapi/auth/login -Content-Type: application/json - -{ - "password": "1234" -} - -> {% - client.assert(response.status === 201); - - client.global.set("authToken", response.body.authToken); -%} diff --git a/_regroup/test-etapi/api-metrics.http b/_regroup/test-etapi/api-metrics.http deleted file mode 100644 index 78aee7217..000000000 --- a/_regroup/test-etapi/api-metrics.http +++ /dev/null @@ -1,43 +0,0 @@ -### Test regular API metrics endpoint (requires session authentication) - -### Get metrics from regular API (default Prometheus format) -GET {{triliumHost}}/api/metrics - -> {% -client.test("API metrics endpoint returns Prometheus format by default", function() { - client.assert(response.status === 200, "Response status is not 200"); - client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain"); - client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric"); - client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric"); - client.assert(response.body.includes("# HELP"), "Should contain HELP comments"); - client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments"); -}); -%} - -### Get metrics in JSON format -GET {{triliumHost}}/api/metrics?format=json - -> {% -client.test("API metrics endpoint returns JSON when requested", function() { - client.assert(response.status === 200, "Response status is not 200"); - client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json"); - client.assert(response.body.version, "Version info not present"); - client.assert(response.body.database, "Database info not present"); - client.assert(response.body.timestamp, "Timestamp not present"); - client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number"); - client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number"); - client.assert(response.body.noteTypes, "Note types breakdown not present"); - client.assert(response.body.attachmentTypes, "Attachment types breakdown not present"); - client.assert(response.body.statistics, "Statistics not present"); -}); -%} - -### Test invalid format parameter -GET {{triliumHost}}/api/metrics?format=xml - -> {% -client.test("Invalid format parameter returns error", function() { - client.assert(response.status === 500, "Response status should be 500"); - client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats"); -}); -%} \ No newline at end of file diff --git a/_regroup/test-etapi/app-info.http b/_regroup/test-etapi/app-info.http deleted file mode 100644 index a851005c2..000000000 --- a/_regroup/test-etapi/app-info.http +++ /dev/null @@ -1,7 +0,0 @@ -GET {{triliumHost}}/etapi/app-info -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.clipperProtocolVersion === "1.0"); -%} diff --git a/_regroup/test-etapi/basic-auth.http b/_regroup/test-etapi/basic-auth.http deleted file mode 100644 index cf79c357e..000000000 --- a/_regroup/test-etapi/basic-auth.http +++ /dev/null @@ -1,21 +0,0 @@ -GET {{triliumHost}}/etapi/app-info -Authorization: Basic etapi {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.clipperProtocolVersion === "1.0"); -%} - -### - -GET {{triliumHost}}/etapi/app-info -Authorization: Basic etapi wrong - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/app-info -Authorization: Basic wrong {{authToken}} - -> {% client.assert(response.status === 401); %} diff --git a/_regroup/test-etapi/create-backup.http b/_regroup/test-etapi/create-backup.http deleted file mode 100644 index 59ffbebc4..000000000 --- a/_regroup/test-etapi/create-backup.http +++ /dev/null @@ -1,4 +0,0 @@ -PUT {{triliumHost}}/etapi/backup/etapi_test -Authorization: {{authToken}} - -> {% client.assert(response.status === 201); %} diff --git a/_regroup/test-etapi/create-entities.http b/_regroup/test-etapi/create-entities.http deleted file mode 100644 index 98dae28b1..000000000 --- a/_regroup/test-etapi/create-entities.http +++ /dev/null @@ -1,158 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "forcedId{{$randomInt}}", - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!", - "dateCreated": "2023-08-21 23:38:51.123+0200", - "utcDateCreated": "2023-08-21 23:38:51.123Z" -} - -> {% - client.assert(response.status === 201); - client.assert(response.body.note.noteId.startsWith("forcedId")); - client.assert(response.body.note.title == "Hello"); - client.assert(response.body.note.dateCreated == "2023-08-21 23:38:51.123+0200"); - client.assert(response.body.note.utcDateCreated == "2023-08-21 23:38:51.123Z"); - client.assert(response.body.branch.parentNoteId == "root"); - - client.log(`Created note ` + response.body.note.noteId + ` and branch ` + response.body.branch.branchId); - - client.global.set("createdNoteId", response.body.note.noteId); - client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### Clone to another location - -POST {{triliumHost}}/etapi/branches -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "parentNoteId": "_hidden" -} - -> {% - client.assert(response.status === 201); - client.assert(response.body.parentNoteId == "_hidden"); - - client.global.set("clonedBranchId", response.body.branchId); - - client.log(`Created cloned branch ` + response.body.branchId); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.noteId == client.global.get("createdNoteId")); - client.assert(response.body.title == "Hello"); - // order is not defined and may fail in the future - client.assert(response.body.parentBranchIds[0] == client.global.get("clonedBranchId")) - client.assert(response.body.parentBranchIds[1] == client.global.get("createdBranchId")); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body == "Hi there!"); -%} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.branchId == client.global.get("createdBranchId")); - client.assert(response.body.parentNoteId == "root"); -%} - -### - -GET {{triliumHost}}/etapi/branches/{{clonedBranchId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.branchId == client.global.get("clonedBranchId")); - client.assert(response.body.parentNoteId == "_hidden"); -%} - -### - -POST {{triliumHost}}/etapi/attributes -Content-Type: application/json -Authorization: {{authToken}} - -{ - "attributeId": "forcedAttributeId{{$randomInt}}", - "noteId": "{{createdNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": true -} - -> {% - client.assert(response.status === 201); - client.assert(response.body.attributeId.startsWith("forcedAttributeId")); - - client.global.set("createdAttributeId", response.body.attributeId); -%} - -### - -GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.attributeId == client.global.get("createdAttributeId")); -%} - -### - -POST {{triliumHost}}/etapi/attachments -Content-Type: application/json -Authorization: {{authToken}} - -{ - "ownerId": "{{createdNoteId}}", - "role": "file", - "mime": "plain/text", - "title": "my attachment", - "content": "my text" -} - -> {% - client.assert(response.status === 201); - - client.global.set("createdAttachmentId", response.body.attachmentId); -%} - -### - -GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body.attachmentId == client.global.get("createdAttachmentId")); - client.assert(response.body.role == "file"); - client.assert(response.body.mime == "plain/text"); - client.assert(response.body.title == "my attachment"); -%} diff --git a/_regroup/test-etapi/delete-attachment.http b/_regroup/test-etapi/delete-attachment.http deleted file mode 100644 index d12e8de43..000000000 --- a/_regroup/test-etapi/delete-attachment.http +++ /dev/null @@ -1,52 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/attachments -Authorization: {{authToken}} -Content-Type: application/json - -{ - "ownerId": "{{createdNoteId}}", - "role": "file", - "mime": "text/plain", - "title": "my attachment", - "content": "text" -} - -> {% client.global.set("createdAttachmentId", response.body.attachmentId); %} - -### - -DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### repeat the DELETE request to test the idempotency - -DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### - -GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "ATTACHMENT_NOT_FOUND"); -%} diff --git a/_regroup/test-etapi/delete-attribute.http b/_regroup/test-etapi/delete-attribute.http deleted file mode 100644 index d61b75ba2..000000000 --- a/_regroup/test-etapi/delete-attribute.http +++ /dev/null @@ -1,52 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": true -} - -> {% client.global.set("createdAttributeId", response.body.attributeId); %} - -### - -DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### repeat the DELETE request to test the idempotency - -DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### - -GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND"); -%} diff --git a/_regroup/test-etapi/delete-cloned-branch.http b/_regroup/test-etapi/delete-cloned-branch.http deleted file mode 100644 index a87a6fa4d..000000000 --- a/_regroup/test-etapi/delete-cloned-branch.http +++ /dev/null @@ -1,87 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% - client.global.set("createdNoteId", response.body.note.noteId); - client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### Clone to another location - -POST {{triliumHost}}/etapi/branches -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "parentNoteId": "_hidden" -} - -> {% client.global.set("clonedBranchId", response.body.branchId); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/branches/{{clonedBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### repeat the DELETE request to test the idempotency - -DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "BRANCH_NOT_FOUND"); -%} - -### - -GET {{triliumHost}}/etapi/branches/{{clonedBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} diff --git a/_regroup/test-etapi/delete-note-with-all-branches.http b/_regroup/test-etapi/delete-note-with-all-branches.http deleted file mode 100644 index 5a50bc4a9..000000000 --- a/_regroup/test-etapi/delete-note-with-all-branches.http +++ /dev/null @@ -1,126 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% - client.global.set("createdNoteId", response.body.note.noteId); - client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": true -} - -> {% client.global.set("createdAttributeId", response.body.attributeId); %} - -### Clone to another location - -POST {{triliumHost}}/etapi/branches -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "parentNoteId": "_hidden" -} - -> {% client.global.set("clonedBranchId", response.body.branchId); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/branches/{{clonedBranchId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### repeat the DELETE request to test the idempotency - -DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% client.assert(response.status === 204, "Response status is not 204"); %} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "BRANCH_NOT_FOUND"); -%} - -### - -GET {{triliumHost}}/etapi/branches/{{clonedBranchId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code == "BRANCH_NOT_FOUND"); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "NOTE_NOT_FOUND"); -%} - -### - -GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% - client.assert(response.status === 404, "Response status is not 404"); - client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND"); -%} diff --git a/_regroup/test-etapi/export-note-subtree.http b/_regroup/test-etapi/export-note-subtree.http deleted file mode 100644 index 28d90a362..000000000 --- a/_regroup/test-etapi/export-note-subtree.http +++ /dev/null @@ -1,37 +0,0 @@ -GET {{triliumHost}}/etapi/notes/root/export -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.headers.valueOf("Content-Type") == "application/zip"); -%} - -### - -GET {{triliumHost}}/etapi/notes/root/export?format=html -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.headers.valueOf("Content-Type") == "application/zip"); -%} - -### - -GET {{triliumHost}}/etapi/notes/root/export?format=markdown -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.headers.valueOf("Content-Type") == "application/zip"); -%} - -### - -GET {{triliumHost}}/etapi/notes/root/export?format=wrong -Authorization: {{authToken}} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code === "UNRECOGNIZED_EXPORT_FORMAT"); -%} diff --git a/_regroup/test-etapi/get-date-notes.http b/_regroup/test-etapi/get-date-notes.http deleted file mode 100644 index 19f0b4fc9..000000000 --- a/_regroup/test-etapi/get-date-notes.http +++ /dev/null @@ -1,72 +0,0 @@ -GET {{triliumHost}}/etapi/inbox/2022-01-01 -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/calendar/days/2022-01-01 -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/calendar/days/2022-1 -Authorization: {{authToken}} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code === "DATE_INVALID"); -%} - -### - -GET {{triliumHost}}/etapi/calendar/weeks/2022-01-01 -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/calendar/weeks/2022-1 -Authorization: {{authToken}} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code === "DATE_INVALID"); -%} - -### - -GET {{triliumHost}}/etapi/calendar/months/2022-01 -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/calendar/months/2022-1 -Authorization: {{authToken}} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code === "MONTH_INVALID"); -%} - -### - -GET {{triliumHost}}/etapi/calendar/years/2022 -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} - -### - -GET {{triliumHost}}/etapi/calendar/years/202 -Authorization: {{authToken}} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code === "YEAR_INVALID"); -%} diff --git a/_regroup/test-etapi/get-inherited-attribute-cloned.http b/_regroup/test-etapi/get-inherited-attribute-cloned.http deleted file mode 100644 index eaf8d91b1..000000000 --- a/_regroup/test-etapi/get-inherited-attribute-cloned.http +++ /dev/null @@ -1,116 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello parent", - "type": "text", - "content": "Hi there!" -} - -> {% -client.assert(response.status === 201); -client.global.set("parentNoteId", response.body.note.noteId); -client.global.set("parentBranchId", response.body.branch.branchId); -%} - -### Create inheritable parent attribute - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{parentNoteId}}", - "type": "label", - "name": "mylabel", - "value": "", - "isInheritable": true, - "position": 10 -} - -> {% -client.assert(response.status === 201); -client.global.set("parentAttributeId", response.body.attributeId); -%} - -### Create child note under root - -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello child", - "type": "text", - "content": "Hi there!" -} - -> {% -client.assert(response.status === 201); -client.global.set("childNoteId", response.body.note.noteId); -client.global.set("childBranchId", response.body.branch.branchId); -%} - -### Create child attribute - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{childNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": false, - "position": 10 -} - -> {% -client.assert(response.status === 201); -client.global.set("childAttributeId", response.body.attributeId); -%} - -### Clone child to parent - -POST {{triliumHost}}/etapi/branches -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{childNoteId}}", - "parentNoteId": "{{parentNoteId}}" -} - -> {% -client.assert(response.status === 201); -client.assert(response.body.parentNoteId == client.global.get("parentNoteId")); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{childNoteId}} -Authorization: {{authToken}} - -> {% - -function hasAttribute(list, attributeId) { - for (let i = 0; i < list.length; i++) { - if (list[i]["attributeId"] === attributeId) { - return true; - } - } - return false; -} - -client.log(JSON.stringify(response.body.attributes)); - -client.assert(response.status === 200); -client.assert(response.body.noteId == client.global.get("childNoteId")); -client.assert(response.body.attributes.length == 2); -client.assert(hasAttribute(response.body.attributes, client.global.get("parentAttributeId"))); -client.assert(hasAttribute(response.body.attributes, client.global.get("childAttributeId"))); -%} diff --git a/_regroup/test-etapi/get-inherited-attribute.http b/_regroup/test-etapi/get-inherited-attribute.http deleted file mode 100644 index 26e9af854..000000000 --- a/_regroup/test-etapi/get-inherited-attribute.http +++ /dev/null @@ -1,61 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "GetInheritedAttributes Test Note", - "type": "text", - "content": "Hi there!" -} - -> {% - client.assert(response.status === 201); - client.global.set("parentNoteId", response.body.note.noteId); -%} - -### - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{parentNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": true -} - -> {% client.global.set("createdAttributeId", response.body.attributeId); %} - -### - -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "{{parentNoteId}}", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% -client.global.set("createdNoteId", response.body.note.noteId); -client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.noteId == client.global.get("createdNoteId")); -client.assert(response.body.attributes.length == 1); -client.assert(response.body.attributes[0].attributeId == client.global.get("createdAttributeId")); -%} diff --git a/_regroup/test-etapi/get-note-content.http b/_regroup/test-etapi/get-note-content.http deleted file mode 100644 index 50c677dd8..000000000 --- a/_regroup/test-etapi/get-note-content.http +++ /dev/null @@ -1,25 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% - client.global.set("createdNoteId", response.body.note.noteId); - client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} - -> {% - client.assert(response.status === 200); - client.assert(response.body === "Hi there!"); -%} diff --git a/_regroup/test-etapi/http-client.env.json b/_regroup/test-etapi/http-client.env.json deleted file mode 100644 index 8ede0719c..000000000 --- a/_regroup/test-etapi/http-client.env.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dev": { - "triliumHost": "http://localhost:37740" - } -} diff --git a/_regroup/test-etapi/import-zip.http b/_regroup/test-etapi/import-zip.http deleted file mode 100644 index e831a050a..000000000 --- a/_regroup/test-etapi/import-zip.http +++ /dev/null @@ -1,12 +0,0 @@ -POST {{triliumHost}}/etapi/notes/root/import -Authorization: {{authToken}} -Content-Type: application/octet-stream -Content-Transfer-Encoding: binary - -< ../db/demo.zip - -> {% - client.assert(response.status === 201); - client.assert(response.body.note.title == "Trilium Demo"); - client.assert(response.body.branch.parentNoteId == "root"); -%} diff --git a/_regroup/test-etapi/logout.http b/_regroup/test-etapi/logout.http deleted file mode 100644 index 9bd7355e0..000000000 --- a/_regroup/test-etapi/logout.http +++ /dev/null @@ -1,34 +0,0 @@ -POST {{triliumHost}}/etapi/auth/login -Content-Type: application/json - -{ - "password": "1234" -} - -> {% - client.assert(response.status === 201); - - client.global.set("testAuthToken", response.body.authToken); -%} - -### - -GET {{triliumHost}}/etapi/notes/root -Authorization: {{testAuthToken}} - -> {% client.assert(response.status === 200); %} - -### - -POST {{triliumHost}}/etapi/auth/logout -Authorization: {{testAuthToken}} -Content-Type: application/json - -> {% client.assert(response.status === 204); %} - -### - -GET {{triliumHost}}/etapi/notes/root -Authorization: {{testAuthToken}} - -> {% client.assert(response.status === 401); %} diff --git a/_regroup/test-etapi/metrics.http b/_regroup/test-etapi/metrics.http deleted file mode 100644 index 24435f954..000000000 --- a/_regroup/test-etapi/metrics.http +++ /dev/null @@ -1,82 +0,0 @@ -### Test ETAPI metrics endpoint - -# First login to get a token -POST {{triliumHost}}/etapi/auth/login -Content-Type: application/json - -{ - "password": "{{password}}" -} - -> {% -client.test("Login successful", function() { - client.assert(response.status === 201, "Response status is not 201"); - client.assert(response.body.authToken, "Auth token not present"); - client.global.set("authToken", response.body.authToken); -}); -%} - -### Get metrics with authentication (default Prometheus format) -GET {{triliumHost}}/etapi/metrics -Authorization: {{authToken}} - -> {% -client.test("Metrics endpoint returns Prometheus format by default", function() { - client.assert(response.status === 200, "Response status is not 200"); - client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain"); - client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric"); - client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric"); - client.assert(response.body.includes("# HELP"), "Should contain HELP comments"); - client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments"); -}); -%} - -### Get metrics in JSON format -GET {{triliumHost}}/etapi/metrics?format=json -Authorization: {{authToken}} - -> {% -client.test("Metrics endpoint returns JSON when requested", function() { - client.assert(response.status === 200, "Response status is not 200"); - client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json"); - client.assert(response.body.version, "Version info not present"); - client.assert(response.body.database, "Database info not present"); - client.assert(response.body.timestamp, "Timestamp not present"); - client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number"); - client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number"); -}); -%} - -### Get metrics in Prometheus format explicitly -GET {{triliumHost}}/etapi/metrics?format=prometheus -Authorization: {{authToken}} - -> {% -client.test("Metrics endpoint returns Prometheus format when requested", function() { - client.assert(response.status === 200, "Response status is not 200"); - client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain"); - client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric"); - client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric"); -}); -%} - -### Test invalid format parameter -GET {{triliumHost}}/etapi/metrics?format=xml -Authorization: {{authToken}} - -> {% -client.test("Invalid format parameter returns error", function() { - client.assert(response.status === 400, "Response status should be 400"); - client.assert(response.body.code === "INVALID_FORMAT", "Error code should be INVALID_FORMAT"); - client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats"); -}); -%} - -### Test without authentication (should fail) -GET {{triliumHost}}/etapi/metrics - -> {% -client.test("Metrics endpoint requires authentication", function() { - client.assert(response.status === 401, "Response status should be 401"); -}); -%} \ No newline at end of file diff --git a/_regroup/test-etapi/no-token.http b/_regroup/test-etapi/no-token.http deleted file mode 100644 index d8198ed2b..000000000 --- a/_regroup/test-etapi/no-token.http +++ /dev/null @@ -1,109 +0,0 @@ -GET {{triliumHost}}/etapi/notes?search=aaa - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/notes/root - -> {% client.assert(response.status === 401); %} - -### - -PATCH {{triliumHost}}/etapi/notes/root -Authorization: fakeauth - -> {% client.assert(response.status === 401); %} - -### - -DELETE {{triliumHost}}/etapi/notes/root -Authorization: fakeauth - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/branches/root -Authorization: fakeauth - -> {% client.assert(response.status === 401); %} - -### - -PATCH {{triliumHost}}/etapi/branches/root - -> {% client.assert(response.status === 401); %} - -### - -DELETE {{triliumHost}}/etapi/branches/root - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/attributes/000 - -> {% client.assert(response.status === 401); %} - -### - -PATCH {{triliumHost}}/etapi/attributes/000 - -> {% client.assert(response.status === 401); %} - -### - -DELETE {{triliumHost}}/etapi/attributes/000 - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/inbox/2022-02-22 - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/calendar/days/2022-02-22 -Authorization: fakeauth - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/calendar/weeks/2022-02-22 - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/calendar/months/2022-02 - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/calendar/years/2022 - -> {% client.assert(response.status === 401); %} - -### - -POST {{triliumHost}}/etapi/create-note - -> {% client.assert(response.status === 401); %} - -### - -GET {{triliumHost}}/etapi/app-info - -> {% client.assert(response.status === 401); %} - -### Fake URL will get a 404 even without token - -GET {{triliumHost}}/etapi/zzzzzz - -> {% client.assert(response.status === 404); %} diff --git a/_regroup/test-etapi/other.http b/_regroup/test-etapi/other.http deleted file mode 100644 index c3f92fc94..000000000 --- a/_regroup/test-etapi/other.http +++ /dev/null @@ -1,4 +0,0 @@ -POST {{triliumHost}}/etapi/refresh-note-ordering/root -Authorization: {{authToken}} - -> {% client.assert(response.status === 200); %} \ No newline at end of file diff --git a/_regroup/test-etapi/patch-attachment.http b/_regroup/test-etapi/patch-attachment.http deleted file mode 100644 index 44ffe696f..000000000 --- a/_regroup/test-etapi/patch-attachment.http +++ /dev/null @@ -1,79 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/attachments -Authorization: {{authToken}} -Content-Type: application/json - -{ - "ownerId": "{{createdNoteId}}", - "role": "file", - "mime": "text/plain", - "title": "my attachment", - "content": "text" -} - -> {% client.global.set("createdAttachmentId", response.body.attachmentId); %} - -### - -PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "title": "CHANGED", - "position": 999 -} - -### - -GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} - -> {% - client.assert(response.body.title === "CHANGED"); - client.assert(response.body.position === 999); -%} - -### - -PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "ownerId": "root" -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_NOT_ALLOWED"); -%} - -### - -PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "title": null -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR"); -%} diff --git a/_regroup/test-etapi/patch-attribute.http b/_regroup/test-etapi/patch-attribute.http deleted file mode 100644 index 625c19446..000000000 --- a/_regroup/test-etapi/patch-attribute.http +++ /dev/null @@ -1,80 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% - client.global.set("createdNoteId", response.body.note.noteId); - client.global.set("createdBranchId", response.body.branch.branchId); -%} - -### - -POST {{triliumHost}}/etapi/attributes -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "{{createdNoteId}}", - "type": "label", - "name": "mylabel", - "value": "val", - "isInheritable": true -} - -> {% client.global.set("createdAttributeId", response.body.attributeId); %} - -### - -PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "value": "CHANGED" -} - -### - -GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} - -> {% -client.assert(response.body.value === "CHANGED"); -%} - -### - -PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "noteId": "root" -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_NOT_ALLOWED"); -%} - -### - -PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "value": null -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR"); -%} \ No newline at end of file diff --git a/_regroup/test-etapi/patch-branch.http b/_regroup/test-etapi/patch-branch.http deleted file mode 100644 index 48116120c..000000000 --- a/_regroup/test-etapi/patch-branch.http +++ /dev/null @@ -1,66 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "type": "text", - "title": "Hello", - "content": "" -} - -> {% client.global.set("createdBranchId", response.body.branch.branchId); %} - -### - -PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "prefix": "pref", - "notePosition": 666, - "isExpanded": true -} - -### - -GET {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.prefix === 'pref'); -client.assert(response.body.notePosition === 666); -client.assert(response.body.isExpanded === true); -%} - -### - -PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root" -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_NOT_ALLOWED"); -%} - -### - -PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "prefix": 123 -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR"); -%} \ No newline at end of file diff --git a/_regroup/test-etapi/patch-note.http b/_regroup/test-etapi/patch-note.http deleted file mode 100644 index 24b9251d2..000000000 --- a/_regroup/test-etapi/patch-note.http +++ /dev/null @@ -1,83 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "code", - "mime": "application/json", - "content": "{}" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.title === 'Hello'); -client.assert(response.body.type === 'code'); -client.assert(response.body.mime === 'application/json'); -%} - -### - -PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "title": "Wassup", - "type": "html", - "mime": "text/html", - "dateCreated": "2023-08-21 23:38:51.123+0200", - "utcDateCreated": "2023-08-21 23:38:51.123Z" -} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.title === 'Wassup'); -client.assert(response.body.type === 'html'); -client.assert(response.body.mime === 'text/html'); -client.assert(response.body.dateCreated == "2023-08-21 23:38:51.123+0200"); -client.assert(response.body.utcDateCreated == "2023-08-21 23:38:51.123Z"); -%} - -### - -PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "isProtected": true -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_NOT_ALLOWED"); -%} - -### - -PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}} -Authorization: {{authToken}} -Content-Type: application/json - -{ - "title": true -} - -> {% - client.assert(response.status === 400); - client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR"); -%} diff --git a/_regroup/test-etapi/post-revision.http b/_regroup/test-etapi/post-revision.http deleted file mode 100644 index 139397855..000000000 --- a/_regroup/test-etapi/post-revision.http +++ /dev/null @@ -1,23 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "code", - "mime": "text/plain", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/notes/{{createdNoteId}}/revision -Authorization: {{authToken}} -Content-Type: text/plain - -Changed content - -> {% client.assert(response.status === 204); %} diff --git a/_regroup/test-etapi/put-attachment-content-binary.http b/_regroup/test-etapi/put-attachment-content-binary.http deleted file mode 100644 index 6e6d6dad3..000000000 --- a/_regroup/test-etapi/put-attachment-content-binary.http +++ /dev/null @@ -1,39 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/attachments -Authorization: {{authToken}} -Content-Type: application/json - -{ - "ownerId": "{{createdNoteId}}", - "role": "file", - "mime": "text/plain", - "title": "my attachment", - "content": "text" -} - -> {% client.global.set("createdAttachmentId", response.body.attachmentId); %} - -### - -PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content -Authorization: {{authToken}} -Content-Type: application/octet-stream -Content-Transfer-Encoding: binary - -< ../images/icon-color.png - -> {% client.assert(response.status === 204); %} diff --git a/_regroup/test-etapi/put-attachment-content.http b/_regroup/test-etapi/put-attachment-content.http deleted file mode 100644 index 57e96a4b9..000000000 --- a/_regroup/test-etapi/put-attachment-content.http +++ /dev/null @@ -1,45 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "text", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -POST {{triliumHost}}/etapi/attachments -Authorization: {{authToken}} -Content-Type: application/json - -{ - "ownerId": "{{createdNoteId}}", - "role": "file", - "mime": "text/plain", - "title": "my attachment", - "content": "text" -} - -> {% client.global.set("createdAttachmentId", response.body.attachmentId); %} - -### - -PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content -Authorization: {{authToken}} -Content-Type: text/plain - -Changed content - -> {% client.assert(response.status === 204); %} - -### - -GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content -Authorization: {{authToken}} - -> {% client.assert(response.body === "Changed content"); %} diff --git a/_regroup/test-etapi/put-note-content-binary.http b/_regroup/test-etapi/put-note-content-binary.http deleted file mode 100644 index 545b3c111..000000000 --- a/_regroup/test-etapi/put-note-content-binary.http +++ /dev/null @@ -1,25 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "image", - "mime": "image/png", - "content": "" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} -Content-Type: application/octet-stream -Content-Transfer-Encoding: binary - -< ../images/icon-color.png - -> {% client.assert(response.status === 204); %} - diff --git a/_regroup/test-etapi/put-note-content.http b/_regroup/test-etapi/put-note-content.http deleted file mode 100644 index 670195ac2..000000000 --- a/_regroup/test-etapi/put-note-content.http +++ /dev/null @@ -1,30 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "Hello", - "type": "code", - "mime": "text/plain", - "content": "Hi there!" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} -Content-Type: text/plain - -Changed content - -> {% client.assert(response.status === 204); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} - -> {% client.assert(response.body === "Changed content"); %} diff --git a/_regroup/test-etapi/search.http b/_regroup/test-etapi/search.http deleted file mode 100644 index 4655f22e0..000000000 --- a/_regroup/test-etapi/search.http +++ /dev/null @@ -1,39 +0,0 @@ -POST {{triliumHost}}/etapi/create-note -Authorization: {{authToken}} -Content-Type: application/json - -{ - "parentNoteId": "root", - "title": "title", - "type": "text", - "content": "{{$uuid}}" -} - -> {% client.global.set("createdNoteId", response.body.note.noteId); %} - -### - -GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content -Authorization: {{authToken}} - -> {% client.global.set("content", response.body); %} - -### - -GET {{triliumHost}}/etapi/notes?search={{content}}&debug=true -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.results.length === 1); -%} - -### Same but with fast search which doesn't look in the content so 0 notes should be found - -GET {{triliumHost}}/etapi/notes?search={{content}}&fastSearch=true -Authorization: {{authToken}} - -> {% -client.assert(response.status === 200); -client.assert(response.body.results.length === 0); -%} diff --git a/apps/client/package.json b/apps/client/package.json index a1e4c9c6a..752eca730 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -10,7 +10,7 @@ "url": "https://github.com/TriliumNext/Notes" }, "dependencies": { - "@eslint/js": "9.27.0", + "@eslint/js": "9.28.0", "@excalidraw/excalidraw": "0.18.0", "@fullcalendar/core": "6.1.17", "@fullcalendar/daygrid": "6.1.17", @@ -66,7 +66,7 @@ "@types/react": "19.1.6", "@types/react-dom": "19.1.5", "copy-webpack-plugin": "13.0.0", - "happy-dom": "17.5.6", + "happy-dom": "17.6.3", "script-loader": "0.7.2", "vite-plugin-static-copy": "3.0.0" }, diff --git a/apps/client/src/services/froca_updater.ts b/apps/client/src/services/froca_updater.ts index 1f8eaa541..412d8d6cd 100644 --- a/apps/client/src/services/froca_updater.ts +++ b/apps/client/src/services/froca_updater.ts @@ -35,8 +35,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) { loadResults.addOption(attributeEntity.name); } else if (ec.entityName === "attachments") { processAttachment(loadResults, ec); - } else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") { - // NOOP + } else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "note_embeddings") { + // NOOP - these entities are handled at the backend level and don't require frontend processing } else { throw new Error(`Unknown entityName '${ec.entityName}'`); } diff --git a/apps/client/src/services/link.spec.ts b/apps/client/src/services/link.spec.ts index 60812ccf9..09eaf09e7 100644 --- a/apps/client/src/services/link.spec.ts +++ b/apps/client/src/services/link.spec.ts @@ -16,4 +16,24 @@ describe("Link", () => { const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`); expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" }); }); + + it("parses notePath with spaces", () => { + const output = parseNavigationStateFromUrl(` #root/WWaBNf3SSA1b/mQ2tIzLVFKHL`); + expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" }); + }); + + it("ignores external URL with internal hash anchor", () => { + const output = parseNavigationStateFromUrl(`https://en.wikipedia.org/wiki/Bearded_Collie#Health`); + expect(output).toMatchObject({}); + }); + + it("ignores malformed but hash-containing external URL", () => { + const output = parseNavigationStateFromUrl("https://abc.com/#drop?searchString=firefox"); + expect(output).toStrictEqual({}); + }); + + it("ignores non-hash internal path", () => { + const output = parseNavigationStateFromUrl("/root/abc123"); + expect(output).toStrictEqual({}); + }); }); diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 0425652f6..5fda4fe7c 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -204,11 +204,17 @@ export function parseNavigationStateFromUrl(url: string | undefined) { return {}; } + url = url.trim(); const hashIdx = url.indexOf("#"); if (hashIdx === -1) { return {}; } + // Exclude external links that contain # + if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString")) { + return {}; + } + const hash = url.substr(hashIdx + 1); // strip also the initial '#' let [notePath, paramString] = hash.split("?"); diff --git a/apps/client/src/services/load_results.ts b/apps/client/src/services/load_results.ts index 11f9a1a11..59d201f2b 100644 --- a/apps/client/src/services/load_results.ts +++ b/apps/client/src/services/load_results.ts @@ -44,9 +44,17 @@ interface OptionRow {} interface NoteReorderingRow {} -interface ContentNoteIdToComponentIdRow { +interface NoteEmbeddingRow { + embedId: string; noteId: string; - componentId: string; + providerId: string; + modelId: string; + dimension: number; + version: number; + dateCreated: string; + utcDateCreated: string; + dateModified: string; + utcDateModified: string; } type EntityRowMappings = { @@ -56,6 +64,7 @@ type EntityRowMappings = { options: OptionRow; revisions: RevisionRow; note_reordering: NoteReorderingRow; + note_embeddings: NoteEmbeddingRow; }; export type EntityRowNames = keyof EntityRowMappings; diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index e15e3ba88..8207f18ff 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -58,8 +58,11 @@ async function getWithSilentNotFound(url: string, componentId?: string) { return await call("GET", url, componentId, { silentNotFound: true }); } -async function get(url: string, componentId?: string) { - return await call("GET", url, componentId); +/** + * @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. + */ +async function get(url: string, componentId?: string, raw?: boolean) { + return await call("GET", url, componentId, { raw }); } async function post(url: string, data?: unknown, componentId?: string) { @@ -102,6 +105,8 @@ let maxKnownEntityChangeId = 0; interface CallOptions { data?: unknown; silentNotFound?: boolean; + // If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. + raw?: boolean; } async function call(method: string, url: string, componentId?: string, options: CallOptions = {}) { @@ -132,7 +137,7 @@ async function call(method: string, url: string, componentId?: string, option }); })) as any; } else { - resp = await ajax(url, method, data, headers, !!options.silentNotFound); + resp = await ajax(url, method, data, headers, !!options.silentNotFound, options.raw); } const maxEntityChangeIdStr = resp.headers["trilium-max-entity-change-id"]; @@ -144,7 +149,10 @@ async function call(method: string, url: string, componentId?: string, option return resp.body as T; } -function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean): Promise { +/** + * @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. + */ +function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean, raw?: boolean): Promise { return new Promise((res, rej) => { const options: JQueryAjaxSettings = { url: window.glob.baseApiUrl + url, @@ -186,6 +194,10 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile } }; + if (raw) { + options.dataType = "text"; + } + if (data) { try { options.data = JSON.stringify(data); diff --git a/apps/client/src/stylesheets/theme-dark.css b/apps/client/src/stylesheets/theme-dark.css index 3b7a8fcc2..a6aab3118 100644 --- a/apps/client/src/stylesheets/theme-dark.css +++ b/apps/client/src/stylesheets/theme-dark.css @@ -70,6 +70,7 @@ --scrollbar-border-color: #666; --scrollbar-background-color: #333; + --selection-background-color: #3399FF70; --tooltip-background-color: #333; --link-color: lightskyblue; diff --git a/apps/client/src/stylesheets/theme-light.css b/apps/client/src/stylesheets/theme-light.css index 70e81885f..b485e8a27 100644 --- a/apps/client/src/stylesheets/theme-light.css +++ b/apps/client/src/stylesheets/theme-light.css @@ -74,6 +74,7 @@ html { --scrollbar-border-color: #ddd; --scrollbar-background-color: #ddd; + --selection-background-color: #3399FF70; --tooltip-background-color: #f8f8f8; --link-color: blue; diff --git a/apps/client/src/stylesheets/theme-next/ribbon.css b/apps/client/src/stylesheets/theme-next/ribbon.css index c7765865d..e21984a76 100644 --- a/apps/client/src/stylesheets/theme-next/ribbon.css +++ b/apps/client/src/stylesheets/theme-next/ribbon.css @@ -108,6 +108,25 @@ div.editability-dropdown a.dropdown-item { font-size: 0.85em; } +/* + * Edited notes (for calendar notes) + */ + +/* The path of the note */ +.edited-notes-list small { + margin-inline-start: 4px; + font-size: inherit; + color: var(--muted-text-color); +} + +.edited-notes-list small::before { + content: "("; +} + +.edited-notes-list small::after { + content: ")"; +} + /* * Owned attributes */ diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index a77715136..5b11839e5 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -1402,6 +1402,7 @@ div.floating-buttons .show-floating-buttons-button:active { div.floating-buttons-children .close-floating-buttons-button::before, div.floating-buttons .show-floating-buttons-button::before { display: block; + line-height: 1; } /* "Show buttons" button */ diff --git a/apps/client/src/types-lib.d.ts b/apps/client/src/types-lib.d.ts index a19bffa9d..0c4474e94 100644 --- a/apps/client/src/types-lib.d.ts +++ b/apps/client/src/types-lib.d.ts @@ -31,3 +31,23 @@ declare module "katex/contrib/auto-render" { }) => void; export default renderMathInElement; } + +import * as L from "leaflet"; + +declare module "leaflet" { + interface GPXMarker { + startIcon?: DivIcon | Icon | string | undefined; + endIcon?: DivIcon | Icon | string | undefined; + wptIcons?: { + [key: string]: DivIcon | Icon | string; + }; + wptTypeIcons?: { + [key: string]: DivIcon | Icon | string; + }; + pointMatchers?: Array<{ regex: RegExp; icon: DivIcon | Icon | string}>; + } + + interface GPXOptions { + markers?: GPXMarker | undefined; + } +} diff --git a/apps/client/src/widgets/llm_chat/communication.ts b/apps/client/src/widgets/llm_chat/communication.ts index bb58a47b2..614add7ad 100644 --- a/apps/client/src/widgets/llm_chat/communication.ts +++ b/apps/client/src/widgets/llm_chat/communication.ts @@ -6,8 +6,10 @@ import type { SessionResponse } from "./types.js"; /** * Create a new chat session + * @param currentNoteId - Optional current note ID for context + * @returns The noteId of the created chat note */ -export async function createChatSession(currentNoteId?: string): Promise<{chatNoteId: string | null, noteId: string | null}> { +export async function createChatSession(currentNoteId?: string): Promise { try { const resp = await server.post('llm/chat', { title: 'Note Chat', @@ -15,48 +17,42 @@ export async function createChatSession(currentNoteId?: string): Promise<{chatNo }); if (resp && resp.id) { - // The backend might provide the noteId separately from the chatNoteId - // If noteId is provided, use it; otherwise, we'll need to query for it separately - return { - chatNoteId: resp.id, - noteId: resp.noteId || null - }; + // Backend returns the chat note ID as 'id' + return resp.id; } } catch (error) { console.error('Failed to create chat session:', error); } - return { - chatNoteId: null, - noteId: null - }; + return null; } /** - * Check if a session exists + * Check if a chat note exists + * @param noteId - The ID of the chat note */ -export async function checkSessionExists(chatNoteId: string): Promise { +export async function checkSessionExists(noteId: string): Promise { try { - // Validate that we have a proper note ID format, not a session ID - // Note IDs in Trilium are typically longer or in a different format - if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) { - console.warn(`Invalid note ID format detected: ${chatNoteId} appears to be a legacy session ID`); - return false; - } - - const sessionCheck = await server.getWithSilentNotFound(`llm/chat/${chatNoteId}`); + const sessionCheck = await server.getWithSilentNotFound(`llm/chat/${noteId}`); return !!(sessionCheck && sessionCheck.id); } catch (error: any) { - console.log(`Error checking chat note ${chatNoteId}:`, error); + console.log(`Error checking chat note ${noteId}:`, error); return false; } } /** * Set up streaming response via WebSocket + * @param noteId - The ID of the chat note + * @param messageParams - Message parameters + * @param onContentUpdate - Callback for content updates + * @param onThinkingUpdate - Callback for thinking updates + * @param onToolExecution - Callback for tool execution + * @param onComplete - Callback for completion + * @param onError - Callback for errors */ export async function setupStreamingResponse( - chatNoteId: string, + noteId: string, messageParams: any, onContentUpdate: (content: string, isDone?: boolean) => void, onThinkingUpdate: (thinking: string) => void, @@ -64,35 +60,24 @@ export async function setupStreamingResponse( onComplete: () => void, onError: (error: Error) => void ): Promise { - // Validate that we have a proper note ID format, not a session ID - if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) { - console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`); - onError(new Error("Invalid note ID format - using a legacy session ID")); - return; - } - return new Promise((resolve, reject) => { let assistantResponse = ''; - let postToolResponse = ''; // Separate accumulator for post-tool execution content let receivedAnyContent = false; - let receivedPostToolContent = false; // Track if we've started receiving post-tool content let timeoutId: number | null = null; let initialTimeoutId: number | null = null; let cleanupTimeoutId: number | null = null; let receivedAnyMessage = false; - let toolsExecuted = false; // Flag to track if tools were executed in this session - let toolExecutionCompleted = false; // Flag to track if tool execution is completed let eventListener: ((event: Event) => void) | null = null; let lastMessageTimestamp = 0; // Create a unique identifier for this response process const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`; - console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${chatNoteId}`); + console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${noteId}`); // Send the initial request to initiate streaming (async () => { try { - const streamResponse = await server.post(`llm/chat/${chatNoteId}/messages/stream`, { + const streamResponse = await server.post(`llm/chat/${noteId}/messages/stream`, { content: messageParams.content, useAdvancedContext: messageParams.useAdvancedContext, showThinking: messageParams.showThinking, @@ -129,28 +114,14 @@ export async function setupStreamingResponse( resolve(); }; - // Function to schedule cleanup with ability to cancel - const scheduleCleanup = (delay: number) => { - // Clear any existing cleanup timeout - if (cleanupTimeoutId) { - window.clearTimeout(cleanupTimeoutId); + // Set initial timeout to catch cases where no message is received at all + initialTimeoutId = window.setTimeout(() => { + if (!receivedAnyMessage) { + console.error(`[${responseId}] No initial message received within timeout`); + performCleanup(); + reject(new Error('No response received from server')); } - - console.log(`[${responseId}] Scheduling listener cleanup in ${delay}ms`); - - // Set new cleanup timeout - cleanupTimeoutId = window.setTimeout(() => { - // Only clean up if no messages received recently (in last 2 seconds) - const timeSinceLastMessage = Date.now() - lastMessageTimestamp; - if (timeSinceLastMessage > 2000) { - performCleanup(); - } else { - console.log(`[${responseId}] Received message recently, delaying cleanup`); - // Reschedule cleanup - scheduleCleanup(2000); - } - }, delay); - }; + }, 10000); // Create a message handler for CustomEvents eventListener = (event: Event) => { @@ -158,7 +129,7 @@ export async function setupStreamingResponse( const message = customEvent.detail; // Only process messages for our chat note - if (!message || message.chatNoteId !== chatNoteId) { + if (!message || message.chatNoteId !== noteId) { return; } @@ -172,12 +143,12 @@ export async function setupStreamingResponse( cleanupTimeoutId = null; } - console.log(`[${responseId}] LLM Stream message received via CustomEvent: chatNoteId=${chatNoteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}, type=${message.type || 'llm-stream'}`); + console.log(`[${responseId}] LLM Stream message received: content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`); // Mark first message received if (!receivedAnyMessage) { receivedAnyMessage = true; - console.log(`[${responseId}] First message received for chat note ${chatNoteId}`); + console.log(`[${responseId}] First message received for chat note ${noteId}`); // Clear the initial timeout since we've received a message if (initialTimeoutId !== null) { @@ -186,109 +157,33 @@ export async function setupStreamingResponse( } } - // Handle specific message types - if (message.type === 'tool_execution_start') { - toolsExecuted = true; // Mark that tools were executed - onThinkingUpdate('Executing tools...'); - // Also trigger tool execution UI with a specific format - onToolExecution({ - action: 'start', - tool: 'tools', - result: 'Executing tools...' - }); - return; // Skip accumulating content from this message + // Handle error + if (message.error) { + console.error(`[${responseId}] Stream error: ${message.error}`); + performCleanup(); + reject(new Error(message.error)); + return; } - if (message.type === 'tool_result' && message.toolExecution) { - toolsExecuted = true; // Mark that tools were executed - console.log(`[${responseId}] Processing tool result: ${JSON.stringify(message.toolExecution)}`); + // Handle thinking updates - only show if showThinking is enabled + if (message.thinking && messageParams.showThinking) { + console.log(`[${responseId}] Received thinking: ${message.thinking.substring(0, 100)}...`); + onThinkingUpdate(message.thinking); + } - // If tool execution doesn't have an action, add 'result' as the default - if (!message.toolExecution.action) { - message.toolExecution.action = 'result'; - } - - // First send a 'start' action to ensure the container is created - onToolExecution({ - action: 'start', - tool: 'tools', - result: 'Tool execution initialized' - }); - - // Then send the actual tool execution data + // Handle tool execution updates + if (message.toolExecution) { + console.log(`[${responseId}] Tool execution update:`, message.toolExecution); onToolExecution(message.toolExecution); - - // Mark tool execution as completed if this is a result or error - if (message.toolExecution.action === 'result' || message.toolExecution.action === 'complete' || message.toolExecution.action === 'error') { - toolExecutionCompleted = true; - console.log(`[${responseId}] Tool execution completed`); - } - - return; // Skip accumulating content from this message - } - - if (message.type === 'tool_execution_error' && message.toolExecution) { - toolsExecuted = true; // Mark that tools were executed - toolExecutionCompleted = true; // Mark tool execution as completed - onToolExecution({ - ...message.toolExecution, - action: 'error', - error: message.toolExecution.error || 'Unknown error during tool execution' - }); - return; // Skip accumulating content from this message - } - - if (message.type === 'tool_completion_processing') { - toolsExecuted = true; // Mark that tools were executed - toolExecutionCompleted = true; // Tools are done, now processing the result - onThinkingUpdate('Generating response with tool results...'); - // Also trigger tool execution UI with a specific format - onToolExecution({ - action: 'generating', - tool: 'tools', - result: 'Generating response with tool results...' - }); - return; // Skip accumulating content from this message } // Handle content updates if (message.content) { - console.log(`[${responseId}] Received content chunk of length ${message.content.length}, preview: "${message.content.substring(0, 50)}${message.content.length > 50 ? '...' : ''}"`); - - // If tools were executed and completed, and we're now getting new content, - // this is likely the final response after tool execution from Anthropic - if (toolsExecuted && toolExecutionCompleted && message.content) { - console.log(`[${responseId}] Post-tool execution content detected`); - - // If this is the first post-tool chunk, indicate we're starting a new response - if (!receivedPostToolContent) { - receivedPostToolContent = true; - postToolResponse = ''; // Clear any previous post-tool response - console.log(`[${responseId}] First post-tool content chunk, starting fresh accumulation`); - } - - // Accumulate post-tool execution content - postToolResponse += message.content; - console.log(`[${responseId}] Accumulated post-tool content, now ${postToolResponse.length} chars`); - - // Update the UI with the accumulated post-tool content - // This replaces the pre-tool content with our accumulated post-tool content - onContentUpdate(postToolResponse, message.done || false); - } else { - // Standard content handling for non-tool cases or initial tool response - - // Check if this is a duplicated message containing the same content we already have - if (message.done && assistantResponse.includes(message.content)) { - console.log(`[${responseId}] Ignoring duplicated content in done message`); - } else { - // Add to our accumulated response - assistantResponse += message.content; - } - - // Update the UI immediately with each chunk - onContentUpdate(assistantResponse, message.done || false); - } + // Simply append the new content - no complex deduplication + assistantResponse += message.content; + // Update the UI immediately with each chunk + onContentUpdate(assistantResponse, message.done || false); receivedAnyContent = true; // Reset timeout since we got content @@ -298,151 +193,33 @@ export async function setupStreamingResponse( // Set new timeout timeoutId = window.setTimeout(() => { - console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`); - - // Clean up + console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`); performCleanup(); reject(new Error('Stream timeout')); }, 30000); } - // Handle tool execution updates (legacy format and standard format with llm-stream type) - if (message.toolExecution) { - // Only process if we haven't already handled this message via specific message types - if (message.type === 'llm-stream' || !message.type) { - console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`); - toolsExecuted = true; // Mark that tools were executed - - // Mark tool execution as completed if this is a result or error - if (message.toolExecution.action === 'result' || - message.toolExecution.action === 'complete' || - message.toolExecution.action === 'error') { - toolExecutionCompleted = true; - console.log(`[${responseId}] Tool execution completed via toolExecution message`); - } - - onToolExecution(message.toolExecution); - } - } - - // Handle tool calls from the raw data or direct in message (OpenAI format) - const toolCalls = message.tool_calls || (message.raw && message.raw.tool_calls); - if (toolCalls && Array.isArray(toolCalls)) { - console.log(`[${responseId}] Received tool calls: ${toolCalls.length} tools`); - toolsExecuted = true; // Mark that tools were executed - - // First send a 'start' action to ensure the container is created - onToolExecution({ - action: 'start', - tool: 'tools', - result: 'Tool execution initialized' - }); - - // Then process each tool call - for (const toolCall of toolCalls) { - let args = toolCall.function?.arguments || {}; - - // Try to parse arguments if they're a string - if (typeof args === 'string') { - try { - args = JSON.parse(args); - } catch (e) { - console.log(`[${responseId}] Could not parse tool arguments as JSON: ${e}`); - args = { raw: args }; - } - } - - onToolExecution({ - action: 'executing', - tool: toolCall.function?.name || 'unknown', - toolCallId: toolCall.id, - args: args - }); - } - } - - // Handle thinking state updates - if (message.thinking) { - console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`); - onThinkingUpdate(message.thinking); - } - // Handle completion if (message.done) { - console.log(`[${responseId}] Stream completed for chat note ${chatNoteId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`); + console.log(`[${responseId}] Stream completed for chat note ${noteId}, final response: ${assistantResponse.length} chars`); - // Dump message content to console for debugging - if (message.content) { - console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`); - - // Check if the done message contains the exact same content as our accumulated response - // We normalize by removing whitespace to avoid false negatives due to spacing differences - const normalizedMessage = message.content.trim(); - const normalizedResponse = assistantResponse.trim(); - - if (normalizedMessage === normalizedResponse) { - console.log(`[${responseId}] Final message is identical to accumulated response, no need to update`); - } - // If the done message is longer but contains our accumulated response, use the done message - else if (normalizedMessage.includes(normalizedResponse) && normalizedMessage.length > normalizedResponse.length) { - console.log(`[${responseId}] Final message is more complete than accumulated response, using it`); - assistantResponse = message.content; - } - // If the done message is different and not already included, append it to avoid duplication - else if (!normalizedResponse.includes(normalizedMessage) && normalizedMessage.length > 0) { - console.log(`[${responseId}] Final message has unique content, using it`); - assistantResponse = message.content; - } - // Otherwise, we already have the content accumulated, so no need to update - else { - console.log(`[${responseId}] Already have this content accumulated, not updating`); - } - } - - // Clear timeout if set + // Clear all timeouts if (timeoutId !== null) { window.clearTimeout(timeoutId); timeoutId = null; } - // Always mark as done when we receive the done flag - onContentUpdate(assistantResponse, true); - - // Set a longer delay before cleanup to allow for post-tool execution messages - // Especially important for Anthropic which may send final message after tool execution - const cleanupDelay = toolsExecuted ? 15000 : 1000; // 15 seconds if tools were used, otherwise 1 second - console.log(`[${responseId}] Setting cleanup delay of ${cleanupDelay}ms since toolsExecuted=${toolsExecuted}`); - scheduleCleanup(cleanupDelay); + // Schedule cleanup after a brief delay to ensure all processing is complete + cleanupTimeoutId = window.setTimeout(() => { + performCleanup(); + }, 100); } }; - // Register event listener for the custom event - try { - window.addEventListener('llm-stream-message', eventListener); - console.log(`[${responseId}] Event listener added for llm-stream-message events`); - } catch (err) { - console.error(`[${responseId}] Error setting up event listener:`, err); - reject(err); - return; - } + // Register the event listener for WebSocket messages + window.addEventListener('llm-stream-message', eventListener); - // Set initial timeout for receiving any message - initialTimeoutId = window.setTimeout(() => { - console.warn(`[${responseId}] No messages received for initial period in chat note ${chatNoteId}`); - if (!receivedAnyMessage) { - console.error(`[${responseId}] WebSocket connection not established for chat note ${chatNoteId}`); - - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - - // Clean up - cleanupEventListener(eventListener); - - // Show error message to user - reject(new Error('WebSocket connection not established')); - } - }, 10000); + console.log(`[${responseId}] Event listener registered, waiting for messages...`); }); } @@ -463,15 +240,9 @@ function cleanupEventListener(listener: ((event: Event) => void) | null): void { /** * Get a direct response from the server without streaming */ -export async function getDirectResponse(chatNoteId: string, messageParams: any): Promise { +export async function getDirectResponse(noteId: string, messageParams: any): Promise { try { - // Validate that we have a proper note ID format, not a session ID - if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) { - console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`); - throw new Error("Invalid note ID format - using a legacy session ID"); - } - - const postResponse = await server.post(`llm/chat/${chatNoteId}/messages`, { + const postResponse = await server.post(`llm/chat/${noteId}/messages`, { message: messageParams.content, includeContext: messageParams.useAdvancedContext, options: { diff --git a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts index 32ffab50d..4565c0db2 100644 --- a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts +++ b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts @@ -37,9 +37,10 @@ export default class LlmChatPanel extends BasicWidget { private thinkingBubble!: HTMLElement; private thinkingText!: HTMLElement; private thinkingToggle!: HTMLElement; - private chatNoteId: string | null = null; - private noteId: string | null = null; // The actual noteId for the Chat Note - private currentNoteId: string | null = null; + + // Simplified to just use noteId - this represents the AI Chat note we're working with + private noteId: string | null = null; + private currentNoteId: string | null = null; // The note providing context (for regular notes) private _messageHandlerId: number | null = null; private _messageHandler: any = null; @@ -68,7 +69,6 @@ export default class LlmChatPanel extends BasicWidget { totalTokens?: number; }; } = { - model: 'default', temperature: 0.7, toolExecutions: [] }; @@ -90,12 +90,21 @@ export default class LlmChatPanel extends BasicWidget { this.messages = messages; } - public getChatNoteId(): string | null { - return this.chatNoteId; + public getNoteId(): string | null { + return this.noteId; } - public setChatNoteId(chatNoteId: string | null): void { - this.chatNoteId = chatNoteId; + public setNoteId(noteId: string | null): void { + this.noteId = noteId; + } + + // Deprecated - keeping for backward compatibility but mapping to noteId + public getChatNoteId(): string | null { + return this.noteId; + } + + public setChatNoteId(noteId: string | null): void { + this.noteId = noteId; } public getNoteContextChatMessages(): HTMLElement { @@ -307,16 +316,22 @@ export default class LlmChatPanel extends BasicWidget { } } - const dataToSave: ChatData = { + // Only save if we have a valid note ID + if (!this.noteId) { + console.warn('Cannot save chat data: no noteId available'); + return; + } + + const dataToSave = { messages: this.messages, - chatNoteId: this.chatNoteId, noteId: this.noteId, + chatNoteId: this.noteId, // For backward compatibility toolSteps: toolSteps, // Add sources if we have them sources: this.sources || [], // Add metadata metadata: { - model: this.metadata?.model || 'default', + model: this.metadata?.model || undefined, provider: this.metadata?.provider || undefined, temperature: this.metadata?.temperature || 0.7, lastUpdated: new Date().toISOString(), @@ -325,7 +340,7 @@ export default class LlmChatPanel extends BasicWidget { } }; - console.log(`Saving chat data with chatNoteId: ${this.chatNoteId}, noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`); + console.log(`Saving chat data with noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`); // Save the data to the note attribute via the callback // This is the ONLY place we should save data, letting the container widget handle persistence @@ -347,16 +362,52 @@ export default class LlmChatPanel extends BasicWidget { const savedData = await this.onGetData() as ChatData; if (savedData?.messages?.length > 0) { + // Check if we actually have new content to avoid unnecessary UI rebuilds + const currentMessageCount = this.messages.length; + const savedMessageCount = savedData.messages.length; + + // If message counts are the same, check if content is different + const hasNewContent = savedMessageCount > currentMessageCount || + JSON.stringify(this.messages) !== JSON.stringify(savedData.messages); + + if (!hasNewContent) { + console.log("No new content detected, skipping UI rebuild"); + return true; + } + + console.log(`Loading saved data: ${currentMessageCount} -> ${savedMessageCount} messages`); + + // Store current scroll position if we need to preserve it + const shouldPreserveScroll = savedMessageCount > currentMessageCount && currentMessageCount > 0; + const currentScrollTop = shouldPreserveScroll ? this.chatContainer.scrollTop : 0; + const currentScrollHeight = shouldPreserveScroll ? this.chatContainer.scrollHeight : 0; + // Load messages + const oldMessages = [...this.messages]; this.messages = savedData.messages; - // Clear and rebuild the chat UI - this.noteContextChatMessages.innerHTML = ''; + // Only rebuild UI if we have significantly different content + if (savedMessageCount > currentMessageCount) { + // We have new messages - just add the new ones instead of rebuilding everything + const newMessages = savedData.messages.slice(currentMessageCount); + console.log(`Adding ${newMessages.length} new messages to UI`); - this.messages.forEach(message => { - const role = message.role as 'user' | 'assistant'; - this.addMessageToChat(role, message.content); - }); + newMessages.forEach(message => { + const role = message.role as 'user' | 'assistant'; + this.addMessageToChat(role, message.content); + }); + } else { + // Content changed but count is same - need to rebuild + console.log("Message content changed, rebuilding UI"); + + // Clear and rebuild the chat UI + this.noteContextChatMessages.innerHTML = ''; + + this.messages.forEach(message => { + const role = message.role as 'user' | 'assistant'; + this.addMessageToChat(role, message.content); + }); + } // Restore tool execution steps if they exist if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) { @@ -400,13 +451,33 @@ export default class LlmChatPanel extends BasicWidget { // Load Chat Note ID if available if (savedData.noteId) { console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`); - this.chatNoteId = savedData.noteId; this.noteId = savedData.noteId; } else { console.log(`No noteId found in saved data, cannot load chat session`); return false; } + // Restore scroll position if we were preserving it + if (shouldPreserveScroll) { + // Calculate the new scroll position to maintain relative position + const newScrollHeight = this.chatContainer.scrollHeight; + const scrollDifference = newScrollHeight - currentScrollHeight; + const newScrollTop = currentScrollTop + scrollDifference; + + // Only scroll down if we're near the bottom, otherwise preserve exact position + const wasNearBottom = (currentScrollTop + this.chatContainer.clientHeight) >= (currentScrollHeight - 50); + + if (wasNearBottom) { + // User was at bottom, scroll to new bottom + this.chatContainer.scrollTop = newScrollHeight; + console.log("User was at bottom, scrolling to new bottom"); + } else { + // User was not at bottom, try to preserve their position + this.chatContainer.scrollTop = newScrollTop; + console.log(`Preserving scroll position: ${currentScrollTop} -> ${newScrollTop}`); + } + } + return true; } } catch (error) { @@ -550,6 +621,15 @@ export default class LlmChatPanel extends BasicWidget { // Get current note context if needed const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null; + // For AI Chat notes, the note itself IS the chat session + // So currentNoteId and noteId should be the same + if (this.noteId && currentActiveNoteId === this.noteId) { + // We're in an AI Chat note - don't reset, just load saved data + console.log(`Refreshing AI Chat note ${this.noteId} - loading saved data`); + await this.loadSavedData(); + return; + } + // If we're switching to a different note, we need to reset if (this.currentNoteId !== currentActiveNoteId) { console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`); @@ -557,7 +637,6 @@ export default class LlmChatPanel extends BasicWidget { // Reset the UI and data this.noteContextChatMessages.innerHTML = ''; this.messages = []; - this.chatNoteId = null; this.noteId = null; // Also reset the chat note ID this.hideSources(); // Hide any sources from previous note @@ -569,7 +648,7 @@ export default class LlmChatPanel extends BasicWidget { const hasSavedData = await this.loadSavedData(); // Only create a new session if we don't have a session or saved data - if (!this.chatNoteId || !this.noteId || !hasSavedData) { + if (!this.noteId || !hasSavedData) { // Create a new chat session await this.createChatSession(); } @@ -580,19 +659,15 @@ export default class LlmChatPanel extends BasicWidget { */ private async createChatSession() { try { - // Create a new chat session, passing the current note ID if it exists - const { chatNoteId, noteId } = await createChatSession( - this.currentNoteId ? this.currentNoteId : undefined - ); + // If we already have a noteId (for AI Chat notes), use it + const contextNoteId = this.noteId || this.currentNoteId; - if (chatNoteId) { - // If we got back an ID from the API, use it - this.chatNoteId = chatNoteId; - - // For new sessions, the noteId should equal the chatNoteId - // This ensures we're using the note ID consistently - this.noteId = noteId || chatNoteId; + // Create a new chat session, passing the context note ID + const noteId = await createChatSession(contextNoteId ? contextNoteId : undefined); + if (noteId) { + // Set the note ID for this chat + this.noteId = noteId; console.log(`Created new chat session with noteId: ${this.noteId}`); } else { throw new Error("Failed to create chat session - no ID returned"); @@ -645,7 +720,7 @@ export default class LlmChatPanel extends BasicWidget { const showThinking = this.showThinkingCheckbox.checked; // Add logging to verify parameters - console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`); + console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`); // Create the message parameters const messageParams = { @@ -695,11 +770,11 @@ export default class LlmChatPanel extends BasicWidget { await validateEmbeddingProviders(this.validationWarning); // Make sure we have a valid session - if (!this.chatNoteId) { + if (!this.noteId) { // If no session ID, create a new session await this.createChatSession(); - if (!this.chatNoteId) { + if (!this.noteId) { // If still no session ID, show error and return console.error("Failed to create chat session"); toastService.showError("Failed to create chat session"); @@ -730,7 +805,7 @@ export default class LlmChatPanel extends BasicWidget { await this.saveCurrentData(); // Add logging to verify parameters - console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`); + console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`); // Create the message parameters const messageParams = { @@ -767,12 +842,12 @@ export default class LlmChatPanel extends BasicWidget { */ private async handleDirectResponse(messageParams: any): Promise { try { - if (!this.chatNoteId) return false; + if (!this.noteId) return false; - console.log(`Getting direct response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`); + console.log(`Getting direct response using sessionId: ${this.noteId} (noteId: ${this.noteId})`); // Get a direct response from the server - const postResponse = await getDirectResponse(this.chatNoteId, messageParams); + const postResponse = await getDirectResponse(this.noteId, messageParams); // If the POST request returned content directly, display it if (postResponse && postResponse.content) { @@ -845,11 +920,11 @@ export default class LlmChatPanel extends BasicWidget { * Set up streaming response via WebSocket */ private async setupStreamingResponse(messageParams: any): Promise { - if (!this.chatNoteId) { + if (!this.noteId) { throw new Error("No session ID available"); } - console.log(`Setting up streaming response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`); + console.log(`Setting up streaming response using sessionId: ${this.noteId} (noteId: ${this.noteId})`); // Store tool executions captured during streaming const toolExecutionsCache: Array<{ @@ -862,7 +937,7 @@ export default class LlmChatPanel extends BasicWidget { }> = []; return setupStreamingResponse( - this.chatNoteId, + this.noteId, messageParams, // Content update handler (content: string, isDone: boolean = false) => { @@ -898,7 +973,7 @@ export default class LlmChatPanel extends BasicWidget { similarity?: number; content?: string; }>; - }>(`llm/chat/${this.chatNoteId}`) + }>(`llm/chat/${this.noteId}`) .then((sessionData) => { console.log("Got updated session data:", sessionData); @@ -933,9 +1008,9 @@ export default class LlmChatPanel extends BasicWidget { } } - // Save the updated data to the note - this.saveCurrentData() - .catch(err => console.error("Failed to save data after streaming completed:", err)); + // DON'T save here - let the server handle saving the complete conversation + // to avoid race conditions between client and server saves + console.log("Updated metadata after streaming completion, server should save"); }) .catch(err => console.error("Error fetching session data after streaming:", err)); } @@ -973,11 +1048,9 @@ export default class LlmChatPanel extends BasicWidget { console.log(`Cached tool execution for ${toolData.tool} to be saved later`); - // Save immediately after receiving a tool execution - // This ensures we don't lose tool execution data if streaming fails - this.saveCurrentData().catch(err => { - console.error("Failed to save tool execution data:", err); - }); + // DON'T save immediately during streaming - let the server handle saving + // to avoid race conditions between client and server saves + console.log(`Tool execution cached, will be saved by server`); } }, // Complete handler @@ -995,23 +1068,19 @@ export default class LlmChatPanel extends BasicWidget { * Update the UI with streaming content */ private updateStreamingUI(assistantResponse: string, isDone: boolean = false) { - // Parse and handle thinking content if present - if (!isDone) { - const thinkingContent = this.parseThinkingContent(assistantResponse); - if (thinkingContent) { - this.updateThinkingText(thinkingContent); - // Don't display the raw response with think tags in the chat - return; - } - } - - // Get the existing assistant message or create a new one - let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child'); - - if (!assistantMessageEl) { - // If no assistant message yet, create one + // Track if we have a streaming message in progress + const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming'); + + // Create a new message element or use the existing streaming one + let assistantMessageEl: HTMLElement; + + if (hasStreamingMessage) { + // Use the existing streaming message + assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!; + } else { + // Create a new message element assistantMessageEl = document.createElement('div'); - assistantMessageEl.className = 'assistant-message message mb-3'; + assistantMessageEl.className = 'assistant-message message mb-3 streaming'; this.noteContextChatMessages.appendChild(assistantMessageEl); // Add assistant profile icon @@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget { assistantMessageEl.appendChild(messageContent); } - // Clean the response to remove thinking tags before displaying - const cleanedResponse = this.removeThinkingTags(assistantResponse); - - // Update the content + // Update the content with the current response const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement; - messageContent.innerHTML = formatMarkdown(cleanedResponse); + messageContent.innerHTML = formatMarkdown(assistantResponse); - // Apply syntax highlighting if this is the final update + // When the response is complete if (isDone) { + // Remove the streaming class to mark this message as complete + assistantMessageEl.classList.remove('streaming'); + + // Apply syntax highlighting formatCodeBlocks($(assistantMessageEl as HTMLElement)); // Hide the thinking display when response is complete this.hideThinkingDisplay(); - // Update message in the data model for storage - // Find the last assistant message to update, or add a new one if none exists - const assistantMessages = this.messages.filter(msg => msg.role === 'assistant'); - const lastAssistantMsgIndex = assistantMessages.length > 0 ? - this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1; - - if (lastAssistantMsgIndex >= 0) { - // Update existing message with cleaned content - this.messages[lastAssistantMsgIndex].content = cleanedResponse; - } else { - // Add new message with cleaned content - this.messages.push({ - role: 'assistant', - content: cleanedResponse - }); - } - - // Hide loading indicator - hideLoadingIndicator(this.loadingIndicator); - - // Save the final state to the Chat Note - this.saveCurrentData().catch(err => { - console.error("Failed to save assistant response to note:", err); + // Always add a new message to the data model + // This ensures we preserve all distinct assistant messages + this.messages.push({ + role: 'assistant', + content: assistantResponse, + timestamp: new Date() }); + + // Save the updated message list + this.saveCurrentData(); } // Scroll to bottom this.chatContainer.scrollTop = this.chatContainer.scrollHeight; } - /** - * Remove thinking tags from response content - */ - private removeThinkingTags(content: string): string { - if (!content) return content; - - // Remove ... blocks from the content - return content.replace(/[\s\S]*?<\/think>/gi, '').trim(); - } - /** * Handle general errors in the send message flow */ diff --git a/apps/client/src/widgets/llm_chat/types.ts b/apps/client/src/widgets/llm_chat/types.ts index 300a7856a..7181651d0 100644 --- a/apps/client/src/widgets/llm_chat/types.ts +++ b/apps/client/src/widgets/llm_chat/types.ts @@ -11,7 +11,7 @@ export interface ChatResponse { export interface SessionResponse { id: string; title: string; - noteId?: string; + noteId: string; // The ID of the chat note } export interface ToolExecutionStep { @@ -33,8 +33,8 @@ export interface MessageData { export interface ChatData { messages: MessageData[]; - chatNoteId: string | null; - noteId?: string | null; + noteId: string; // The ID of the chat note + chatNoteId?: string; // Deprecated - kept for backward compatibility, should equal noteId toolSteps: ToolExecutionStep[]; sources?: Array<{ noteId: string; diff --git a/apps/client/src/widgets/ribbon_widgets/edited_notes.ts b/apps/client/src/widgets/ribbon_widgets/edited_notes.ts index 4c8d79abe..2967b5f6f 100644 --- a/apps/client/src/widgets/ribbon_widgets/edited_notes.ts +++ b/apps/client/src/widgets/ribbon_widgets/edited_notes.ts @@ -19,7 +19,7 @@ const TPL = /*html*/`
${t("edited_notes.no_edited_notes_found")}
-
+ `; diff --git a/apps/client/src/widgets/type_widgets/ai_chat.ts b/apps/client/src/widgets/type_widgets/ai_chat.ts index e96cf5f20..f733b499b 100644 --- a/apps/client/src/widgets/type_widgets/ai_chat.ts +++ b/apps/client/src/widgets/type_widgets/ai_chat.ts @@ -94,6 +94,11 @@ export default class AiChatTypeWidget extends TypeWidget { this.llmChatPanel.clearNoteContextChatMessages(); this.llmChatPanel.setMessages([]); + // Set the note ID for the chat panel + if (note) { + this.llmChatPanel.setNoteId(note.noteId); + } + // This will load saved data via the getData callback await this.llmChatPanel.refresh(); this.isInitialized = true; @@ -130,7 +135,7 @@ export default class AiChatTypeWidget extends TypeWidget { // Reset the chat panel UI this.llmChatPanel.clearNoteContextChatMessages(); this.llmChatPanel.setMessages([]); - this.llmChatPanel.setChatNoteId(null); + this.llmChatPanel.setNoteId(this.note.noteId); } // Call the parent method to refresh @@ -152,6 +157,7 @@ export default class AiChatTypeWidget extends TypeWidget { // Make sure the chat panel has the current note ID if (this.note) { this.llmChatPanel.setCurrentNoteId(this.note.noteId); + this.llmChatPanel.setNoteId(this.note.noteId); } this.initPromise = (async () => { @@ -186,7 +192,7 @@ export default class AiChatTypeWidget extends TypeWidget { // Format the data properly - this is the canonical format of the data const formattedData = { messages: data.messages || [], - chatNoteId: data.chatNoteId || this.note.noteId, + noteId: this.note.noteId, // Always use the note's own ID toolSteps: data.toolSteps || [], sources: data.sources || [], metadata: { diff --git a/apps/client/src/widgets/type_widgets/geo_map.ts b/apps/client/src/widgets/type_widgets/geo_map.ts index 320fb7c9a..7f2b3e52a 100644 --- a/apps/client/src/widgets/type_widgets/geo_map.ts +++ b/apps/client/src/widgets/type_widgets/geo_map.ts @@ -224,11 +224,26 @@ export default class GeoMapTypeWidget extends TypeWidget { this.gpxLoaded = true; } - // TODO: This is not very efficient as it's probably a string response that is parsed and then converted back to string and parsed again. - const xmlResponse = await server.get(`notes/${note.noteId}/open`); - const stringResponse = new XMLSerializer().serializeToString(xmlResponse); + const xmlResponse = await server.get(`notes/${note.noteId}/open`, undefined, true); + let stringResponse: string; + if (xmlResponse instanceof Uint8Array) { + stringResponse = new TextDecoder().decode(xmlResponse); + } else { + stringResponse = xmlResponse; + } - const track = new this.L.GPX(stringResponse, {}); + const track = new this.L.GPX(stringResponse, { + markers: { + startIcon: this.#buildIcon(note.getIcon(), note.getColorClass(), note.title), + endIcon: this.#buildIcon("bxs-flag-checkered"), + wptIcons: { + "": this.#buildIcon("bx bx-pin") + } + }, + polyline_options: { + color: note.getLabelValue("color") ?? "blue" + } + }); track.addTo(this.geoMapWidget.map); this.currentTrackData[note.noteId] = track; } @@ -276,13 +291,13 @@ export default class GeoMapTypeWidget extends TypeWidget { this.currentMarkerData[note.noteId] = marker; } - #buildIcon(bxIconClass: string, colorClass: string, title: string) { + #buildIcon(bxIconClass: string, colorClass?: string, title?: string) { return this.L.divIcon({ html: /*html*/`\ - - ${title}`, + + ${title ?? ""}`, iconSize: [25, 41], iconAnchor: [12, 41] }); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 024d7d3a9..7c9813963 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,6 @@ "config": { "forge": "./electron-forge/forge.config.cjs" }, - "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", "scripts": { "start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js" }, diff --git a/apps/dump-db/package.json b/apps/dump-db/package.json index a63105b9c..3fd75f86f 100644 --- a/apps/dump-db/package.json +++ b/apps/dump-db/package.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", - "@types/mime-types": "^2.1.4", + "@types/mime-types": "^3.0.0", "@types/yargs": "^17.0.33" }, "nx": { diff --git a/apps/server/package.json b/apps/server/package.json index 2b8836251..89a19b068 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,7 +23,7 @@ "@types/ini": "4.1.1", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", - "@types/mime-types": "2.1.4", + "@types/mime-types": "3.0.0", "@types/multer": "1.4.12", "@types/safe-compare": "1.1.2", "@types/sanitize-html": "2.16.0", @@ -85,10 +85,10 @@ "jsdom": "26.1.0", "marked": "15.0.12", "mime-types": "3.0.1", - "multer": "2.0.0", + "multer": "2.0.1", "normalize-strings": "1.1.1", "ollama": "0.5.16", - "openai": "4.104.0", + "openai": "5.1.0", "rand-token": "1.0.1", "safe-compare": "1.1.4", "sanitize-filename": "1.6.3", diff --git a/apps/server/spec/etapi/api-metrics.spec.ts b/apps/server/spec/etapi/api-metrics.spec.ts new file mode 100644 index 000000000..a9c98df87 --- /dev/null +++ b/apps/server/spec/etapi/api-metrics.spec.ts @@ -0,0 +1,48 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import buildApp from "../../src/app.js"; +import supertest from "supertest"; + +let app: Application; +let token: string; + +// TODO: This is an API test, not ETAPI. + +describe("api/metrics", () => { + beforeAll(async () => { + app = await buildApp(); + }); + + it("returns Prometheus format by default", async () => { + const response = await supertest(app) + .get("/api/metrics") + .expect(200); + expect(response.headers["content-type"]).toContain("text/plain"); + expect(response.text).toContain("trilium_info"); + expect(response.text).toContain("trilium_notes_total"); + expect(response.text).toContain("# HELP"); + expect(response.text).toContain("# TYPE"); + }); + + it("returns JSON when requested", async() => { + const response = await supertest(app) + .get("/api/metrics?format=json") + .expect(200); + expect(response.headers["content-type"]).toContain("application/json"); + expect(response.body.version).toBeTruthy(); + expect(response.body.database).toBeTruthy(); + expect(response.body.timestamp).toBeTruthy(); + expect(response.body.database.totalNotes).toBeTypeOf("number"); + expect(response.body.database.activeNotes).toBeTypeOf("number"); + expect(response.body.noteTypes).toBeTruthy(); + expect(response.body.attachmentTypes).toBeTruthy(); + expect(response.body.statistics).toBeTruthy(); + }); + + it("returns error on invalid format", async() => { + const response = await supertest(app) + .get("/api/metrics?format=xml") + .expect(500); + expect(response.body.message).toContain("prometheus"); + }); +}); diff --git a/apps/server/spec/etapi/app-info.spec.ts b/apps/server/spec/etapi/app-info.spec.ts new file mode 100644 index 000000000..03a5a389b --- /dev/null +++ b/apps/server/spec/etapi/app-info.spec.ts @@ -0,0 +1,20 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import buildApp from "../../src/app.js"; +import supertest from "supertest"; + +let app: Application; +let token: string; + +describe("etapi/app-info", () => { + beforeAll(async () => { + app = await buildApp(); + }); + + it("retrieves correct app info", async () => { + const response = await supertest(app) + .get("/etapi/app-info") + .expect(200); + expect(response.body.clipperProtocolVersion).toBe("1.0"); + }); +}); diff --git a/apps/server/spec/etapi/attachment-content.spec.ts b/apps/server/spec/etapi/attachment-content.spec.ts new file mode 100644 index 000000000..12c90c155 --- /dev/null +++ b/apps/server/spec/etapi/attachment-content.spec.ts @@ -0,0 +1,64 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; +let createdAttachmentId: string; + +describe("etapi/attachment-content", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + createdNoteId = await createNote(app, token); + + // Create an attachment + const response = await supertest(app) + .post(`/etapi/attachments`) + .auth(USER, token, { "type": "basic"}) + .send({ + "ownerId": createdNoteId, + "role": "file", + "mime": "text/plain", + "title": "my attachment", + "content": "text" + }); + createdAttachmentId = response.body.attachmentId; + expect(createdAttachmentId).toBeTruthy(); + }); + + it("changes attachment content", async () => { + const text = "Changed content"; + await supertest(app) + .put(`/etapi/attachments/${createdAttachmentId}/content`) + .auth(USER, token, { "type": "basic"}) + .set("Content-Type", "text/plain") + .send(text) + .expect(204); + + // Ensure it got changed. + const response = await supertest(app) + .get(`/etapi/attachments/${createdAttachmentId}/content`) + .auth(USER, token, { "type": "basic"}); + expect(response.text).toStrictEqual(text); + }); + + it("supports binary content", async() => { + await supertest(app) + .put(`/etapi/attachments/${createdAttachmentId}/content`) + .auth(USER, token, { "type": "basic"}) + .set("Content-Type", "application/octet-stream") + .set("Content-Transfer-Encoding", "binary") + .send(Buffer.from("Hello world")) + .expect(204); + }); + +}); diff --git a/apps/server/spec/etapi/basic-auth.spec.ts b/apps/server/spec/etapi/basic-auth.spec.ts new file mode 100644 index 000000000..6518c7a12 --- /dev/null +++ b/apps/server/spec/etapi/basic-auth.spec.ts @@ -0,0 +1,54 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +const URL = "/etapi/notes/root"; + +describe("basic-auth", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("auth token works", async () => { + const response = await supertest(app) + .get(URL) + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + it("rejects wrong password", async () => { + const response = await supertest(app) + .get(URL) + .auth(USER, "wrong", { "type": "basic"}) + .expect(401); + }); + + it("rejects wrong user", async () => { + const response = await supertest(app) + .get(URL) + .auth("wrong", token, { "type": "basic"}) + .expect(401); + }); + + it("logs out", async () => { + await supertest(app) + .post("/etapi/auth/logout") + .auth(USER, token, { "type": "basic"}) + .expect(204); + + // Ensure we can't access it anymore + await supertest(app) + .get("/etapi/notes/root") + .auth(USER, token, { "type": "basic"}) + .expect(401); + }); +}); diff --git a/apps/server/spec/etapi/create-backup.spec.ts b/apps/server/spec/etapi/create-backup.spec.ts new file mode 100644 index 000000000..00c8751aa --- /dev/null +++ b/apps/server/spec/etapi/create-backup.spec.ts @@ -0,0 +1,26 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/backup", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("backup works", async () => { + const response = await supertest(app) + .put("/etapi/backup/etapi_test") + .auth(USER, token, { "type": "basic"}) + .expect(204); + }); +}); diff --git a/apps/server/spec/etapi/create-entities.spec.ts b/apps/server/spec/etapi/create-entities.spec.ts new file mode 100644 index 000000000..25dab1d45 --- /dev/null +++ b/apps/server/spec/etapi/create-entities.spec.ts @@ -0,0 +1,178 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; +import { randomInt } from "crypto"; + +let app: Application; +let token: string; +let createdNoteId: string; +let createdBranchId: string; +let clonedBranchId: string; +let createdAttributeId: string; +let createdAttachmentId: string; + +const USER = "etapi"; + +describe("etapi/create-entities", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + ({ createdNoteId, createdBranchId } = await createNote()); + clonedBranchId = await createClone(); + createdAttributeId = await createAttribute(); + createdAttachmentId = await createAttachment(); + }); + + it("returns note info", async () => { + const response = await supertest(app) + .get(`/etapi/notes/${createdNoteId}`) + .auth(USER, token, { "type": "basic"}) + .send({ + noteId: createdNoteId, + parentNoteId: "_hidden" + }) + .expect(200); + expect(response.body).toMatchObject({ + noteId: createdNoteId, + title: "Hello" + }); + expect(new Set(response.body.parentBranchIds)) + .toStrictEqual(new Set([ clonedBranchId, createdBranchId ])); + }); + + it("obtains note content", async () => { + await supertest(app) + .get(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic"}) + .expect(200) + .expect("Hi there!"); + }); + + it("obtains created branch information", async () => { + const response = await supertest(app) + .get(`/etapi/branches/${createdBranchId}`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body).toMatchObject({ + branchId: createdBranchId, + parentNoteId: "root" + }); + }); + + it("obtains cloned branch information", async () => { + const response = await supertest(app) + .get(`/etapi/branches/${clonedBranchId}`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body).toMatchObject({ + branchId: clonedBranchId, + parentNoteId: "_hidden" + }); + }); + + it("obtains attribute information", async () => { + const response = await supertest(app) + .get(`/etapi/attributes/${createdAttributeId}`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body.attributeId).toStrictEqual(createdAttributeId); + }); + + it("obtains attachment information", async () => { + const response = await supertest(app) + .get(`/etapi/attachments/${createdAttachmentId}`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body.attachmentId).toStrictEqual(createdAttachmentId); + expect(response.body).toMatchObject({ + role: "file", + mime: "plain/text", + title: "my attachment" + }); + }); +}); + +async function createNote() { + const noteId = `forcedId${randomInt(1000)}`; + const response = await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic"}) + .send({ + "noteId": noteId, + "parentNoteId": "root", + "title": "Hello", + "type": "text", + "content": "Hi there!", + "dateCreated": "2023-08-21 23:38:51.123+0200", + "utcDateCreated": "2023-08-21 23:38:51.123Z" + }) + .expect(201); + expect(response.body.note.noteId).toStrictEqual(noteId); + expect(response.body).toMatchObject({ + note: { + noteId, + title: "Hello", + dateCreated: "2023-08-21 23:38:51.123+0200", + utcDateCreated: "2023-08-21 23:38:51.123Z" + }, + branch: { + parentNoteId: "root" + } + }); + + return { + createdNoteId: response.body.note.noteId, + createdBranchId: response.body.branch.branchId + }; +} + +async function createClone() { + const response = await supertest(app) + .post("/etapi/branches") + .auth(USER, token, { "type": "basic"}) + .send({ + noteId: createdNoteId, + parentNoteId: "_hidden" + }) + .expect(201); + expect(response.body.parentNoteId).toStrictEqual("_hidden"); + return response.body.branchId; +} + +async function createAttribute() { + const attributeId = `forcedId${randomInt(1000)}`; + const response = await supertest(app) + .post("/etapi/attributes") + .auth(USER, token, { "type": "basic"}) + .send({ + "attributeId": attributeId, + "noteId": createdNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": true + }) + .expect(201); + expect(response.body.attributeId).toStrictEqual(attributeId); + return response.body.attributeId; +} + +async function createAttachment() { + const response = await supertest(app) + .post("/etapi/attachments") + .auth(USER, token, { "type": "basic"}) + .send({ + "ownerId": createdNoteId, + "role": "file", + "mime": "plain/text", + "title": "my attachment", + "content": "my text" + }) + .expect(201); + return response.body.attachmentId; +} diff --git a/apps/server/spec/etapi/delete-entities.spec.ts b/apps/server/spec/etapi/delete-entities.spec.ts new file mode 100644 index 000000000..581b1e693 --- /dev/null +++ b/apps/server/spec/etapi/delete-entities.spec.ts @@ -0,0 +1,172 @@ +import { Application } from "express"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; +import { randomInt } from "crypto"; + +let app: Application; +let token: string; +let createdNoteId: string; +let createdBranchId: string; + +const USER = "etapi"; + +type EntityType = "attachments" | "attributes" | "branches" | "notes"; + +describe("etapi/delete-entities", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + beforeEach(async () => { + ({ createdNoteId, createdBranchId } = await createNote()); + }); + + it("deletes attachment", async () => { + const attachmentId = await createAttachment(); + await deleteEntity("attachments", attachmentId); + await expectNotFound("attachments", attachmentId); + }); + + it("deletes attribute", async () => { + const attributeId = await createAttribute(); + await deleteEntity("attributes", attributeId); + await expectNotFound("attributes", attributeId); + }); + + it("deletes cloned branch", async () => { + const clonedBranchId = await createClone(); + + await expectFound("branches", createdBranchId); + await expectFound("branches", clonedBranchId); + + await deleteEntity("branches", createdBranchId); + await expectNotFound("branches", createdBranchId); + + await expectFound("branches", clonedBranchId); + await expectFound("notes", createdNoteId); + }); + + it("deletes note with all branches", async () => { + const attributeId = await createAttribute(); + + const clonedBranchId = await createClone(); + + await expectFound("notes", createdNoteId); + await expectFound("branches", createdBranchId); + await expectFound("branches", clonedBranchId); + await expectFound("attributes", attributeId); + await deleteEntity("notes", createdNoteId); + + await expectNotFound("branches", createdBranchId); + await expectNotFound("branches", clonedBranchId); + await expectNotFound("notes", createdNoteId); + await expectNotFound("attributes", attributeId); + }); +}); + +async function createNote() { + const noteId = `forcedId${randomInt(1000)}`; + const response = await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic"}) + .send({ + "noteId": noteId, + "parentNoteId": "root", + "title": "Hello", + "type": "text", + "content": "Hi there!", + "dateCreated": "2023-08-21 23:38:51.123+0200", + "utcDateCreated": "2023-08-21 23:38:51.123Z" + }) + .expect(201); + expect(response.body.note.noteId).toStrictEqual(noteId); + + return { + createdNoteId: response.body.note.noteId, + createdBranchId: response.body.branch.branchId + }; +} + +async function createClone() { + const response = await supertest(app) + .post("/etapi/branches") + .auth(USER, token, { "type": "basic"}) + .send({ + noteId: createdNoteId, + parentNoteId: "_hidden" + }) + .expect(201); + expect(response.body.parentNoteId).toStrictEqual("_hidden"); + return response.body.branchId; +} + +async function createAttribute() { + const attributeId = `forcedId${randomInt(1000)}`; + const response = await supertest(app) + .post("/etapi/attributes") + .auth(USER, token, { "type": "basic"}) + .send({ + "attributeId": attributeId, + "noteId": createdNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": true + }) + .expect(201); + expect(response.body.attributeId).toStrictEqual(attributeId); + return response.body.attributeId; +} + +async function createAttachment() { + const response = await supertest(app) + .post("/etapi/attachments") + .auth(USER, token, { "type": "basic"}) + .send({ + "ownerId": createdNoteId, + "role": "file", + "mime": "plain/text", + "title": "my attachment", + "content": "my text" + }) + .expect(201); + return response.body.attachmentId; +} + +async function deleteEntity(entity: EntityType, id: string) { + // Delete twice to test idempotency. + for (let i=0; i < 2; i++) { + await supertest(app) + .delete(`/etapi/${entity}/${id}`) + .auth(USER, token, { "type": "basic"}) + .expect(204); + } +} + +const MISSING_ENTITY_ERROR_CODES: Record = { + attachments: "ATTACHMENT_NOT_FOUND", + attributes: "ATTRIBUTE_NOT_FOUND", + branches: "BRANCH_NOT_FOUND", + notes: "NOTE_NOT_FOUND" +} + +async function expectNotFound(entity: EntityType, id: string) { + const response = await supertest(app) + .get(`/etapi/${entity}/${id}`) + .auth(USER, token, { "type": "basic"}) + .expect(404); + + expect(response.body.code).toStrictEqual(MISSING_ENTITY_ERROR_CODES[entity]); +} + +async function expectFound(entity: EntityType, id: string) { + await supertest(app) + .get(`/etapi/${entity}/${id}`) + .auth(USER, token, { "type": "basic"}) + .expect(200); +} diff --git a/apps/server/spec/etapi/etapi-metrics.spec.ts b/apps/server/spec/etapi/etapi-metrics.spec.ts new file mode 100644 index 000000000..7b7d3a184 --- /dev/null +++ b/apps/server/spec/etapi/etapi-metrics.spec.ts @@ -0,0 +1,71 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/metrics", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("returns Prometheus format by default", async () => { + const response = await supertest(app) + .get("/etapi/metrics") + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.headers["content-type"]).toContain("text/plain"); + expect(response.text).toContain("trilium_info"); + expect(response.text).toContain("trilium_notes_total"); + expect(response.text).toContain("# HELP"); + expect(response.text).toContain("# TYPE"); + }); + + it("returns JSON when requested", async() => { + const response = await supertest(app) + .get("/etapi/metrics?format=json") + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.headers["content-type"]).toContain("application/json"); + expect(response.body.version).toBeTruthy(); + expect(response.body.database).toBeTruthy(); + expect(response.body.timestamp).toBeTruthy(); + expect(response.body.database.totalNotes).toBeTypeOf("number"); + expect(response.body.database.activeNotes).toBeTypeOf("number"); + expect(response.body.noteTypes).toBeTruthy(); + expect(response.body.attachmentTypes).toBeTruthy(); + expect(response.body.statistics).toBeTruthy(); + }); + + it("returns Prometheus format explicitly", async () => { + const response = await supertest(app) + .get("/etapi/metrics?format=prometheus") + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.headers["content-type"]).toContain("text/plain"); + expect(response.text).toContain("trilium_info"); + expect(response.text).toContain("trilium_notes_total"); + }); + + it("returns error on invalid format", async() => { + const response = await supertest(app) + .get("/etapi/metrics?format=xml") + .auth(USER, token, { "type": "basic"}) + .expect(500); + expect(response.body.message).toContain("prometheus"); + }); + + it("should fail without authentication", async() => { + await supertest(app) + .get("/etapi/metrics") + .expect(401); + }); +}); diff --git a/apps/server/spec/etapi/export-note-subtree.spec.ts b/apps/server/spec/etapi/export-note-subtree.spec.ts new file mode 100644 index 000000000..f5f09b532 --- /dev/null +++ b/apps/server/spec/etapi/export-note-subtree.spec.ts @@ -0,0 +1,51 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/export-note-subtree", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("export works", async () => { + await supertest(app) + .get("/etapi/notes/root/export") + .auth(USER, token, { "type": "basic"}) + .expect(200) + .expect("Content-Type", "application/zip"); + }); + + it("HTML export works", async () => { + await supertest(app) + .get("/etapi/notes/root/export?format=html") + .auth(USER, token, { "type": "basic"}) + .expect(200) + .expect("Content-Type", "application/zip"); + }); + + it("Markdown export works", async () => { + await supertest(app) + .get("/etapi/notes/root/export?format=markdown") + .auth(USER, token, { "type": "basic"}) + .expect(200) + .expect("Content-Type", "application/zip"); + }); + + it("reports wrong format", async () => { + const response = await supertest(app) + .get("/etapi/notes/root/export?format=wrong") + .auth(USER, token, { "type": "basic"}) + .expect(400); + expect(response.body.code).toStrictEqual("UNRECOGNIZED_EXPORT_FORMAT"); + }); +}); diff --git a/apps/server/spec/etapi/get-date-notes.spec.ts b/apps/server/spec/etapi/get-date-notes.spec.ts new file mode 100644 index 000000000..e1f67fec6 --- /dev/null +++ b/apps/server/spec/etapi/get-date-notes.spec.ts @@ -0,0 +1,103 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import config from "../../src/services/config.js"; +import { login } from "./utils.js"; +import { Application } from "express"; +import supertest from "supertest"; +import date_notes from "../../src/services/date_notes.js"; +import cls from "../../src/services/cls.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/get-date-notes", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("obtains inbox", async () => { + await supertest(app) + .get("/etapi/inbox/2022-01-01") + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + describe("days", () => { + it("obtains day from calendar", async () => { + await supertest(app) + .get("/etapi/calendar/days/2022-01-01") + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + it("detects invalid date", async () => { + const response = await supertest(app) + .get("/etapi/calendar/days/2022-1") + .auth(USER, token, { "type": "basic"}) + .expect(400); + expect(response.body.code).toStrictEqual("DATE_INVALID"); + }); + }); + + describe("weeks", () => { + beforeAll(() => { + cls.init(() => { + const rootCalendarNote = date_notes.getRootCalendarNote(); + rootCalendarNote.setLabel("enableWeekNote"); + }); + }); + + it("obtains week calendar", async () => { + await supertest(app) + .get("/etapi/calendar/weeks/2022-W01") + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + it("detects invalid date", async () => { + const response = await supertest(app) + .get("/etapi/calendar/weeks/2022-1") + .auth(USER, token, { "type": "basic"}) + .expect(400); + expect(response.body.code).toStrictEqual("WEEK_INVALID"); + }); + }); + + describe("months", () => { + it("obtains month calendar", async () => { + await supertest(app) + .get("/etapi/calendar/months/2022-01") + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + it("detects invalid month", async () => { + const response = await supertest(app) + .get("/etapi/calendar/months/2022-1") + .auth(USER, token, { "type": "basic"}) + .expect(400); + expect(response.body.code).toStrictEqual("MONTH_INVALID"); + }); + }); + + describe("years", () => { + it("obtains year calendar", async () => { + await supertest(app) + .get("/etapi/calendar/years/2022") + .auth(USER, token, { "type": "basic"}) + .expect(200); + }); + + it("detects invalid year", async () => { + const response = await supertest(app) + .get("/etapi/calendar/years/202") + .auth(USER, token, { "type": "basic"}) + .expect(400); + expect(response.body.code).toStrictEqual("YEAR_INVALID"); + }); + }); +}); diff --git a/apps/server/spec/etapi/get-inherited-attribute-cloned.spec.ts b/apps/server/spec/etapi/get-inherited-attribute-cloned.spec.ts new file mode 100644 index 000000000..5d882746f --- /dev/null +++ b/apps/server/spec/etapi/get-inherited-attribute-cloned.spec.ts @@ -0,0 +1,98 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +let parentNoteId: string; + +describe("etapi/get-inherited-attribute-cloned", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + parentNoteId = await createNote(app, token); + }); + + it("gets inherited attribute", async () => { + // Create an inheritable attribute on the parent note. + let response = await supertest(app) + .post("/etapi/attributes") + .auth("etapi", token, { "type": "basic"}) + .send({ + "noteId": parentNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": true, + "position": 10 + }) + .expect(201); + const parentAttributeId = response.body.attributeId; + expect(parentAttributeId).toBeTruthy(); + + // Create a subnote. + response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": parentNoteId, + "title": "Hello", + "type": "text", + "content": "Hi there!" + }) + .expect(201); + const childNoteId = response.body.note.noteId; + + // Create child attribute + response = await supertest(app) + .post("/etapi/attributes") + .auth("etapi", token, { "type": "basic"}) + .send({ + "noteId": childNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": false, + "position": 10 + }) + .expect(201); + const childAttributeId = response.body.attributeId; + expect(parentAttributeId).toBeTruthy(); + + // Clone child to parent + response = await supertest(app) + .post("/etapi/branches") + .auth("etapi", token, { "type": "basic"}) + .send({ + noteId: childNoteId, + parentNoteId: parentNoteId + }) + .expect(200); + parentNoteId = response.body.parentNoteId; + + // Check attribute IDs + response = await supertest(app) + .get(`/etapi/notes/${childNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .expect(200); + expect(response.body.noteId).toStrictEqual(childNoteId); + expect(response.body.attributes).toHaveLength(2); + expect(hasAttribute(response.body.attributes, parentAttributeId)); + expect(hasAttribute(response.body.attributes, childAttributeId)); + }); + + function hasAttribute(list: object[], attributeId: string) { + for (let i = 0; i < list.length; i++) { + if (list[i]["attributeId"] === attributeId) { + return true; + } + } + return false; + } +}); diff --git a/apps/server/spec/etapi/get-inherited-attribute.spec.ts b/apps/server/spec/etapi/get-inherited-attribute.spec.ts new file mode 100644 index 000000000..c0e92dde1 --- /dev/null +++ b/apps/server/spec/etapi/get-inherited-attribute.spec.ts @@ -0,0 +1,60 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +let parentNoteId: string; + +describe("etapi/get-inherited-attribute", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + parentNoteId = await createNote(app, token); + }); + + it("gets inherited attribute", async () => { + // Create an inheritable attribute on the parent note. + let response = await supertest(app) + .post("/etapi/attributes") + .auth("etapi", token, { "type": "basic"}) + .send({ + "noteId": parentNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": true + }) + .expect(201); + const createdAttributeId = response.body.attributeId; + expect(createdAttributeId).toBeTruthy(); + + // Create a subnote. + response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": parentNoteId, + "title": "Hello", + "type": "text", + "content": "Hi there!" + }) + .expect(201); + const createdNoteId = response.body.note.noteId; + + // Check the attribute is inherited. + response = await supertest(app) + .get(`/etapi/notes/${createdNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .expect(200); + expect(response.body.noteId).toStrictEqual(createdNoteId); + expect(response.body.attributes).toHaveLength(1); + expect(response.body.attributes[0].attributeId === createdAttributeId); + }); +}); diff --git a/apps/server/spec/etapi/import-zip.spec.ts b/apps/server/spec/etapi/import-zip.spec.ts new file mode 100644 index 000000000..c42623b76 --- /dev/null +++ b/apps/server/spec/etapi/import-zip.spec.ts @@ -0,0 +1,34 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; +import { readFileSync } from "fs"; +import { join } from "path"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/import", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("demo zip can be imported", async () => { + const buffer = readFileSync(join(__dirname, "../../src/assets/db/demo.zip")); + const response = await supertest(app) + .post("/etapi/notes/root/import") + .auth(USER, token, { "type": "basic"}) + .set("Content-Type", "application/octet-stream") + .set("Content-Transfer-Encoding", "binary") + .send(buffer) + .expect(201); + expect(response.body.note.title).toStrictEqual("Journal"); + expect(response.body.branch.parentNoteId).toStrictEqual("root"); + }); +}); diff --git a/apps/server/spec/etapi/no-token.spec.ts b/apps/server/spec/etapi/no-token.spec.ts new file mode 100644 index 000000000..d4a7a2f9f --- /dev/null +++ b/apps/server/spec/etapi/no-token.spec.ts @@ -0,0 +1,54 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; +import type TestAgent from "supertest/lib/agent.js"; + +let app: Application; + +const USER = "etapi"; + +const routes = [ + "GET /etapi/notes?search=aaa", + "GET /etapi/notes/root", + "PATCH /etapi/notes/root", + "DELETE /etapi/notes/root", + "GET /etapi/branches/root", + "PATCH /etapi/branches/root", + "DELETE /etapi/branches/root", + "GET /etapi/attributes/000", + "PATCH /etapi/attributes/000", + "DELETE /etapi/attributes/000", + "GET /etapi/inbox/2022-02-22", + "GET /etapi/calendar/days/2022-02-22", + "GET /etapi/calendar/weeks/2022-02-22", + "GET /etapi/calendar/months/2022-02", + "GET /etapi/calendar/years/2022", + "POST /etapi/create-note", + "GET /etapi/app-info", +] + +describe("no-token", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + }); + + for (const route of routes) { + const [ method, url ] = route.split(" ", 2); + + it(`rejects access to ${method} ${url}`, () => { + (supertest(app)[method.toLowerCase()](url) as TestAgent) + .auth(USER, "fakeauth", { "type": "basic"}) + .expect(401) + }); + } + + it("responds with 404 even without token", () => { + supertest(app) + .get("/etapi/zzzzzz") + .expect(404); + }); +}); diff --git a/apps/server/spec/etapi/note-content.spec.ts b/apps/server/spec/etapi/note-content.spec.ts new file mode 100644 index 000000000..5b7fdcba8 --- /dev/null +++ b/apps/server/spec/etapi/note-content.spec.ts @@ -0,0 +1,72 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; + +describe("etapi/note-content", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + createdNoteId = await createNote(app, token); + }); + + it("get content", async () => { + const response = await getContentResponse(); + expect(response.text).toStrictEqual("Hi there!"); + }); + + it("put note content", async () => { + const text = "Changed content"; + await supertest(app) + .put(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic"}) + .set("Content-Type", "text/plain") + .send(text) + .expect(204); + + const response = await getContentResponse(); + expect(response.text).toStrictEqual(text); + }); + + it("put note content binary", async () => { + // First, create a binary note + const response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "Hello", + "mime": "image/png", + "type": "image", + "content": "" + }) + .expect(201); + const createdNoteId = response.body.note.noteId; + + // Put binary content + await supertest(app) + .put(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic"}) + .set("Content-Type", "application/octet-stream") + .set("Content-Transfer-Encoding", "binary") + .send(Buffer.from("Hello world")) + .expect(204); + }); + + function getContentResponse() { + return supertest(app) + .get(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + } +}); diff --git a/apps/server/spec/etapi/other.spec.ts b/apps/server/spec/etapi/other.spec.ts new file mode 100644 index 000000000..d7c1d38b3 --- /dev/null +++ b/apps/server/spec/etapi/other.spec.ts @@ -0,0 +1,26 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/refresh-note-ordering/root", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("refreshes note ordering", async () => { + await supertest(app) + .post("/etapi/refresh-note-ordering/root") + .auth(USER, token, { "type": "basic"}) + .expect(204); + }); +}); diff --git a/apps/server/spec/etapi/patch-attachment.spec.ts b/apps/server/spec/etapi/patch-attachment.spec.ts new file mode 100644 index 000000000..706ac7fbb --- /dev/null +++ b/apps/server/spec/etapi/patch-attachment.spec.ts @@ -0,0 +1,78 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; +let createdAttachmentId: string; + +describe("etapi/attachment-content", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + createdNoteId = await createNote(app, token); + + // Create an attachment + const response = await supertest(app) + .post(`/etapi/attachments`) + .auth(USER, token, { "type": "basic"}) + .send({ + "ownerId": createdNoteId, + "role": "file", + "mime": "text/plain", + "title": "my attachment", + "content": "text" + }); + createdAttachmentId = response.body.attachmentId; + expect(createdAttachmentId).toBeTruthy(); + }); + + it("changes title and position", async () => { + const state = { + title: "CHANGED", + position: 999 + } + await supertest(app) + .patch(`/etapi/attachments/${createdAttachmentId}`) + .auth(USER, token, { "type": "basic"}) + .send(state) + .expect(200); + + // Ensure it got changed. + const response = await supertest(app) + .get(`/etapi/attachments/${createdAttachmentId}`) + .auth(USER, token, { "type": "basic"}); + expect(response.body).toMatchObject(state); + }); + + it("forbids changing owner", async () => { + const response = await supertest(app) + .patch(`/etapi/attachments/${createdAttachmentId}`) + .auth(USER, token, { "type": "basic"}) + .send({ + ownerId: "root" + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED"); + }); + + it("handles validation error", async () => { + const response = await supertest(app) + .patch(`/etapi/attachments/${createdAttachmentId}`) + .auth(USER, token, { "type": "basic"}) + .send({ + title: null + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR"); + }); + +}); diff --git a/apps/server/spec/etapi/patch-attribute.spec.ts b/apps/server/spec/etapi/patch-attribute.spec.ts new file mode 100644 index 000000000..821a4e3db --- /dev/null +++ b/apps/server/spec/etapi/patch-attribute.spec.ts @@ -0,0 +1,77 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; +let createdAttributeId: string; + +describe("etapi/patch-attribute", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + createdNoteId = await createNote(app, token); + + // Create an attribute + const response = await supertest(app) + .post(`/etapi/attributes`) + .auth(USER, token, { "type": "basic"}) + .send({ + "noteId": createdNoteId, + "type": "label", + "name": "mylabel", + "value": "val", + "isInheritable": true + }); + createdAttributeId = response.body.attributeId; + expect(createdAttributeId).toBeTruthy(); + }); + + it("changes name and value", async () => { + const state = { + value: "CHANGED" + }; + await supertest(app) + .patch(`/etapi/attributes/${createdAttributeId}`) + .auth(USER, token, { "type": "basic"}) + .send(state) + .expect(200); + + // Ensure it got changed. + const response = await supertest(app) + .get(`/etapi/attributes/${createdAttributeId}`) + .auth(USER, token, { "type": "basic"}); + expect(response.body).toMatchObject(state); + }); + + it("forbids setting disallowed property", async () => { + const response = await supertest(app) + .patch(`/etapi/attributes/${createdAttributeId}`) + .auth(USER, token, { "type": "basic"}) + .send({ + noteId: "root" + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED"); + }); + + it("forbids setting wrong data type", async () => { + const response = await supertest(app) + .patch(`/etapi/attributes/${createdAttributeId}`) + .auth(USER, token, { "type": "basic"}) + .send({ + value: null + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR"); + }); + +}); diff --git a/apps/server/spec/etapi/patch-branch.spec.ts b/apps/server/spec/etapi/patch-branch.spec.ts new file mode 100644 index 000000000..ecca59b2d --- /dev/null +++ b/apps/server/spec/etapi/patch-branch.spec.ts @@ -0,0 +1,77 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdBranchId: string; + +describe("etapi/attachment-content", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + // Create a note and a branch. + const response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "Hello", + "type": "text", + "content": "", + }) + .expect(201); + + createdBranchId = response.body.branch.branchId; + }); + + it("can patch branch info", async () => { + const state = { + prefix: "pref", + notePosition: 666, + isExpanded: true + }; + + await supertest(app) + .patch(`/etapi/branches/${createdBranchId}`) + .auth("etapi", token, { "type": "basic"}) + .send(state) + .expect(200); + + const response = await supertest(app) + .get(`/etapi/branches/${createdBranchId}`) + .auth("etapi", token, { "type": "basic"}) + .expect(200); + expect(response.body).toMatchObject(state); + }); + + it("rejects not allowed property", async () => { + const response = await supertest(app) + .patch(`/etapi/branches/${createdBranchId}`) + .auth("etapi", token, { "type": "basic"}) + .send({ + parentNoteId: "root" + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED"); + }); + + it("rejects invalid property value", async () => { + const response = await supertest(app) + .patch(`/etapi/branches/${createdBranchId}`) + .auth("etapi", token, { "type": "basic"}) + .send({ + prefix: 123 + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR"); + }); + +}); diff --git a/apps/server/spec/etapi/patch-note.spec.ts b/apps/server/spec/etapi/patch-note.spec.ts new file mode 100644 index 000000000..178808762 --- /dev/null +++ b/apps/server/spec/etapi/patch-note.spec.ts @@ -0,0 +1,89 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; + +describe("etapi/patch-note", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + const response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "Hello", + "type": "code", + "mime": "application/json", + "content": "{}" + }) + .expect(201); + + const createdNoteId = response.body.note.noteId as string; + expect(createdNoteId).toBeTruthy(); + }); + + it("obtains correct note information", async () => { + await expectNoteToMatch({ + title: "Hello", + type: "code", + mime: "application/json" + }); + }); + + it("patches type, mime and creation dates", async () => { + const changes = { + "title": "Wassup", + "type": "html", + "mime": "text/html", + "dateCreated": "2023-08-21 23:38:51.123+0200", + "utcDateCreated": "2023-08-21 23:38:51.123Z" + }; + await supertest(app) + .patch(`/etapi/notes/${createdNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .send(changes) + .expect(200); + await expectNoteToMatch(changes); + }); + + it("refuses setting protection", async () => { + const response = await supertest(app) + .patch(`/etapi/notes/${createdNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .send({ + isProtected: true + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED"); + }); + + it("refuses incorrect type", async () => { + const response = await supertest(app) + .patch(`/etapi/notes/${createdNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .send({ + title: true + }) + .expect(400); + expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR"); + }); + + async function expectNoteToMatch(state: object) { + const response = await supertest(app) + .get(`/etapi/notes/${createdNoteId}`) + .auth("etapi", token, { "type": "basic"}) + .expect(200); + expect(response.body).toMatchObject(state); + } +}); diff --git a/apps/server/spec/etapi/post-revision.spec.ts b/apps/server/spec/etapi/post-revision.spec.ts new file mode 100644 index 000000000..20b4d15dd --- /dev/null +++ b/apps/server/spec/etapi/post-revision.spec.ts @@ -0,0 +1,29 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; + +describe("etapi/post-revision", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + createdNoteId = await createNote(app, token); + }); + + it("posts note revision", async () => { + await supertest(app) + .post(`/etapi/notes/${createdNoteId}/revision`) + .auth(USER, token, { "type": "basic"}) + .send("Changed content") + .expect(204); + }); +}); diff --git a/apps/server/spec/etapi/search.spec.ts b/apps/server/spec/etapi/search.spec.ts new file mode 100644 index 000000000..bfd14e740 --- /dev/null +++ b/apps/server/spec/etapi/search.spec.ts @@ -0,0 +1,40 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; +import { randomUUID } from "crypto"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let content: string; + +describe("etapi/search", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + content = randomUUID(); + await createNote(app, token, content); + }); + + it("finds by content", async () => { + const response = await supertest(app) + .get(`/etapi/notes?search=${content}&debug=true`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body.results).toHaveLength(1); + }); + + it("does not find by content when fast search is on", async () => { + const response = await supertest(app) + .get(`/etapi/notes?search=${content}&debug=true&fastSearch=true`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + expect(response.body.results).toHaveLength(0); + }); +}); diff --git a/apps/server/spec/etapi/utils.ts b/apps/server/spec/etapi/utils.ts new file mode 100644 index 000000000..140c6820d --- /dev/null +++ b/apps/server/spec/etapi/utils.ts @@ -0,0 +1,33 @@ +import type { Application } from "express"; +import supertest from "supertest"; +import { expect } from "vitest"; + +export async function login(app: Application) { + // Obtain auth token. + const response = await supertest(app) + .post("/etapi/auth/login") + .send({ + "password": "demo1234" + }) + .expect(201); + const token = response.body.authToken; + expect(token).toBeTruthy(); + return token; +} + +export async function createNote(app: Application, token: string, content?: string) { + const response = await supertest(app) + .post("/etapi/create-note") + .auth("etapi", token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "Hello", + "type": "text", + "content": content ?? "Hi there!", + }) + .expect(201); + + const noteId = response.body.note.noteId as string; + expect(noteId).toStrictEqual(noteId); + return noteId; +} diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index 2fae6e73c..74e7ff746 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -3,6 +3,13 @@ import i18next from "i18next"; import { join } from "path"; import dayjs from "dayjs"; +// Initialize environment variables. +process.env.TRILIUM_DATA_DIR = join(__dirname, "db"); +process.env.TRILIUM_RESOURCE_DIR = join(__dirname, "../src"); +process.env.TRILIUM_INTEGRATION_TEST = "memory"; +process.env.TRILIUM_ENV = "dev"; +process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200"; + beforeAll(async () => { // Initialize the translations manually to avoid any side effects. const Backend = (await import("i18next-fs-backend")).default; diff --git a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json index f5f7da775..a6eecd810 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json +++ b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json @@ -1 +1 @@ -[{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"book","attributes":[{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_GTwFsgaA0lCt","title":"Book","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Book"},{"name":"iconClass","value":"bx bx-book","type":"label"}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Geo Map"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}]}]},{"id":"_help_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]}] \ No newline at end of file +[{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to Note"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"book","attributes":[{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_GTwFsgaA0lCt","title":"Book","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Book"},{"name":"iconClass","value":"bx bx-book","type":"label"}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Geo Map"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}],"children":[{"id":"_help_bOP3TB56fL1V","title":"grafana-dashboard.json","type":"doc","attributes":[{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]}] \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/1_Metrics_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/1_Metrics_image.png new file mode 100644 index 000000000..683789547 Binary files /dev/null and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/1_Metrics_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/2_Metrics_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/2_Metrics_image.png new file mode 100644 index 000000000..08181d986 Binary files /dev/null and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/2_Metrics_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html index 805db1df2..7f2d204f9 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html @@ -79,4 +79,24 @@ trilium_notes_total 1234 1701432000
  • 400 - Invalid format parameter
  • 401 - Missing or invalid ETAPI token
  • 500 - Internal server error
  • - \ No newline at end of file + +

     

    +

    Grafana Dashboard

    +
    + +
    +

     

    +

    You can also use the Grafana Dashboard that has been created for TriliumNext + - just take the JSON from grafana-dashboard.json and + then import the dashboard, following these screenshots:

    +
    + +
    +

    Then paste the JSON, and hit load:

    +
    + +
    +

     

    \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics/grafana-dashboard.json b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics/grafana-dashboard.json new file mode 100644 index 000000000..2e1e4511e --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics/grafana-dashboard.json @@ -0,0 +1,1335 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": 549, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "🏠 Trilium Overview", + "type": "row" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Current Trilium version and build information", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 101, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_info{job=~'$job',instance=~'$instance'}", + "format": "table", + "instant": true, + "refId": "A" + } + ], + "title": "📋 Instance Information", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true, + "__name__": true, + "instance": true, + "job": true + }, + "indexByName": {}, + "renameByName": { + "build_date": "Build Date", + "build_revision": "Git Revision", + "db_version": "DB Version", + "node_version": "Node.js", + "sync_version": "Sync Version", + "version": "Version" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Database file size in human-readable format", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 500000000 + }, + { + "color": "red", + "value": 1000000000 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 102, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_database_size_bytes{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "💾 Database Size", + "type": "stat" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Total active notes in your Trilium instance", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 1000 + }, + { + "color": "red", + "value": 5000 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 103, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_active{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "📝 Active Notes", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 7 + }, + "id": 200, + "panels": [], + "title": "📊 Key Metrics", + "type": "row" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Total notes including deleted ones", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 8 + }, + "id": 201, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_active{job=~'$job',instance=~'$instance'}", + "legendFormat": "Active Notes", + "refId": "A" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_deleted{job=~'$job',instance=~'$instance'}", + "legendFormat": "Deleted Notes", + "refId": "B" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_protected{job=~'$job',instance=~'$instance'}", + "legendFormat": "Protected Notes", + "refId": "C" + } + ], + "title": "📝 Notes Distribution", + "type": "piechart" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Breakdown of attachments by MIME type", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 8 + }, + "id": 202, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_attachments_by_type{job=~'$job',instance=~'$instance'}", + "legendFormat": "{{mime_type}}", + "refId": "A" + } + ], + "title": "🖼️ Attachments by Type", + "type": "piechart" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Distribution of notes by their content type", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 203, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "value", + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_by_type{job=~'$job',instance=~'$instance'}", + "legendFormat": "{{type}}", + "refId": "A" + } + ], + "title": "📄 Notes by Content Type", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 300, + "panels": [], + "title": "📈 Trends & Time Series", + "type": "row" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Growth of notes over time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 8, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Active Notes" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Total Notes" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 301, + "options": { + "legend": { + "calcs": [ + "last", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_active{job=~'$job',instance=~'$instance'}", + "legendFormat": "Active Notes", + "refId": "A" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_notes_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Total Notes", + "refId": "B" + } + ], + "title": "📈 Notes Growth Over Time", + "type": "timeseries" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Attachment storage trends", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 8, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Active Attachments" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Total Attachments" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 302, + "options": { + "legend": { + "calcs": [ + "last", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_attachments_active{job=~'$job',instance=~'$instance'}", + "legendFormat": "Active Attachments", + "refId": "A" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_attachments_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Total Attachments", + "refId": "B" + } + ], + "title": "📎 Attachments Growth Over Time", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 400, + "panels": [], + "title": "🔧 Advanced Metrics", + "type": "row" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Number of branches connecting notes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 26 + }, + "id": 401, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_branches_total{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "🌳 Total Branches", + "type": "stat" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Number of note attributes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 26 + }, + "id": 402, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_attributes_total{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "🏷️ Attributes", + "type": "stat" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Number of note revisions", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 26 + }, + "id": 403, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_revisions_total{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "🔄 Revisions", + "type": "stat" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Number of ETAPI tokens", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 26 + }, + "id": 404, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_etapi_tokens_total{job=~'$job',instance=~'$instance'}", + "refId": "A" + } + ], + "title": "🔑 API Tokens", + "type": "stat" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Various storage and system metrics", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Recent Notes" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 30 + }, + "id": 405, + "options": { + "legend": { + "calcs": [ + "last" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_blobs_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Blob Records", + "refId": "A" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_recent_notes_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Recent Notes", + "refId": "B" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_embeddings_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Embeddings", + "refId": "C" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_embedding_providers_total{job=~'$job',instance=~'$instance'}", + "legendFormat": "Embedding Providers", + "refId": "D" + } + ], + "title": "📊 Storage & System Metrics", + "type": "timeseries" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "description": "Timeline showing when content was created and last modified", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "points", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 8, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "always", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "dateTimeAsIso" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 30 + }, + "id": 406, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.1", + "targets": [ + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_oldest_note_timestamp{job=~'$job',instance=~'$instance'} * 1000", + "legendFormat": "Oldest Note", + "refId": "A" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_newest_note_timestamp{job=~'$job',instance=~'$instance'} * 1000", + "legendFormat": "Newest Note", + "refId": "B" + }, + { + "datasource": { + "uid": "${datasource}" + }, + "expr": "trilium_last_modified_timestamp{job=~'$job',instance=~'$instance'} * 1000", + "legendFormat": "Last Modified", + "refId": "C" + } + ], + "title": "⏰ Content Timeline", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "1m", + "schemaVersion": 41, + "tags": [ + "trilium", + "notes", + "monitoring", + "enhanced" + ], + "templating": { + "list": [ + { + "current": { + "text": "myprom", + "value": "PA04845DA3A4B088E" + }, + "includeAll": false, + "label": "Datasource", + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "//", + "type": "datasource" + }, + { + "allValue": ".*", + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": { + "UID": "", + "type": "" + }, + "includeAll": true, + "label": "Job", + "multi": true, + "name": "job", + "options": [], + "query": "query_result(up)", + "refresh": 1, + "regex": "/job=\"([^\"]+)\"/", + "sort": 1, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "includeAll": true, + "label": "Instance", + "multi": true, + "name": "instance", + "options": [], + "query": "trilium_database_size_bytes", + "refresh": 1, + "regex": "/instance=\"([^\"]+)\"/", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "TriliumNext Dashboard", + "uid": "06993f9b-a477-4723-bf18-47743393b382", + "version": 5 +} \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics_image.png new file mode 100644 index 000000000..ae68ddd02 Binary files /dev/null and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts.html index ddd6b647d..a9c2d7c65 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts.html @@ -77,7 +77,7 @@ class="reference-link" href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting.