Merge branch 'develop' into date/time

This commit is contained in:
SiriusXT 2025-06-04 16:48:37 +08:00
commit 3cdee1ac86
167 changed files with 14718 additions and 3699 deletions

2
.gitignore vendored
View File

@ -44,3 +44,5 @@ upload
.rollup.cache
*.tsbuildinfo
/result

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
GET {{triliumHost}}/etapi/app-info
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.clipperProtocolVersion === "1.0");
%}

View File

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

View File

@ -1,4 +0,0 @@
PUT {{triliumHost}}/etapi/backup/etapi_test
Authorization: {{authToken}}
> {% client.assert(response.status === 201); %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
{
"dev": {
"triliumHost": "http://localhost:37740"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
POST {{triliumHost}}/etapi/refresh-note-ordering/root
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,8 +58,11 @@ async function getWithSilentNotFound<T>(url: string, componentId?: string) {
return await call<T>("GET", url, componentId, { silentNotFound: true });
}
async function get<T>(url: string, componentId?: string) {
return await call<T>("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<T>(url: string, componentId?: string, raw?: boolean) {
return await call<T>("GET", url, componentId, { raw });
}
async function post<T>(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<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
@ -132,7 +137,7 @@ async function call<T>(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<T>(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<Response> {
/**
* @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<Response> {
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);

View File

@ -70,6 +70,7 @@
--scrollbar-border-color: #666;
--scrollbar-background-color: #333;
--selection-background-color: #3399FF70;
--tooltip-background-color: #333;
--link-color: lightskyblue;

View File

@ -74,6 +74,7 @@ html {
--scrollbar-border-color: #ddd;
--scrollbar-background-color: #ddd;
--selection-background-color: #3399FF70;
--tooltip-background-color: #f8f8f8;
--link-color: blue;

View File

@ -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
*/

View File

@ -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 */

View File

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

View File

@ -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<string | null> {
try {
const resp = await server.post<SessionResponse>('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<boolean> {
export async function checkSessionExists(noteId: string): Promise<boolean> {
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<any>(`llm/chat/${chatNoteId}`);
const sessionCheck = await server.getWithSilentNotFound<any>(`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<void> {
// 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<any>(`llm/chat/${chatNoteId}/messages/stream`, {
const streamResponse = await server.post<any>(`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<any> {
export async function getDirectResponse(noteId: string, messageParams: any): Promise<any> {
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<any>(`llm/chat/${chatNoteId}/messages`, {
const postResponse = await server.post<any>(`llm/chat/${noteId}/messages`, {
message: messageParams.content,
includeContext: messageParams.useAdvancedContext,
options: {

View File

@ -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<boolean> {
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<void> {
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;
}
}
// Track if we have a streaming message in progress
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
// Get the existing assistant message or create a new one
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
// Create a new message element or use the existing streaming one
let assistantMessageEl: HTMLElement;
if (!assistantMessageEl) {
// If no assistant message yet, create one
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 <think>...</think> blocks from the content
return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
/**
* Handle general errors in the send message flow
*/

View File

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

View File

@ -19,7 +19,7 @@ const TPL = /*html*/`
<div class="no-edited-notes-found">${t("edited_notes.no_edited_notes_found")}</div>
<div class="edited-notes-list"></div>
<div class="edited-notes-list use-tn-links"></div>
</div>
`;

View File

@ -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: {

View File

@ -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<XMLDocument>(`notes/${note.noteId}/open`);
const stringResponse = new XMLSerializer().serializeToString(xmlResponse);
const xmlResponse = await server.get<string | Uint8Array>(`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*/`\
<img class="icon" src="${markerIcon}" />
<img class="icon-shadow" src="${markerIconShadow}" />
<span class="bx ${bxIconClass} ${colorClass}"></span>
<span class="title-label">${title}</span>`,
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
<span class="title-label">${title ?? ""}</span>`,
iconSize: [25, 41],
iconAnchor: [12, 41]
});

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>(response.body.parentBranchIds))
.toStrictEqual(new Set<string>([ 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;
}

View File

@ -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<EntityType, string> = {
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -80,3 +80,23 @@ trilium_notes_total 1234 1701432000
<li><code>401</code> - Missing or invalid ETAPI token</li>
<li><code>500</code> - Internal server error</li>
</ul>
<p>&nbsp;</p>
<h2><strong>Grafana Dashboard</strong></h2>
<figure class="image">
<img style="aspect-ratio:2594/1568;" src="1_Metrics_image.png" width="2594"
height="1568">
</figure>
<p>&nbsp;</p>
<p>You can also use the Grafana Dashboard that has been created for TriliumNext
- just take the JSON from&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/uYF7pmepw27K/_help_bOP3TB56fL1V">grafana-dashboard.json</a>&nbsp;and
then import the dashboard, following these screenshots:</p>
<figure class="image">
<img style="aspect-ratio:1881/282;" src="2_Metrics_image.png" width="1881"
height="282">
</figure>
<p>Then paste the JSON, and hit load:</p>
<figure class="image">
<img style="aspect-ratio:1055/830;" src="Metrics_image.png" width="1055"
height="830">
</figure>
<p>&nbsp;</p>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -77,7 +77,7 @@
class="reference-link" href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>.</p>
</aside>
<ul>
<li>&nbsp;<kbd>Enter</kbd> in tree pane switches from tree pane into note title.
<li><kbd>Enter</kbd> in tree pane switches from tree pane into note title.
Enter from note title switches focus to text editor. <kbd>Ctrl</kbd>+<kbd>.</kbd> switches
back from editor to tree pane.</li>
<li><kbd>Ctrl</kbd>+<kbd>.</kbd> - jump away from the editor to tree pane and

View File

@ -19,7 +19,7 @@
<tr>
<td>1</td>
<td>
<figure class="image image_resized image-style-align-center" style="width:51.25%;">
<figure class="image image-style-align-center image_resized" style="width:51.25%;">
<img style="aspect-ratio:1256/1044;" src="7_Geo Map_image.png" width="1256"
height="1044">
</figure>
@ -29,7 +29,7 @@
<tr>
<td>2</td>
<td>
<figure class="image image_resized image-style-align-center" style="width:53.44%;">
<figure class="image image-style-align-center image_resized" style="width:53.44%;">
<img style="aspect-ratio:1720/1396;" src="9_Geo Map_image.png" width="1720"
height="1396">
</figure>
@ -170,7 +170,7 @@
<tr>
<td>1</td>
<td>
<figure class="image image_resized image-style-align-center" style="width:56.84%;">
<figure class="image image-style-align-center image_resized" style="width:56.84%;">
<img style="aspect-ratio:732/918;" src="13_Geo Map_image.png" width="732"
height="918">
</figure>
@ -187,7 +187,7 @@
<tr>
<td>2</td>
<td>
<figure class="image image_resized image-style-align-center" style="width:100%;">
<figure class="image image-style-align-center image_resized" style="width:100%;">
<img style="aspect-ratio:518/84;" src="4_Geo Map_image.png" width="518"
height="84">
</figure>
@ -197,7 +197,7 @@
<tr>
<td>3</td>
<td>
<figure class="image image_resized image-style-align-center" style="width:100%;">
<figure class="image image-style-align-center image_resized" style="width:100%;">
<img style="aspect-ratio:1074/276;" src="12_Geo Map_image.png" width="1074"
height="276">
</figure>
@ -316,8 +316,15 @@ class="table" style="width:100%;">
</tbody>
</table>
</figure>
<h2>Troubleshooting</h2>
<aside class="admonition note">
<p>The starting point of the track will be displayed as a marker, with the
name of the note underneath. The start marker will also respect the icon
and the <code>color</code> of the note. The end marker is displayed with
a distinct icon.</p>
<p>If the GPX contains waypoints, they will also be displayed. If they have
a name, it is displayed when hovering over it with the mouse.</p>
</aside>
<h2>Troubleshooting</h2>
<figure class="image image-style-align-right image_resized" style="width:34.06%;">
<img style="aspect-ratio:678/499;" src="14_Geo Map_image.png" width="678"
height="499">

View File

@ -60,7 +60,7 @@
</td>
</tr>
<tr>
<td>Mark selected text as <a href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_UYuUB1ZekNQU">keyboard shortcut</a>
<td>Mark selected text as <a href="#root/_help_UYuUB1ZekNQU">keyboard shortcut</a>
</td>
<td><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd>
</td>
@ -68,7 +68,7 @@
</td>
</tr>
<tr>
<td>Insert&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_YfYAtQBcfo5V">Math Equations</a>
<td>Insert&nbsp;<a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</td>
<td><kbd>Ctrl</kbd> + <kbd>M</kbd>
</td>
@ -76,7 +76,7 @@
</td>
</tr>
<tr>
<td rowspan="2">Move blocks (lists, paragraphs, etc.) up</td>
<td>Move blocks (lists, paragraphs, etc.) up</td>
<td><kbd>Ctrl</kbd>+<kbd></kbd>&nbsp;</td>
<td><kbd></kbd>+<kbd></kbd>&nbsp;</td>
</tr>
@ -87,7 +87,7 @@
</td>
</tr>
<tr>
<td rowspan="2">Move blocks (lists, paragraphs, etc.) down</td>
<td>Move blocks (lists, paragraphs, etc.) down</td>
<td><kbd>Ctrl</kbd>+<kbd></kbd>
</td>
<td><kbd></kbd>+<kbd></kbd>
@ -102,6 +102,7 @@
</tbody>
</table>
</figure>
<h2>Common shortcuts</h2>
<aside class="admonition tip">
<p>This section of keyboard shortcuts presents a subset of the keyboard shortcuts
@ -260,6 +261,7 @@
</tbody>
</table>
</figure>
<h3>Interacting with blocks</h3>
<p>Blocks are images, tables, blockquotes, annotations.</p>
<figure class="table">
@ -373,6 +375,7 @@
</tbody>
</table>
</figure>
<h3>General UI shortcuts</h3>
<figure class="table">
<table>

View File

@ -1,12 +1,18 @@
{
"keyboard_actions": {
"back-in-note-history": "Navigate to previous note in history",
"forward-in-note-history": "Navigate to next note in history",
"open-jump-to-note-dialog": "Open \"Jump to note\" dialog",
"scroll-to-active-note": "Scroll note tree to active note",
"quick-search": "Activate quick search bar",
"search-in-subtree": "Search for notes in the active note's subtree",
"expand-subtree": "Expand subtree of current note",
"collapse-tree": "Collapses the complete note tree",
"collapse-subtree": "Collapses subtree of current note",
"sort-child-notes": "Sort child notes",
"creating-and-moving-notes": "Creating and moving notes",
"create-note-after": "Create note after active note",
"create-note-into": "Create note as child of active note",
"create-note-into-inbox": "Create a note in the inbox (if defined) or day note",
"delete-note": "Delete note",
"move-note-up": "Move note up",
@ -14,40 +20,44 @@
"move-note-up-in-hierarchy": "Move note up in hierarchy",
"move-note-down-in-hierarchy": "Move note down in hierarchy",
"edit-note-title": "Jump from tree to the note detail and edit title",
"edit-branch-prefix": "Show Edit branch prefix dialog",
"edit-branch-prefix": "Show \"Edit branch prefix\" dialog",
"cloneNotesTo": "Clone selected notes",
"moveNotesTo": "Move selected notes",
"note-clipboard": "Note clipboard",
"copy-notes-to-clipboard": "Copy selected notes to the clipboard",
"paste-notes-from-clipboard": "Paste notes from the clipboard into active note",
"cut-notes-to-clipboard": "Cut selected notes to the clipboard",
"select-all-notes-in-parent": "Select all notes from the current note level",
"add-note-above-to-the-selection": "Add note above to the selection",
"add-note-below-to-selection": "Add note above to the selection",
"add-note-below-to-selection": "Add note below to the selection",
"duplicate-subtree": "Duplicate subtree",
"tabs-and-windows": "Tabs & Windows",
"open-new-tab": "Opens new tab",
"close-active-tab": "Closes active tab",
"reopen-last-tab": "Reopens the last closed tab",
"activate-next-tab": "Activates tab on the right",
"activate-previous-tab": "Activates tab on the left",
"open-new-tab": "Open new tab",
"close-active-tab": "Close active tab",
"reopen-last-tab": "Reopen the last closed tab",
"activate-next-tab": "Activate tab on the right",
"activate-previous-tab": "Activate tab on the left",
"open-new-window": "Open new empty window",
"toggle-tray": "Shows/hides the application from the system tray",
"first-tab": "Activates the first tab in the list",
"second-tab": "Activates the second tab in the list",
"third-tab": "Activates the third tab in the list",
"fourth-tab": "Activates the fourth tab in the list",
"fifth-tab": "Activates the fifth tab in the list",
"sixth-tab": "Activates the sixth tab in the list",
"seventh-tab": "Activates the seventh tab in the list",
"eight-tab": "Activates the eighth tab in the list",
"ninth-tab": "Activates the ninth tab in the list",
"last-tab": "Activates the last tab in the list",
"toggle-tray": "Show/hide the application from the system tray",
"first-tab": "Activate the first tab in the list",
"second-tab": "Activate the second tab in the list",
"third-tab": "Activate the third tab in the list",
"fourth-tab": "Activate the fourth tab in the list",
"fifth-tab": "Activate the fifth tab in the list",
"sixth-tab": "Activate the sixth tab in the list",
"seventh-tab": "Activate the seventh tab in the list",
"eight-tab": "Activate the eighth tab in the list",
"ninth-tab": "Activate the ninth tab in the list",
"last-tab": "Activate the last tab in the list",
"dialogs": "Dialogs",
"show-note-source": "Shows Note Source dialog",
"show-options": "Shows Options dialog",
"show-revisions": "Shows Note Revisions dialog",
"show-recent-changes": "Shows Recent Changes dialog",
"show-sql-console": "Shows SQL Console dialog",
"show-backend-log": "Shows Backend Log dialog",
"show-note-source": "Show \"Note Source\" dialog",
"show-options": "Open \"Options\" page",
"show-revisions": "Show \"Note Revisions\" dialog",
"show-recent-changes": "Show \"Recent Changes\" dialog",
"show-sql-console": "Open \"SQL Console\" page",
"show-backend-log": "Open \"Backend Log\" page",
"show-help": "Open the built-in User Guide",
"show-cheatsheet": "Show a modal with common keyboard operations",
"text-note-operations": "Text note operations",
"add-link-to-text": "Open dialog to add link to the text",
"follow-link-under-cursor": "Follow link within which the caret is placed",
@ -76,10 +86,11 @@
"open-note-externally": "Open note as a file with default application",
"render-active-note": "Render (re-render) active note",
"run-active-note": "Run active JavaScript (frontend/backend) code note",
"toggle-note-hoisting": "Toggles note hoisting of active note",
"toggle-note-hoisting": "Toggle note hoisting of active note",
"unhoist": "Unhoist from anywhere",
"reload-frontend-app": "Reload frontend App",
"open-dev-tools": "Open dev tools",
"reload-frontend-app": "Reload frontend",
"open-dev-tools": "Open developer tools",
"find-in-text": "Toggle search panel",
"toggle-left-note-tree-panel": "Toggle left (note tree) panel",
"toggle-full-screen": "Toggle full screen",
"zoom-out": "Zoom Out",
@ -88,11 +99,9 @@
"reset-zoom-level": "Reset zoom level",
"copy-without-formatting": "Copy selected text without formatting",
"force-save-revision": "Force creating / saving new note revision of the active note",
"show-help": "Shows the built-in User Guide",
"toggle-book-properties": "Toggle Book Properties",
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
"export-as-pdf": "Exports the current note as a PDF",
"show-cheatsheet": "Shows a modal with common keyboard operations",
"export-as-pdf": "Export the current note as a PDF",
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
},
"login": {

View File

@ -118,6 +118,15 @@
<% if (themeCssUrl) { %>
<link href="<%= themeCssUrl %>" rel="stylesheet">
<% } %>
<% if (themeUseNextAsBase === "next") { %>
<link href="<%= assetPath %>/stylesheets/theme-next.css" rel="stylesheet">
<% } else if (themeUseNextAsBase === "next-dark") { %>
<link href="<%= assetPath %>/stylesheets/theme-next-dark.css" rel="stylesheet">
<% } else if (themeUseNextAsBase === "next-light") { %>
<link href="<%= assetPath %>/stylesheets/theme-next-light.css" rel="stylesheet">
<% } %>
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">

View File

@ -13,6 +13,7 @@
<link rel="shortcut icon" href="../favicon.ico">
<% } %>
<script src="<%= appPath %>/share.js" type="module"></script>
<link href="<%= assetPath %>/src/share.css" rel="stylesheet">
<% if (!note.isLabelTruthy("shareOmitDefaultCss")) { %>
<link href="<%= assetPath %>/stylesheets/share.css" rel="stylesheet">
<% } %>

View File

@ -12,6 +12,7 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons";
import BBlob from "./entities/bblob.js";
import BRecentNote from "./entities/brecent_note.js";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import type BNoteEmbedding from "./entities/bnote_embedding.js";
interface AttachmentOpts {
includeContentLength?: boolean;
@ -32,6 +33,7 @@ export default class Becca {
attributeIndex!: Record<string, BAttribute[]>;
options!: Record<string, BOption>;
etapiTokens!: Record<string, BEtapiToken>;
noteEmbeddings!: Record<string, BNoteEmbedding>;
allNoteSetCache: NoteSet | null;
@ -48,6 +50,7 @@ export default class Becca {
this.attributeIndex = {};
this.options = {};
this.etapiTokens = {};
this.noteEmbeddings = {};
this.dirtyNoteSetCache();

View File

@ -9,9 +9,10 @@ import BBranch from "./entities/bbranch.js";
import BAttribute from "./entities/battribute.js";
import BOption from "./entities/boption.js";
import BEtapiToken from "./entities/betapi_token.js";
import BNoteEmbedding from "./entities/bnote_embedding.js";
import cls from "../services/cls.js";
import entityConstructor from "../becca/entity_constructor.js";
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow, NoteEmbeddingRow } from "@triliumnext/commons";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import ws from "../services/ws.js";
@ -63,6 +64,10 @@ function load() {
for (const row of sql.getRows<EtapiTokenRow>(/*sql*/`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
new BEtapiToken(row);
}
for (const row of sql.getRows<NoteEmbeddingRow>(/*sql*/`SELECT embedId, noteId, providerId, modelId, dimension, embedding, version, dateCreated, dateModified, utcDateCreated, utcDateModified FROM note_embeddings`)) {
new BNoteEmbedding(row).init();
}
});
for (const noteId in becca.notes) {
@ -85,7 +90,7 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({ entity
return;
}
if (["notes", "branches", "attributes", "etapi_tokens", "options"].includes(entityName)) {
if (["notes", "branches", "attributes", "etapi_tokens", "options", "note_embeddings"].includes(entityName)) {
const EntityClass = entityConstructor.getEntityFromEntityName(entityName);
const primaryKeyName = EntityClass.primaryKeyName;
@ -143,6 +148,8 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENT
attributeDeleted(entityId);
} else if (entityName === "etapi_tokens") {
etapiTokenDeleted(entityId);
} else if (entityName === "note_embeddings") {
noteEmbeddingDeleted(entityId);
}
});
@ -278,6 +285,10 @@ function etapiTokenDeleted(etapiTokenId: string) {
delete becca.etapiTokens[etapiTokenId];
}
function noteEmbeddingDeleted(embedId: string) {
delete becca.noteEmbeddings[embedId];
}
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
try {
becca.decryptProtectedNotes();

View File

@ -32,6 +32,12 @@ class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
}
}
init() {
if (this.embedId) {
this.becca.noteEmbeddings[this.embedId] = this;
}
}
updateFromRow(row: NoteEmbeddingRow): void {
this.embedId = row.embedId;
this.noteId = row.noteId;
@ -44,6 +50,10 @@ class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
this.dateModified = row.dateModified;
this.utcDateCreated = row.utcDateCreated;
this.utcDateModified = row.utcDateModified;
if (this.embedId) {
this.becca.noteEmbeddings[this.embedId] = this;
}
}
override beforeSaving() {

View File

@ -4,10 +4,10 @@ import eu from "./etapi_utils.js";
import backupService from "../services/backup.js";
function register(router: Router) {
eu.route(router, "put", "/etapi/backup/:backupName", async (req, res, next) => {
await backupService.backupNow(req.params.backupName);
res.sendStatus(204);
eu.route(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
backupService.backupNow(req.params.backupName)
.then(() => res.sendStatus(204))
.catch(() => res.sendStatus(500));
});
}

View File

@ -6,7 +6,7 @@ import etapiTokenService from "../services/etapi_tokens.js";
import config from "../services/config.js";
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
import type { ValidatorMap } from "./etapi-interface.js";
import type { ApiRequestHandler } from "../routes/route_api.js";
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
const GENERIC_CODE = "GENERIC";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
@ -73,11 +73,11 @@ function processRequest(req: Request, res: Response, routeHandler: ApiRequestHan
}
}
function route(router: Router, method: HttpMethod, path: string, routeHandler: ApiRequestHandler) {
function route(router: Router, method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
router[method](path, checkEtapiAuth, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: RequestHandler) {
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: SyncRouteRequestHandler) {
router[method](path, ...middleware, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}

View File

@ -15,46 +15,46 @@ function isValidDate(date: string) {
}
function register(router: Router) {
eu.route(router, "get", "/etapi/inbox/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/inbox/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await specialNotesService.getInboxNote(date);
const note = specialNotesService.getInboxNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/days/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/days/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getDayNote(date);
const note = dateNotesService.getDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getWeekFirstDayNote(date);
const note = dateNotesService.getWeekFirstDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/weeks/:week", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/weeks/:week", (req, res, next) => {
const { week } = req.params;
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
throw getWeekInvalidError(week);
}
const note = await dateNotesService.getWeekNote(week);
const note = dateNotesService.getWeekNote(week);
if (!note) {
throw getWeekNotFoundError(week);
@ -63,14 +63,14 @@ function register(router: Router) {
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/months/:month", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/months/:month", (req, res, next) => {
const { month } = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
throw getMonthInvalidError(month);
}
const note = await dateNotesService.getMonthNote(month);
const note = dateNotesService.getMonthNote(month);
res.json(mappers.mapNoteToPojo(note));
});

View File

@ -5,7 +5,6 @@ import options from "../../services/options.js";
// Import the index service for knowledge base management
import indexService from "../../services/llm/index_service.js";
import restChatService from "../../services/llm/rest_chat_service.js";
import chatService from '../../services/llm/chat_service.js';
import chatStorageService from '../../services/llm/chat_storage_service.js';
// Define basic interfaces
@ -190,23 +189,26 @@ async function getSession(req: Request, res: Response) {
* tags: ["llm"]
*/
async function updateSession(req: Request, res: Response) {
// Get the chat using ChatService
// Get the chat using chatStorageService directly
const chatNoteId = req.params.chatNoteId;
const updates = req.body;
try {
// Get the chat
const session = await chatService.getOrCreateSession(chatNoteId);
const chat = await chatStorageService.getChat(chatNoteId);
if (!chat) {
throw new Error(`Chat with ID ${chatNoteId} not found`);
}
// Update title if provided
if (updates.title) {
await chatStorageService.updateChat(chatNoteId, session.messages, updates.title);
await chatStorageService.updateChat(chatNoteId, chat.messages, updates.title);
}
// Return the updated chat
return {
id: chatNoteId,
title: updates.title || session.title,
title: updates.title || chat.title,
updatedAt: new Date()
};
} catch (error) {
@ -248,18 +250,18 @@ async function updateSession(req: Request, res: Response) {
* tags: ["llm"]
*/
async function listSessions(req: Request, res: Response) {
// Get all sessions using ChatService
// Get all sessions using chatStorageService directly
try {
const sessions = await chatService.getAllSessions();
const chats = await chatStorageService.getAllChats();
// Format the response
return {
sessions: sessions.map(session => ({
id: session.id,
title: session.title,
createdAt: new Date(), // Since we don't have this in chat sessions
lastActive: new Date(), // Since we don't have this in chat sessions
messageCount: session.messages.length
sessions: chats.map(chat => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt || new Date(),
lastActive: chat.updatedAt || new Date(),
messageCount: chat.messages.length
}))
};
} catch (error) {
@ -811,17 +813,38 @@ async function streamMessage(req: Request, res: Response) {
const { content, useAdvancedContext, showThinking, mentions } = req.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
throw new Error('Content cannot be empty');
return res.status(400).json({
success: false,
error: 'Content cannot be empty'
});
}
// Check if session exists
const session = restChatService.getSessions().get(chatNoteId);
if (!session) {
throw new Error('Chat not found');
// IMPORTANT: Immediately send a success response to the initial POST request
// The client is waiting for this to confirm streaming has been initiated
res.status(200).json({
success: true,
message: 'Streaming initiated successfully'
});
log.info(`Sent immediate success response for streaming setup`);
// Create a new response object for streaming through WebSocket only
// We won't use HTTP streaming since we've already sent the HTTP response
// Get or create chat directly from storage (simplified approach)
let chat = await chatStorageService.getChat(chatNoteId);
if (!chat) {
// Create a new chat if it doesn't exist
chat = await chatStorageService.createChat('New Chat');
log.info(`Created new chat with ID: ${chat.id} for stream request`);
}
// Update last active timestamp
session.lastActive = new Date();
// Add the user message to the chat immediately
chat.messages.push({
role: 'user',
content
});
// Save the chat to ensure the user message is recorded
await chatStorageService.updateChat(chat.id, chat.messages, chat.title);
// Process mentions if provided
let enhancedContent = content;
@ -830,7 +853,6 @@ async function streamMessage(req: Request, res: Response) {
// Import note service to get note content
const becca = (await import('../../becca/becca.js')).default;
const mentionContexts: string[] = [];
for (const mention of mentions) {
@ -857,102 +879,94 @@ async function streamMessage(req: Request, res: Response) {
}
}
// Add user message to the session (with enhanced content for processing)
session.messages.push({
role: 'user',
content: enhancedContent,
timestamp: new Date()
});
// Create request parameters for the pipeline
const requestParams = {
chatNoteId: chatNoteId,
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true,
stream: true // Always stream for this endpoint
};
// Create a fake request/response pair to pass to the handler
const fakeReq = {
...req,
method: 'GET', // Set to GET to indicate streaming
query: {
stream: 'true', // Set stream param - don't use format: 'stream' to avoid confusion
useAdvancedContext: String(useAdvancedContext === true),
showThinking: String(showThinking === true)
},
params: {
chatNoteId: chatNoteId
},
// Make sure the enhanced content is available to the handler
body: {
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true
}
} as unknown as Request;
// Log to verify correct parameters
log.info(`WebSocket stream settings - useAdvancedContext=${useAdvancedContext === true}, in query=${fakeReq.query.useAdvancedContext}, in body=${fakeReq.body.useAdvancedContext}`);
// Extra safety to ensure the parameters are passed correctly
if (useAdvancedContext === true) {
log.info(`Enhanced context IS enabled for this request`);
} else {
log.info(`Enhanced context is NOT enabled for this request`);
}
// Process the request in the background
Promise.resolve().then(async () => {
try {
await restChatService.handleSendMessage(fakeReq, res);
} catch (error) {
log.error(`Background message processing error: ${error}`);
// Import the WebSocket service
const wsService = (await import('../../services/ws.js')).default;
// Define LLMStreamMessage interface
interface LLMStreamMessage {
type: 'llm-stream';
chatNoteId: string;
content?: string;
thinking?: string;
toolExecution?: any;
done?: boolean;
error?: string;
raw?: unknown;
}
// Send error to client via WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error processing message: ${error}`,
done: true
} as LLMStreamMessage);
}
});
// Import the WebSocket service
// Import the WebSocket service to send immediate feedback
const wsService = (await import('../../services/ws.js')).default;
// Let the client know streaming has started via WebSocket (helps client confirm connection is working)
// Let the client know streaming has started
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
thinking: 'Initializing streaming LLM response...'
thinking: showThinking ? 'Initializing streaming LLM response...' : undefined
});
// Let the client know streaming has started via HTTP response
return {
success: true,
message: 'Streaming started',
chatNoteId: chatNoteId
};
// Instead of trying to reimplement the streaming logic ourselves,
// delegate to restChatService but set up the correct protocol:
// 1. We've already sent a success response to the initial POST
// 2. Now we'll have restChatService process the actual streaming through WebSocket
try {
// Import the WebSocket service for sending messages
const wsService = (await import('../../services/ws.js')).default;
// Create a simple pass-through response object that won't write to the HTTP response
// but will allow restChatService to send WebSocket messages
const dummyResponse = {
writableEnded: false,
// Implement methods that would normally be used by restChatService
write: (_chunk: string) => {
// Silent no-op - we're only using WebSocket
return true;
},
end: (_chunk?: string) => {
// Log when streaming is complete via WebSocket
log.info(`[${chatNoteId}] Completed HTTP response handling during WebSocket streaming`);
return dummyResponse;
},
setHeader: (name: string, _value: string) => {
// Only log for content-type to reduce noise
if (name.toLowerCase() === 'content-type') {
log.info(`[${chatNoteId}] Setting up streaming for WebSocket only`);
}
return dummyResponse;
}
};
// Process the streaming now through WebSocket only
try {
log.info(`[${chatNoteId}] Processing LLM streaming through WebSocket after successful initiation at ${new Date().toISOString()}`);
// Call restChatService with our enhanced request and dummy response
// The important part is setting method to GET to indicate streaming mode
await restChatService.handleSendMessage({
...req,
method: 'GET', // Indicate streaming mode
query: {
...req.query,
stream: 'true' // Add the required stream parameter
},
body: {
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true
},
params: { chatNoteId }
} as unknown as Request, dummyResponse as unknown as Response);
log.info(`[${chatNoteId}] WebSocket streaming completed at ${new Date().toISOString()}`);
} catch (streamError) {
log.error(`[${chatNoteId}] Error during WebSocket streaming: ${streamError}`);
// Send error message through WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error during streaming: ${streamError}`,
done: true
});
}
} catch (error) {
log.error(`Error during streaming: ${error}`);
// Send error to client via WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error processing message: ${error}`,
done: true
});
}
} catch (error: any) {
log.error(`Error starting message stream: ${error.message}`);
throw error;
log.error(`Error starting message stream, can't communicate via WebSocket: ${error.message}`);
}
}

View File

@ -81,13 +81,13 @@ async function listModels(req: Request, res: Response) {
// Filter and categorize models
const allModels = response.data || [];
// Separate models into chat models and embedding models
// Include all models as chat models, without filtering by specific model names
// This allows models from providers like OpenRouter to be displayed
const chatModels = allModels
.filter((model) =>
// Include GPT models for chat
model.id.includes('gpt') ||
// Include Claude models via Azure OpenAI
model.id.includes('claude')
// Exclude models that are explicitly for embeddings
!model.id.includes('embedding') &&
!model.id.includes('embed')
)
.map((model) => ({
id: model.id,

View File

@ -799,6 +799,7 @@ class ConsistencyChecks {
this.runEntityChangeChecks("attributes", "attributeId");
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
this.runEntityChangeChecks("options", "name");
this.runEntityChangeChecks("note_embeddings", "embedId");
}
findWronglyNamedAttributes() {

View File

@ -1,5 +1,5 @@
import sanitizeHtml from "sanitize-html";
import sanitizeUrl from "@braintree/sanitize-url";
import { sanitizeUrl } from "@braintree/sanitize-url";
import optionService from "./options.js";
// Be consistent with `ALLOWED_PROTOCOLS` in `src\public\app\services\link.js`
@ -190,6 +190,6 @@ function sanitize(dirtyHtml: string) {
export default {
sanitize,
sanitizeUrl: (url: string) => {
return sanitizeUrl.sanitizeUrl(url).trim();
return sanitizeUrl(url).trim();
}
};

Some files were not shown because too many files have changed in this diff Show More