mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 07:08:55 +02:00
Merge branch 'develop' into date/time
This commit is contained in:
commit
3cdee1ac86
4
.gitignore
vendored
4
.gitignore
vendored
@ -43,4 +43,6 @@ apps/*/out
|
||||
upload
|
||||
|
||||
.rollup.cache
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
/result
|
@ -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:
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
%}
|
@ -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");
|
||||
});
|
||||
%}
|
@ -1,7 +0,0 @@
|
||||
GET {{triliumHost}}/etapi/app-info
|
||||
Authorization: {{authToken}}
|
||||
|
||||
> {%
|
||||
client.assert(response.status === 200);
|
||||
client.assert(response.body.clipperProtocolVersion === "1.0");
|
||||
%}
|
@ -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); %}
|
@ -1,4 +0,0 @@
|
||||
PUT {{triliumHost}}/etapi/backup/etapi_test
|
||||
Authorization: {{authToken}}
|
||||
|
||||
> {% client.assert(response.status === 201); %}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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); %}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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")));
|
||||
%}
|
@ -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"));
|
||||
%}
|
@ -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!");
|
||||
%}
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"dev": {
|
||||
"triliumHost": "http://localhost:37740"
|
||||
}
|
||||
}
|
@ -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");
|
||||
%}
|
@ -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); %}
|
@ -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");
|
||||
});
|
||||
%}
|
@ -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); %}
|
@ -1,4 +0,0 @@
|
||||
POST {{triliumHost}}/etapi/refresh-note-ordering/root
|
||||
Authorization: {{authToken}}
|
||||
|
||||
> {% client.assert(response.status === 200); %}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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");
|
||||
%}
|
@ -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); %}
|
@ -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); %}
|
@ -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"); %}
|
@ -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); %}
|
||||
|
@ -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"); %}
|
@ -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);
|
||||
%}
|
@ -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"
|
||||
},
|
||||
|
@ -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}'`);
|
||||
}
|
||||
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
@ -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("?");
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -70,6 +70,7 @@
|
||||
|
||||
--scrollbar-border-color: #666;
|
||||
--scrollbar-background-color: #333;
|
||||
--selection-background-color: #3399FF70;
|
||||
--tooltip-background-color: #333;
|
||||
--link-color: lightskyblue;
|
||||
|
||||
|
@ -74,6 +74,7 @@ html {
|
||||
|
||||
--scrollbar-border-color: #ddd;
|
||||
--scrollbar-background-color: #ddd;
|
||||
--selection-background-color: #3399FF70;
|
||||
--tooltip-background-color: #f8f8f8;
|
||||
--link-color: blue;
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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 */
|
||||
|
20
apps/client/src/types-lib.d.ts
vendored
20
apps/client/src/types-lib.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the existing assistant message or create a new one
|
||||
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
|
||||
|
||||
if (!assistantMessageEl) {
|
||||
// If no assistant message yet, create one
|
||||
// Track if we have a streaming message in progress
|
||||
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
|
||||
|
||||
// Create a new message element or use the existing streaming one
|
||||
let assistantMessageEl: HTMLElement;
|
||||
|
||||
if (hasStreamingMessage) {
|
||||
// Use the existing streaming message
|
||||
assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!;
|
||||
} else {
|
||||
// Create a new message element
|
||||
assistantMessageEl = document.createElement('div');
|
||||
assistantMessageEl.className = 'assistant-message message mb-3';
|
||||
assistantMessageEl.className = 'assistant-message message mb-3 streaming';
|
||||
this.noteContextChatMessages.appendChild(assistantMessageEl);
|
||||
|
||||
// Add assistant profile icon
|
||||
@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
assistantMessageEl.appendChild(messageContent);
|
||||
}
|
||||
|
||||
// Clean the response to remove thinking tags before displaying
|
||||
const cleanedResponse = this.removeThinkingTags(assistantResponse);
|
||||
|
||||
// Update the content
|
||||
// Update the content with the current response
|
||||
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
|
||||
messageContent.innerHTML = formatMarkdown(cleanedResponse);
|
||||
messageContent.innerHTML = formatMarkdown(assistantResponse);
|
||||
|
||||
// Apply syntax highlighting if this is the final update
|
||||
// When the response is complete
|
||||
if (isDone) {
|
||||
// Remove the streaming class to mark this message as complete
|
||||
assistantMessageEl.classList.remove('streaming');
|
||||
|
||||
// Apply syntax highlighting
|
||||
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
||||
|
||||
// Hide the thinking display when response is complete
|
||||
this.hideThinkingDisplay();
|
||||
|
||||
// Update message in the data model for storage
|
||||
// Find the last assistant message to update, or add a new one if none exists
|
||||
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
|
||||
const lastAssistantMsgIndex = assistantMessages.length > 0 ?
|
||||
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
|
||||
|
||||
if (lastAssistantMsgIndex >= 0) {
|
||||
// Update existing message with cleaned content
|
||||
this.messages[lastAssistantMsgIndex].content = cleanedResponse;
|
||||
} else {
|
||||
// Add new message with cleaned content
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: cleanedResponse
|
||||
});
|
||||
}
|
||||
|
||||
// Hide loading indicator
|
||||
hideLoadingIndicator(this.loadingIndicator);
|
||||
|
||||
// Save the final state to the Chat Note
|
||||
this.saveCurrentData().catch(err => {
|
||||
console.error("Failed to save assistant response to note:", err);
|
||||
// Always add a new message to the data model
|
||||
// This ensures we preserve all distinct assistant messages
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantResponse,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// Save the updated message list
|
||||
this.saveCurrentData();
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove thinking tags from response content
|
||||
*/
|
||||
private removeThinkingTags(content: string): string {
|
||||
if (!content) return content;
|
||||
|
||||
// Remove <think>...</think> blocks from the content
|
||||
return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general errors in the send message flow
|
||||
*/
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
`;
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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]
|
||||
});
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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": {
|
||||
|
@ -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",
|
||||
|
48
apps/server/spec/etapi/api-metrics.spec.ts
Normal file
48
apps/server/spec/etapi/api-metrics.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
20
apps/server/spec/etapi/app-info.spec.ts
Normal file
20
apps/server/spec/etapi/app-info.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
64
apps/server/spec/etapi/attachment-content.spec.ts
Normal file
64
apps/server/spec/etapi/attachment-content.spec.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
54
apps/server/spec/etapi/basic-auth.spec.ts
Normal file
54
apps/server/spec/etapi/basic-auth.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
26
apps/server/spec/etapi/create-backup.spec.ts
Normal file
26
apps/server/spec/etapi/create-backup.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
178
apps/server/spec/etapi/create-entities.spec.ts
Normal file
178
apps/server/spec/etapi/create-entities.spec.ts
Normal 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;
|
||||
}
|
172
apps/server/spec/etapi/delete-entities.spec.ts
Normal file
172
apps/server/spec/etapi/delete-entities.spec.ts
Normal 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);
|
||||
}
|
71
apps/server/spec/etapi/etapi-metrics.spec.ts
Normal file
71
apps/server/spec/etapi/etapi-metrics.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
51
apps/server/spec/etapi/export-note-subtree.spec.ts
Normal file
51
apps/server/spec/etapi/export-note-subtree.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
103
apps/server/spec/etapi/get-date-notes.spec.ts
Normal file
103
apps/server/spec/etapi/get-date-notes.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
});
|
60
apps/server/spec/etapi/get-inherited-attribute.spec.ts
Normal file
60
apps/server/spec/etapi/get-inherited-attribute.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
34
apps/server/spec/etapi/import-zip.spec.ts
Normal file
34
apps/server/spec/etapi/import-zip.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
54
apps/server/spec/etapi/no-token.spec.ts
Normal file
54
apps/server/spec/etapi/no-token.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
72
apps/server/spec/etapi/note-content.spec.ts
Normal file
72
apps/server/spec/etapi/note-content.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
26
apps/server/spec/etapi/other.spec.ts
Normal file
26
apps/server/spec/etapi/other.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
78
apps/server/spec/etapi/patch-attachment.spec.ts
Normal file
78
apps/server/spec/etapi/patch-attachment.spec.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
77
apps/server/spec/etapi/patch-attribute.spec.ts
Normal file
77
apps/server/spec/etapi/patch-attribute.spec.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
77
apps/server/spec/etapi/patch-branch.spec.ts
Normal file
77
apps/server/spec/etapi/patch-branch.spec.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
89
apps/server/spec/etapi/patch-note.spec.ts
Normal file
89
apps/server/spec/etapi/patch-note.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
29
apps/server/spec/etapi/post-revision.spec.ts
Normal file
29
apps/server/spec/etapi/post-revision.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
40
apps/server/spec/etapi/search.spec.ts
Normal file
40
apps/server/spec/etapi/search.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
33
apps/server/spec/etapi/utils.ts
Normal file
33
apps/server/spec/etapi/utils.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/1_Metrics_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/1_Metrics_image.png
generated
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 548 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/2_Metrics_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/2_Metrics_image.png
generated
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
22
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html
generated
vendored
22
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics.html
generated
vendored
@ -79,4 +79,24 @@ trilium_notes_total 1234 1701432000
|
||||
<li><code>400</code> - Invalid format parameter</li>
|
||||
<li><code>401</code> - Missing or invalid ETAPI token</li>
|
||||
<li><code>500</code> - Internal server error</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<p> </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> </p>
|
||||
<p>You can also use the Grafana Dashboard that has been created for TriliumNext
|
||||
- just take the JSON from <a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/uYF7pmepw27K/_help_bOP3TB56fL1V">grafana-dashboard.json</a> 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> </p>
|
1335
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics/grafana-dashboard.json
generated
vendored
Normal file
1335
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics/grafana-dashboard.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Metrics_image.png
generated
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
@ -77,7 +77,7 @@
|
||||
class="reference-link" href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>.</p>
|
||||
</aside>
|
||||
<ul>
|
||||
<li> <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
|
||||
|
21
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Geo Map.html
generated
vendored
21
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Geo Map.html
generated
vendored
@ -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">
|
||||
|
@ -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 <a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_YfYAtQBcfo5V">Math Equations</a>
|
||||
<td>Insert <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> </td>
|
||||
<td><kbd>⌘</kbd>+<kbd>↑</kbd> </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>
|
||||
|
@ -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": {
|
||||
|
@ -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">
|
||||
|
||||
|
@ -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">
|
||||
<% } %>
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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() {
|
||||
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
});
|
||||
|
||||
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Check if session exists
|
||||
const session = restChatService.getSessions().get(chatNoteId);
|
||||
if (!session) {
|
||||
throw new Error('Chat not found');
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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')
|
||||
.filter((model) =>
|
||||
// Exclude models that are explicitly for embeddings
|
||||
!model.id.includes('embedding') &&
|
||||
!model.id.includes('embed')
|
||||
)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
|
@ -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() {
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user