mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 15:19:01 +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
|
upload
|
||||||
|
|
||||||
.rollup.cache
|
.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)
|
* [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
|
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
|
||||||
* Customizable UI (sidebar buttons, user-defined widgets, ...)
|
* 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:
|
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
|
||||||
|
|
||||||
|
@ -40,8 +40,8 @@
|
|||||||
"@types/express": "5.0.1",
|
"@types/express": "5.0.1",
|
||||||
"@types/node": "22.15.29",
|
"@types/node": "22.15.29",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@vitest/coverage-v8": "3.1.4",
|
"@vitest/coverage-v8": "3.2.1",
|
||||||
"eslint": "9.27.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||||
"esm": "3.2.25",
|
"esm": "3.2.25",
|
||||||
"jsdoc": "4.0.4",
|
"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"
|
"url": "https://github.com/TriliumNext/Notes"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/js": "9.27.0",
|
"@eslint/js": "9.28.0",
|
||||||
"@excalidraw/excalidraw": "0.18.0",
|
"@excalidraw/excalidraw": "0.18.0",
|
||||||
"@fullcalendar/core": "6.1.17",
|
"@fullcalendar/core": "6.1.17",
|
||||||
"@fullcalendar/daygrid": "6.1.17",
|
"@fullcalendar/daygrid": "6.1.17",
|
||||||
@ -66,7 +66,7 @@
|
|||||||
"@types/react": "19.1.6",
|
"@types/react": "19.1.6",
|
||||||
"@types/react-dom": "19.1.5",
|
"@types/react-dom": "19.1.5",
|
||||||
"copy-webpack-plugin": "13.0.0",
|
"copy-webpack-plugin": "13.0.0",
|
||||||
"happy-dom": "17.5.6",
|
"happy-dom": "17.6.3",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"vite-plugin-static-copy": "3.0.0"
|
"vite-plugin-static-copy": "3.0.0"
|
||||||
},
|
},
|
||||||
|
@ -35,8 +35,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
|||||||
loadResults.addOption(attributeEntity.name);
|
loadResults.addOption(attributeEntity.name);
|
||||||
} else if (ec.entityName === "attachments") {
|
} else if (ec.entityName === "attachments") {
|
||||||
processAttachment(loadResults, ec);
|
processAttachment(loadResults, ec);
|
||||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "note_embeddings") {
|
||||||
// NOOP
|
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||||
}
|
}
|
||||||
|
@ -16,4 +16,24 @@ describe("Link", () => {
|
|||||||
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
|
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
|
||||||
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url = url.trim();
|
||||||
const hashIdx = url.indexOf("#");
|
const hashIdx = url.indexOf("#");
|
||||||
if (hashIdx === -1) {
|
if (hashIdx === -1) {
|
||||||
return {};
|
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 '#'
|
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
|
||||||
let [notePath, paramString] = hash.split("?");
|
let [notePath, paramString] = hash.split("?");
|
||||||
|
|
||||||
|
@ -44,9 +44,17 @@ interface OptionRow {}
|
|||||||
|
|
||||||
interface NoteReorderingRow {}
|
interface NoteReorderingRow {}
|
||||||
|
|
||||||
interface ContentNoteIdToComponentIdRow {
|
interface NoteEmbeddingRow {
|
||||||
|
embedId: string;
|
||||||
noteId: string;
|
noteId: string;
|
||||||
componentId: string;
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
dimension: number;
|
||||||
|
version: number;
|
||||||
|
dateCreated: string;
|
||||||
|
utcDateCreated: string;
|
||||||
|
dateModified: string;
|
||||||
|
utcDateModified: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityRowMappings = {
|
type EntityRowMappings = {
|
||||||
@ -56,6 +64,7 @@ type EntityRowMappings = {
|
|||||||
options: OptionRow;
|
options: OptionRow;
|
||||||
revisions: RevisionRow;
|
revisions: RevisionRow;
|
||||||
note_reordering: NoteReorderingRow;
|
note_reordering: NoteReorderingRow;
|
||||||
|
note_embeddings: NoteEmbeddingRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityRowNames = keyof EntityRowMappings;
|
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 });
|
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) {
|
async function post<T>(url: string, data?: unknown, componentId?: string) {
|
||||||
@ -102,6 +105,8 @@ let maxKnownEntityChangeId = 0;
|
|||||||
interface CallOptions {
|
interface CallOptions {
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
silentNotFound?: boolean;
|
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 = {}) {
|
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;
|
})) as any;
|
||||||
} else {
|
} 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"];
|
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;
|
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) => {
|
return new Promise((res, rej) => {
|
||||||
const options: JQueryAjaxSettings = {
|
const options: JQueryAjaxSettings = {
|
||||||
url: window.glob.baseApiUrl + url,
|
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) {
|
if (data) {
|
||||||
try {
|
try {
|
||||||
options.data = JSON.stringify(data);
|
options.data = JSON.stringify(data);
|
||||||
|
@ -70,6 +70,7 @@
|
|||||||
|
|
||||||
--scrollbar-border-color: #666;
|
--scrollbar-border-color: #666;
|
||||||
--scrollbar-background-color: #333;
|
--scrollbar-background-color: #333;
|
||||||
|
--selection-background-color: #3399FF70;
|
||||||
--tooltip-background-color: #333;
|
--tooltip-background-color: #333;
|
||||||
--link-color: lightskyblue;
|
--link-color: lightskyblue;
|
||||||
|
|
||||||
|
@ -74,6 +74,7 @@ html {
|
|||||||
|
|
||||||
--scrollbar-border-color: #ddd;
|
--scrollbar-border-color: #ddd;
|
||||||
--scrollbar-background-color: #ddd;
|
--scrollbar-background-color: #ddd;
|
||||||
|
--selection-background-color: #3399FF70;
|
||||||
--tooltip-background-color: #f8f8f8;
|
--tooltip-background-color: #f8f8f8;
|
||||||
--link-color: blue;
|
--link-color: blue;
|
||||||
|
|
||||||
|
@ -108,6 +108,25 @@ div.editability-dropdown a.dropdown-item {
|
|||||||
font-size: 0.85em;
|
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
|
* 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-children .close-floating-buttons-button::before,
|
||||||
div.floating-buttons .show-floating-buttons-button::before {
|
div.floating-buttons .show-floating-buttons-button::before {
|
||||||
display: block;
|
display: block;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Show buttons" button */
|
/* "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;
|
}) => void;
|
||||||
export default renderMathInElement;
|
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
|
* 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 {
|
try {
|
||||||
const resp = await server.post<SessionResponse>('llm/chat', {
|
const resp = await server.post<SessionResponse>('llm/chat', {
|
||||||
title: 'Note Chat',
|
title: 'Note Chat',
|
||||||
@ -15,48 +17,42 @@ export async function createChatSession(currentNoteId?: string): Promise<{chatNo
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resp && resp.id) {
|
if (resp && resp.id) {
|
||||||
// The backend might provide the noteId separately from the chatNoteId
|
// Backend returns the chat note ID as 'id'
|
||||||
// If noteId is provided, use it; otherwise, we'll need to query for it separately
|
return resp.id;
|
||||||
return {
|
|
||||||
chatNoteId: resp.id,
|
|
||||||
noteId: resp.noteId || null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create chat session:', error);
|
console.error('Failed to create chat session:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
chatNoteId: null,
|
|
||||||
noteId: 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 {
|
try {
|
||||||
// Validate that we have a proper note ID format, not a session ID
|
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${noteId}`);
|
||||||
// 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}`);
|
|
||||||
return !!(sessionCheck && sessionCheck.id);
|
return !!(sessionCheck && sessionCheck.id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`Error checking chat note ${chatNoteId}:`, error);
|
console.log(`Error checking chat note ${noteId}:`, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up streaming response via WebSocket
|
* 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(
|
export async function setupStreamingResponse(
|
||||||
chatNoteId: string,
|
noteId: string,
|
||||||
messageParams: any,
|
messageParams: any,
|
||||||
onContentUpdate: (content: string, isDone?: boolean) => void,
|
onContentUpdate: (content: string, isDone?: boolean) => void,
|
||||||
onThinkingUpdate: (thinking: string) => void,
|
onThinkingUpdate: (thinking: string) => void,
|
||||||
@ -64,35 +60,24 @@ export async function setupStreamingResponse(
|
|||||||
onComplete: () => void,
|
onComplete: () => void,
|
||||||
onError: (error: Error) => void
|
onError: (error: Error) => void
|
||||||
): Promise<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) => {
|
return new Promise((resolve, reject) => {
|
||||||
let assistantResponse = '';
|
let assistantResponse = '';
|
||||||
let postToolResponse = ''; // Separate accumulator for post-tool execution content
|
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
let receivedPostToolContent = false; // Track if we've started receiving post-tool content
|
|
||||||
let timeoutId: number | null = null;
|
let timeoutId: number | null = null;
|
||||||
let initialTimeoutId: number | null = null;
|
let initialTimeoutId: number | null = null;
|
||||||
let cleanupTimeoutId: number | null = null;
|
let cleanupTimeoutId: number | null = null;
|
||||||
let receivedAnyMessage = false;
|
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 eventListener: ((event: Event) => void) | null = null;
|
||||||
let lastMessageTimestamp = 0;
|
let lastMessageTimestamp = 0;
|
||||||
|
|
||||||
// Create a unique identifier for this response process
|
// Create a unique identifier for this response process
|
||||||
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
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
|
// Send the initial request to initiate streaming
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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,
|
content: messageParams.content,
|
||||||
useAdvancedContext: messageParams.useAdvancedContext,
|
useAdvancedContext: messageParams.useAdvancedContext,
|
||||||
showThinking: messageParams.showThinking,
|
showThinking: messageParams.showThinking,
|
||||||
@ -129,28 +114,14 @@ export async function setupStreamingResponse(
|
|||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to schedule cleanup with ability to cancel
|
// Set initial timeout to catch cases where no message is received at all
|
||||||
const scheduleCleanup = (delay: number) => {
|
initialTimeoutId = window.setTimeout(() => {
|
||||||
// Clear any existing cleanup timeout
|
if (!receivedAnyMessage) {
|
||||||
if (cleanupTimeoutId) {
|
console.error(`[${responseId}] No initial message received within timeout`);
|
||||||
window.clearTimeout(cleanupTimeoutId);
|
performCleanup();
|
||||||
|
reject(new Error('No response received from server'));
|
||||||
}
|
}
|
||||||
|
}, 10000);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a message handler for CustomEvents
|
// Create a message handler for CustomEvents
|
||||||
eventListener = (event: Event) => {
|
eventListener = (event: Event) => {
|
||||||
@ -158,7 +129,7 @@ export async function setupStreamingResponse(
|
|||||||
const message = customEvent.detail;
|
const message = customEvent.detail;
|
||||||
|
|
||||||
// Only process messages for our chat note
|
// Only process messages for our chat note
|
||||||
if (!message || message.chatNoteId !== chatNoteId) {
|
if (!message || message.chatNoteId !== noteId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,12 +143,12 @@ export async function setupStreamingResponse(
|
|||||||
cleanupTimeoutId = null;
|
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
|
// Mark first message received
|
||||||
if (!receivedAnyMessage) {
|
if (!receivedAnyMessage) {
|
||||||
receivedAnyMessage = true;
|
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
|
// Clear the initial timeout since we've received a message
|
||||||
if (initialTimeoutId !== null) {
|
if (initialTimeoutId !== null) {
|
||||||
@ -186,109 +157,33 @@ export async function setupStreamingResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle specific message types
|
// Handle error
|
||||||
if (message.type === 'tool_execution_start') {
|
if (message.error) {
|
||||||
toolsExecuted = true; // Mark that tools were executed
|
console.error(`[${responseId}] Stream error: ${message.error}`);
|
||||||
onThinkingUpdate('Executing tools...');
|
performCleanup();
|
||||||
// Also trigger tool execution UI with a specific format
|
reject(new Error(message.error));
|
||||||
onToolExecution({
|
return;
|
||||||
action: 'start',
|
|
||||||
tool: 'tools',
|
|
||||||
result: 'Executing tools...'
|
|
||||||
});
|
|
||||||
return; // Skip accumulating content from this message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'tool_result' && message.toolExecution) {
|
// Handle thinking updates - only show if showThinking is enabled
|
||||||
toolsExecuted = true; // Mark that tools were executed
|
if (message.thinking && messageParams.showThinking) {
|
||||||
console.log(`[${responseId}] Processing tool result: ${JSON.stringify(message.toolExecution)}`);
|
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
|
// Handle tool execution updates
|
||||||
if (!message.toolExecution.action) {
|
if (message.toolExecution) {
|
||||||
message.toolExecution.action = 'result';
|
console.log(`[${responseId}] Tool execution update:`, message.toolExecution);
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
onToolExecution(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
|
// Handle content updates
|
||||||
if (message.content) {
|
if (message.content) {
|
||||||
console.log(`[${responseId}] Received content chunk of length ${message.content.length}, preview: "${message.content.substring(0, 50)}${message.content.length > 50 ? '...' : ''}"`);
|
// Simply append the new content - no complex deduplication
|
||||||
|
assistantResponse += message.content;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Update the UI immediately with each chunk
|
||||||
|
onContentUpdate(assistantResponse, message.done || false);
|
||||||
receivedAnyContent = true;
|
receivedAnyContent = true;
|
||||||
|
|
||||||
// Reset timeout since we got content
|
// Reset timeout since we got content
|
||||||
@ -298,151 +193,33 @@ export async function setupStreamingResponse(
|
|||||||
|
|
||||||
// Set new timeout
|
// Set new timeout
|
||||||
timeoutId = window.setTimeout(() => {
|
timeoutId = window.setTimeout(() => {
|
||||||
console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`);
|
console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`);
|
||||||
|
|
||||||
// Clean up
|
|
||||||
performCleanup();
|
performCleanup();
|
||||||
reject(new Error('Stream timeout'));
|
reject(new Error('Stream timeout'));
|
||||||
}, 30000);
|
}, 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
|
// Handle completion
|
||||||
if (message.done) {
|
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
|
// Clear all timeouts
|
||||||
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
|
|
||||||
if (timeoutId !== null) {
|
if (timeoutId !== null) {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always mark as done when we receive the done flag
|
// Schedule cleanup after a brief delay to ensure all processing is complete
|
||||||
onContentUpdate(assistantResponse, true);
|
cleanupTimeoutId = window.setTimeout(() => {
|
||||||
|
performCleanup();
|
||||||
// Set a longer delay before cleanup to allow for post-tool execution messages
|
}, 100);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register event listener for the custom event
|
// Register the event listener for WebSocket messages
|
||||||
try {
|
window.addEventListener('llm-stream-message', eventListener);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial timeout for receiving any message
|
console.log(`[${responseId}] Event listener registered, waiting for messages...`);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,15 +240,9 @@ function cleanupEventListener(listener: ((event: Event) => void) | null): void {
|
|||||||
/**
|
/**
|
||||||
* Get a direct response from the server without streaming
|
* 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 {
|
try {
|
||||||
// Validate that we have a proper note ID format, not a session ID
|
const postResponse = await server.post<any>(`llm/chat/${noteId}/messages`, {
|
||||||
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`, {
|
|
||||||
message: messageParams.content,
|
message: messageParams.content,
|
||||||
includeContext: messageParams.useAdvancedContext,
|
includeContext: messageParams.useAdvancedContext,
|
||||||
options: {
|
options: {
|
||||||
|
@ -37,9 +37,10 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
private thinkingBubble!: HTMLElement;
|
private thinkingBubble!: HTMLElement;
|
||||||
private thinkingText!: HTMLElement;
|
private thinkingText!: HTMLElement;
|
||||||
private thinkingToggle!: HTMLElement;
|
private thinkingToggle!: HTMLElement;
|
||||||
private chatNoteId: string | null = null;
|
|
||||||
private noteId: string | null = null; // The actual noteId for the Chat Note
|
// Simplified to just use noteId - this represents the AI Chat note we're working with
|
||||||
private currentNoteId: string | null = null;
|
private noteId: string | null = null;
|
||||||
|
private currentNoteId: string | null = null; // The note providing context (for regular notes)
|
||||||
private _messageHandlerId: number | null = null;
|
private _messageHandlerId: number | null = null;
|
||||||
private _messageHandler: any = null;
|
private _messageHandler: any = null;
|
||||||
|
|
||||||
@ -68,7 +69,6 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
};
|
};
|
||||||
} = {
|
} = {
|
||||||
model: 'default',
|
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
toolExecutions: []
|
toolExecutions: []
|
||||||
};
|
};
|
||||||
@ -90,12 +90,21 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
this.messages = messages;
|
this.messages = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getChatNoteId(): string | null {
|
public getNoteId(): string | null {
|
||||||
return this.chatNoteId;
|
return this.noteId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setChatNoteId(chatNoteId: string | null): void {
|
public setNoteId(noteId: string | null): void {
|
||||||
this.chatNoteId = chatNoteId;
|
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 {
|
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,
|
messages: this.messages,
|
||||||
chatNoteId: this.chatNoteId,
|
|
||||||
noteId: this.noteId,
|
noteId: this.noteId,
|
||||||
|
chatNoteId: this.noteId, // For backward compatibility
|
||||||
toolSteps: toolSteps,
|
toolSteps: toolSteps,
|
||||||
// Add sources if we have them
|
// Add sources if we have them
|
||||||
sources: this.sources || [],
|
sources: this.sources || [],
|
||||||
// Add metadata
|
// Add metadata
|
||||||
metadata: {
|
metadata: {
|
||||||
model: this.metadata?.model || 'default',
|
model: this.metadata?.model || undefined,
|
||||||
provider: this.metadata?.provider || undefined,
|
provider: this.metadata?.provider || undefined,
|
||||||
temperature: this.metadata?.temperature || 0.7,
|
temperature: this.metadata?.temperature || 0.7,
|
||||||
lastUpdated: new Date().toISOString(),
|
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
|
// 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
|
// 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;
|
const savedData = await this.onGetData() as ChatData;
|
||||||
|
|
||||||
if (savedData?.messages?.length > 0) {
|
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
|
// Load messages
|
||||||
|
const oldMessages = [...this.messages];
|
||||||
this.messages = savedData.messages;
|
this.messages = savedData.messages;
|
||||||
|
|
||||||
// Clear and rebuild the chat UI
|
// Only rebuild UI if we have significantly different content
|
||||||
this.noteContextChatMessages.innerHTML = '';
|
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 => {
|
newMessages.forEach(message => {
|
||||||
const role = message.role as 'user' | 'assistant';
|
const role = message.role as 'user' | 'assistant';
|
||||||
this.addMessageToChat(role, message.content);
|
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
|
// Restore tool execution steps if they exist
|
||||||
if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) {
|
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
|
// Load Chat Note ID if available
|
||||||
if (savedData.noteId) {
|
if (savedData.noteId) {
|
||||||
console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`);
|
console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`);
|
||||||
this.chatNoteId = savedData.noteId;
|
|
||||||
this.noteId = savedData.noteId;
|
this.noteId = savedData.noteId;
|
||||||
} else {
|
} else {
|
||||||
console.log(`No noteId found in saved data, cannot load chat session`);
|
console.log(`No noteId found in saved data, cannot load chat session`);
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -550,6 +621,15 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
// Get current note context if needed
|
// Get current note context if needed
|
||||||
const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null;
|
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 we're switching to a different note, we need to reset
|
||||||
if (this.currentNoteId !== currentActiveNoteId) {
|
if (this.currentNoteId !== currentActiveNoteId) {
|
||||||
console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`);
|
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
|
// Reset the UI and data
|
||||||
this.noteContextChatMessages.innerHTML = '';
|
this.noteContextChatMessages.innerHTML = '';
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.chatNoteId = null;
|
|
||||||
this.noteId = null; // Also reset the chat note ID
|
this.noteId = null; // Also reset the chat note ID
|
||||||
this.hideSources(); // Hide any sources from previous note
|
this.hideSources(); // Hide any sources from previous note
|
||||||
|
|
||||||
@ -569,7 +648,7 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
const hasSavedData = await this.loadSavedData();
|
const hasSavedData = await this.loadSavedData();
|
||||||
|
|
||||||
// Only create a new session if we don't have a session or saved data
|
// 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
|
// Create a new chat session
|
||||||
await this.createChatSession();
|
await this.createChatSession();
|
||||||
}
|
}
|
||||||
@ -580,19 +659,15 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
*/
|
*/
|
||||||
private async createChatSession() {
|
private async createChatSession() {
|
||||||
try {
|
try {
|
||||||
// Create a new chat session, passing the current note ID if it exists
|
// If we already have a noteId (for AI Chat notes), use it
|
||||||
const { chatNoteId, noteId } = await createChatSession(
|
const contextNoteId = this.noteId || this.currentNoteId;
|
||||||
this.currentNoteId ? this.currentNoteId : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (chatNoteId) {
|
// Create a new chat session, passing the context note ID
|
||||||
// If we got back an ID from the API, use it
|
const noteId = await createChatSession(contextNoteId ? contextNoteId : undefined);
|
||||||
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;
|
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
// Set the note ID for this chat
|
||||||
|
this.noteId = noteId;
|
||||||
console.log(`Created new chat session with noteId: ${this.noteId}`);
|
console.log(`Created new chat session with noteId: ${this.noteId}`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Failed to create chat session - no ID returned");
|
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;
|
const showThinking = this.showThinkingCheckbox.checked;
|
||||||
|
|
||||||
// Add logging to verify parameters
|
// 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
|
// Create the message parameters
|
||||||
const messageParams = {
|
const messageParams = {
|
||||||
@ -695,11 +770,11 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
await validateEmbeddingProviders(this.validationWarning);
|
await validateEmbeddingProviders(this.validationWarning);
|
||||||
|
|
||||||
// Make sure we have a valid session
|
// Make sure we have a valid session
|
||||||
if (!this.chatNoteId) {
|
if (!this.noteId) {
|
||||||
// If no session ID, create a new session
|
// If no session ID, create a new session
|
||||||
await this.createChatSession();
|
await this.createChatSession();
|
||||||
|
|
||||||
if (!this.chatNoteId) {
|
if (!this.noteId) {
|
||||||
// If still no session ID, show error and return
|
// If still no session ID, show error and return
|
||||||
console.error("Failed to create chat session");
|
console.error("Failed to create chat session");
|
||||||
toastService.showError("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();
|
await this.saveCurrentData();
|
||||||
|
|
||||||
// Add logging to verify parameters
|
// 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
|
// Create the message parameters
|
||||||
const messageParams = {
|
const messageParams = {
|
||||||
@ -767,12 +842,12 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
*/
|
*/
|
||||||
private async handleDirectResponse(messageParams: any): Promise<boolean> {
|
private async handleDirectResponse(messageParams: any): Promise<boolean> {
|
||||||
try {
|
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
|
// 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 the POST request returned content directly, display it
|
||||||
if (postResponse && postResponse.content) {
|
if (postResponse && postResponse.content) {
|
||||||
@ -845,11 +920,11 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
* Set up streaming response via WebSocket
|
* Set up streaming response via WebSocket
|
||||||
*/
|
*/
|
||||||
private async setupStreamingResponse(messageParams: any): Promise<void> {
|
private async setupStreamingResponse(messageParams: any): Promise<void> {
|
||||||
if (!this.chatNoteId) {
|
if (!this.noteId) {
|
||||||
throw new Error("No session ID available");
|
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
|
// Store tool executions captured during streaming
|
||||||
const toolExecutionsCache: Array<{
|
const toolExecutionsCache: Array<{
|
||||||
@ -862,7 +937,7 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
return setupStreamingResponse(
|
return setupStreamingResponse(
|
||||||
this.chatNoteId,
|
this.noteId,
|
||||||
messageParams,
|
messageParams,
|
||||||
// Content update handler
|
// Content update handler
|
||||||
(content: string, isDone: boolean = false) => {
|
(content: string, isDone: boolean = false) => {
|
||||||
@ -898,7 +973,7 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
similarity?: number;
|
similarity?: number;
|
||||||
content?: string;
|
content?: string;
|
||||||
}>;
|
}>;
|
||||||
}>(`llm/chat/${this.chatNoteId}`)
|
}>(`llm/chat/${this.noteId}`)
|
||||||
.then((sessionData) => {
|
.then((sessionData) => {
|
||||||
console.log("Got updated session data:", 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
|
// DON'T save here - let the server handle saving the complete conversation
|
||||||
this.saveCurrentData()
|
// to avoid race conditions between client and server saves
|
||||||
.catch(err => console.error("Failed to save data after streaming completed:", err));
|
console.log("Updated metadata after streaming completion, server should save");
|
||||||
})
|
})
|
||||||
.catch(err => console.error("Error fetching session data after streaming:", err));
|
.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`);
|
console.log(`Cached tool execution for ${toolData.tool} to be saved later`);
|
||||||
|
|
||||||
// Save immediately after receiving a tool execution
|
// DON'T save immediately during streaming - let the server handle saving
|
||||||
// This ensures we don't lose tool execution data if streaming fails
|
// to avoid race conditions between client and server saves
|
||||||
this.saveCurrentData().catch(err => {
|
console.log(`Tool execution cached, will be saved by server`);
|
||||||
console.error("Failed to save tool execution data:", err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Complete handler
|
// Complete handler
|
||||||
@ -995,23 +1068,19 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
* Update the UI with streaming content
|
* Update the UI with streaming content
|
||||||
*/
|
*/
|
||||||
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
|
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
|
||||||
// Parse and handle thinking content if present
|
// Track if we have a streaming message in progress
|
||||||
if (!isDone) {
|
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
|
||||||
const thinkingContent = this.parseThinkingContent(assistantResponse);
|
|
||||||
if (thinkingContent) {
|
// Create a new message element or use the existing streaming one
|
||||||
this.updateThinkingText(thinkingContent);
|
let assistantMessageEl: HTMLElement;
|
||||||
// Don't display the raw response with think tags in the chat
|
|
||||||
return;
|
if (hasStreamingMessage) {
|
||||||
}
|
// Use the existing streaming message
|
||||||
}
|
assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!;
|
||||||
|
} else {
|
||||||
// Get the existing assistant message or create a new one
|
// Create a new message element
|
||||||
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
|
|
||||||
|
|
||||||
if (!assistantMessageEl) {
|
|
||||||
// If no assistant message yet, create one
|
|
||||||
assistantMessageEl = document.createElement('div');
|
assistantMessageEl = document.createElement('div');
|
||||||
assistantMessageEl.className = 'assistant-message message mb-3';
|
assistantMessageEl.className = 'assistant-message message mb-3 streaming';
|
||||||
this.noteContextChatMessages.appendChild(assistantMessageEl);
|
this.noteContextChatMessages.appendChild(assistantMessageEl);
|
||||||
|
|
||||||
// Add assistant profile icon
|
// Add assistant profile icon
|
||||||
@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
assistantMessageEl.appendChild(messageContent);
|
assistantMessageEl.appendChild(messageContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the response to remove thinking tags before displaying
|
// Update the content with the current response
|
||||||
const cleanedResponse = this.removeThinkingTags(assistantResponse);
|
|
||||||
|
|
||||||
// Update the content
|
|
||||||
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
|
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) {
|
if (isDone) {
|
||||||
|
// Remove the streaming class to mark this message as complete
|
||||||
|
assistantMessageEl.classList.remove('streaming');
|
||||||
|
|
||||||
|
// Apply syntax highlighting
|
||||||
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
||||||
|
|
||||||
// Hide the thinking display when response is complete
|
// Hide the thinking display when response is complete
|
||||||
this.hideThinkingDisplay();
|
this.hideThinkingDisplay();
|
||||||
|
|
||||||
// Update message in the data model for storage
|
// Always add a new message to the data model
|
||||||
// Find the last assistant message to update, or add a new one if none exists
|
// This ensures we preserve all distinct assistant messages
|
||||||
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
|
this.messages.push({
|
||||||
const lastAssistantMsgIndex = assistantMessages.length > 0 ?
|
role: 'assistant',
|
||||||
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
|
content: assistantResponse,
|
||||||
|
timestamp: new Date()
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save the updated message list
|
||||||
|
this.saveCurrentData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom
|
// Scroll to bottom
|
||||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
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
|
* Handle general errors in the send message flow
|
||||||
*/
|
*/
|
||||||
|
@ -11,7 +11,7 @@ export interface ChatResponse {
|
|||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
noteId?: string;
|
noteId: string; // The ID of the chat note
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolExecutionStep {
|
export interface ToolExecutionStep {
|
||||||
@ -33,8 +33,8 @@ export interface MessageData {
|
|||||||
|
|
||||||
export interface ChatData {
|
export interface ChatData {
|
||||||
messages: MessageData[];
|
messages: MessageData[];
|
||||||
chatNoteId: string | null;
|
noteId: string; // The ID of the chat note
|
||||||
noteId?: string | null;
|
chatNoteId?: string; // Deprecated - kept for backward compatibility, should equal noteId
|
||||||
toolSteps: ToolExecutionStep[];
|
toolSteps: ToolExecutionStep[];
|
||||||
sources?: Array<{
|
sources?: Array<{
|
||||||
noteId: string;
|
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="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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -94,6 +94,11 @@ export default class AiChatTypeWidget extends TypeWidget {
|
|||||||
this.llmChatPanel.clearNoteContextChatMessages();
|
this.llmChatPanel.clearNoteContextChatMessages();
|
||||||
this.llmChatPanel.setMessages([]);
|
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
|
// This will load saved data via the getData callback
|
||||||
await this.llmChatPanel.refresh();
|
await this.llmChatPanel.refresh();
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
@ -130,7 +135,7 @@ export default class AiChatTypeWidget extends TypeWidget {
|
|||||||
// Reset the chat panel UI
|
// Reset the chat panel UI
|
||||||
this.llmChatPanel.clearNoteContextChatMessages();
|
this.llmChatPanel.clearNoteContextChatMessages();
|
||||||
this.llmChatPanel.setMessages([]);
|
this.llmChatPanel.setMessages([]);
|
||||||
this.llmChatPanel.setChatNoteId(null);
|
this.llmChatPanel.setNoteId(this.note.noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the parent method to refresh
|
// 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
|
// Make sure the chat panel has the current note ID
|
||||||
if (this.note) {
|
if (this.note) {
|
||||||
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
|
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
|
||||||
|
this.llmChatPanel.setNoteId(this.note.noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initPromise = (async () => {
|
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
|
// Format the data properly - this is the canonical format of the data
|
||||||
const formattedData = {
|
const formattedData = {
|
||||||
messages: data.messages || [],
|
messages: data.messages || [],
|
||||||
chatNoteId: data.chatNoteId || this.note.noteId,
|
noteId: this.note.noteId, // Always use the note's own ID
|
||||||
toolSteps: data.toolSteps || [],
|
toolSteps: data.toolSteps || [],
|
||||||
sources: data.sources || [],
|
sources: data.sources || [],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
@ -224,11 +224,26 @@ export default class GeoMapTypeWidget extends TypeWidget {
|
|||||||
this.gpxLoaded = true;
|
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<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true);
|
||||||
const xmlResponse = await server.get<XMLDocument>(`notes/${note.noteId}/open`);
|
let stringResponse: string;
|
||||||
const stringResponse = new XMLSerializer().serializeToString(xmlResponse);
|
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);
|
track.addTo(this.geoMapWidget.map);
|
||||||
this.currentTrackData[note.noteId] = track;
|
this.currentTrackData[note.noteId] = track;
|
||||||
}
|
}
|
||||||
@ -276,13 +291,13 @@ export default class GeoMapTypeWidget extends TypeWidget {
|
|||||||
this.currentMarkerData[note.noteId] = marker;
|
this.currentMarkerData[note.noteId] = marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
#buildIcon(bxIconClass: string, colorClass: string, title: string) {
|
#buildIcon(bxIconClass: string, colorClass?: string, title?: string) {
|
||||||
return this.L.divIcon({
|
return this.L.divIcon({
|
||||||
html: /*html*/`\
|
html: /*html*/`\
|
||||||
<img class="icon" src="${markerIcon}" />
|
<img class="icon" src="${markerIcon}" />
|
||||||
<img class="icon-shadow" src="${markerIconShadow}" />
|
<img class="icon-shadow" src="${markerIconShadow}" />
|
||||||
<span class="bx ${bxIconClass} ${colorClass}"></span>
|
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
|
||||||
<span class="title-label">${title}</span>`,
|
<span class="title-label">${title ?? ""}</span>`,
|
||||||
iconSize: [25, 41],
|
iconSize: [25, 41],
|
||||||
iconAnchor: [12, 41]
|
iconAnchor: [12, 41]
|
||||||
});
|
});
|
||||||
|
@ -31,7 +31,6 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"forge": "./electron-forge/forge.config.cjs"
|
"forge": "./electron-forge/forge.config.cjs"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js"
|
"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": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^3.0.0",
|
||||||
"@types/yargs": "^17.0.33"
|
"@types/yargs": "^17.0.33"
|
||||||
},
|
},
|
||||||
"nx": {
|
"nx": {
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
"@types/ini": "4.1.1",
|
"@types/ini": "4.1.1",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsdom": "21.1.7",
|
"@types/jsdom": "21.1.7",
|
||||||
"@types/mime-types": "2.1.4",
|
"@types/mime-types": "3.0.0",
|
||||||
"@types/multer": "1.4.12",
|
"@types/multer": "1.4.12",
|
||||||
"@types/safe-compare": "1.1.2",
|
"@types/safe-compare": "1.1.2",
|
||||||
"@types/sanitize-html": "2.16.0",
|
"@types/sanitize-html": "2.16.0",
|
||||||
@ -85,10 +85,10 @@
|
|||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
"mime-types": "3.0.1",
|
"mime-types": "3.0.1",
|
||||||
"multer": "2.0.0",
|
"multer": "2.0.1",
|
||||||
"normalize-strings": "1.1.1",
|
"normalize-strings": "1.1.1",
|
||||||
"ollama": "0.5.16",
|
"ollama": "0.5.16",
|
||||||
"openai": "4.104.0",
|
"openai": "5.1.0",
|
||||||
"rand-token": "1.0.1",
|
"rand-token": "1.0.1",
|
||||||
"safe-compare": "1.1.4",
|
"safe-compare": "1.1.4",
|
||||||
"sanitize-filename": "1.6.3",
|
"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 { join } from "path";
|
||||||
import dayjs from "dayjs";
|
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 () => {
|
beforeAll(async () => {
|
||||||
// Initialize the translations manually to avoid any side effects.
|
// Initialize the translations manually to avoid any side effects.
|
||||||
const Backend = (await import("i18next-fs-backend")).default;
|
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>400</code> - Invalid format parameter</li>
|
||||||
<li><code>401</code> - Missing or invalid ETAPI token</li>
|
<li><code>401</code> - Missing or invalid ETAPI token</li>
|
||||||
<li><code>500</code> - Internal server error</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>
|
class="reference-link" href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<ul>
|
<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
|
Enter from note title switches focus to text editor. <kbd>Ctrl</kbd>+<kbd>.</kbd> switches
|
||||||
back from editor to tree pane.</li>
|
back from editor to tree pane.</li>
|
||||||
<li><kbd>Ctrl</kbd>+<kbd>.</kbd> - jump away from the editor to tree pane and
|
<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>
|
<tr>
|
||||||
<td>1</td>
|
<td>1</td>
|
||||||
<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"
|
<img style="aspect-ratio:1256/1044;" src="7_Geo Map_image.png" width="1256"
|
||||||
height="1044">
|
height="1044">
|
||||||
</figure>
|
</figure>
|
||||||
@ -29,7 +29,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>2</td>
|
<td>2</td>
|
||||||
<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"
|
<img style="aspect-ratio:1720/1396;" src="9_Geo Map_image.png" width="1720"
|
||||||
height="1396">
|
height="1396">
|
||||||
</figure>
|
</figure>
|
||||||
@ -170,7 +170,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>1</td>
|
<td>1</td>
|
||||||
<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"
|
<img style="aspect-ratio:732/918;" src="13_Geo Map_image.png" width="732"
|
||||||
height="918">
|
height="918">
|
||||||
</figure>
|
</figure>
|
||||||
@ -187,7 +187,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>2</td>
|
<td>2</td>
|
||||||
<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"
|
<img style="aspect-ratio:518/84;" src="4_Geo Map_image.png" width="518"
|
||||||
height="84">
|
height="84">
|
||||||
</figure>
|
</figure>
|
||||||
@ -197,7 +197,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>3</td>
|
<td>3</td>
|
||||||
<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"
|
<img style="aspect-ratio:1074/276;" src="12_Geo Map_image.png" width="1074"
|
||||||
height="276">
|
height="276">
|
||||||
</figure>
|
</figure>
|
||||||
@ -316,8 +316,15 @@ class="table" style="width:100%;">
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</figure>
|
</figure>
|
||||||
|
<aside class="admonition note">
|
||||||
<h2>Troubleshooting</h2>
|
<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%;">
|
<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"
|
<img style="aspect-ratio:678/499;" src="14_Geo Map_image.png" width="678"
|
||||||
height="499">
|
height="499">
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
||||||
<td><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd>
|
<td><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd>
|
||||||
</td>
|
</td>
|
||||||
@ -68,7 +68,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
||||||
<td><kbd>Ctrl</kbd> + <kbd>M</kbd>
|
<td><kbd>Ctrl</kbd> + <kbd>M</kbd>
|
||||||
</td>
|
</td>
|
||||||
@ -76,7 +76,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>Ctrl</kbd>+<kbd>↑</kbd> </td>
|
||||||
<td><kbd>⌘</kbd>+<kbd>↑</kbd> </td>
|
<td><kbd>⌘</kbd>+<kbd>↑</kbd> </td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -87,7 +87,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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><kbd>Ctrl</kbd>+<kbd>↑</kbd>
|
||||||
</td>
|
</td>
|
||||||
<td><kbd>⌘</kbd>+<kbd>↑</kbd>
|
<td><kbd>⌘</kbd>+<kbd>↑</kbd>
|
||||||
@ -102,6 +102,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h2>Common shortcuts</h2>
|
<h2>Common shortcuts</h2>
|
||||||
<aside class="admonition tip">
|
<aside class="admonition tip">
|
||||||
<p>This section of keyboard shortcuts presents a subset of the keyboard shortcuts
|
<p>This section of keyboard shortcuts presents a subset of the keyboard shortcuts
|
||||||
@ -260,6 +261,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3>Interacting with blocks</h3>
|
<h3>Interacting with blocks</h3>
|
||||||
<p>Blocks are images, tables, blockquotes, annotations.</p>
|
<p>Blocks are images, tables, blockquotes, annotations.</p>
|
||||||
<figure class="table">
|
<figure class="table">
|
||||||
@ -373,6 +375,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3>General UI shortcuts</h3>
|
<h3>General UI shortcuts</h3>
|
||||||
<figure class="table">
|
<figure class="table">
|
||||||
<table>
|
<table>
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"keyboard_actions": {
|
"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",
|
"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",
|
"search-in-subtree": "Search for notes in the active note's subtree",
|
||||||
"expand-subtree": "Expand subtree of current note",
|
"expand-subtree": "Expand subtree of current note",
|
||||||
"collapse-tree": "Collapses the complete note tree",
|
"collapse-tree": "Collapses the complete note tree",
|
||||||
"collapse-subtree": "Collapses subtree of current note",
|
"collapse-subtree": "Collapses subtree of current note",
|
||||||
"sort-child-notes": "Sort child notes",
|
"sort-child-notes": "Sort child notes",
|
||||||
"creating-and-moving-notes": "Creating and moving 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",
|
"create-note-into-inbox": "Create a note in the inbox (if defined) or day note",
|
||||||
"delete-note": "Delete note",
|
"delete-note": "Delete note",
|
||||||
"move-note-up": "Move note up",
|
"move-note-up": "Move note up",
|
||||||
@ -14,40 +20,44 @@
|
|||||||
"move-note-up-in-hierarchy": "Move note up in hierarchy",
|
"move-note-up-in-hierarchy": "Move note up in hierarchy",
|
||||||
"move-note-down-in-hierarchy": "Move note down 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-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",
|
"note-clipboard": "Note clipboard",
|
||||||
"copy-notes-to-clipboard": "Copy selected notes to the clipboard",
|
"copy-notes-to-clipboard": "Copy selected notes to the clipboard",
|
||||||
"paste-notes-from-clipboard": "Paste notes from the clipboard into active note",
|
"paste-notes-from-clipboard": "Paste notes from the clipboard into active note",
|
||||||
"cut-notes-to-clipboard": "Cut selected notes to the clipboard",
|
"cut-notes-to-clipboard": "Cut selected notes to the clipboard",
|
||||||
"select-all-notes-in-parent": "Select all notes from the current note level",
|
"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-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",
|
"duplicate-subtree": "Duplicate subtree",
|
||||||
"tabs-and-windows": "Tabs & Windows",
|
"tabs-and-windows": "Tabs & Windows",
|
||||||
"open-new-tab": "Opens new tab",
|
"open-new-tab": "Open new tab",
|
||||||
"close-active-tab": "Closes active tab",
|
"close-active-tab": "Close active tab",
|
||||||
"reopen-last-tab": "Reopens the last closed tab",
|
"reopen-last-tab": "Reopen the last closed tab",
|
||||||
"activate-next-tab": "Activates tab on the right",
|
"activate-next-tab": "Activate tab on the right",
|
||||||
"activate-previous-tab": "Activates tab on the left",
|
"activate-previous-tab": "Activate tab on the left",
|
||||||
"open-new-window": "Open new empty window",
|
"open-new-window": "Open new empty window",
|
||||||
"toggle-tray": "Shows/hides the application from the system tray",
|
"toggle-tray": "Show/hide the application from the system tray",
|
||||||
"first-tab": "Activates the first tab in the list",
|
"first-tab": "Activate the first tab in the list",
|
||||||
"second-tab": "Activates the second tab in the list",
|
"second-tab": "Activate the second tab in the list",
|
||||||
"third-tab": "Activates the third tab in the list",
|
"third-tab": "Activate the third tab in the list",
|
||||||
"fourth-tab": "Activates the fourth tab in the list",
|
"fourth-tab": "Activate the fourth tab in the list",
|
||||||
"fifth-tab": "Activates the fifth tab in the list",
|
"fifth-tab": "Activate the fifth tab in the list",
|
||||||
"sixth-tab": "Activates the sixth tab in the list",
|
"sixth-tab": "Activate the sixth tab in the list",
|
||||||
"seventh-tab": "Activates the seventh tab in the list",
|
"seventh-tab": "Activate the seventh tab in the list",
|
||||||
"eight-tab": "Activates the eighth tab in the list",
|
"eight-tab": "Activate the eighth tab in the list",
|
||||||
"ninth-tab": "Activates the ninth tab in the list",
|
"ninth-tab": "Activate the ninth tab in the list",
|
||||||
"last-tab": "Activates the last tab in the list",
|
"last-tab": "Activate the last tab in the list",
|
||||||
"dialogs": "Dialogs",
|
"dialogs": "Dialogs",
|
||||||
"show-note-source": "Shows Note Source dialog",
|
"show-note-source": "Show \"Note Source\" dialog",
|
||||||
"show-options": "Shows Options dialog",
|
"show-options": "Open \"Options\" page",
|
||||||
"show-revisions": "Shows Note Revisions dialog",
|
"show-revisions": "Show \"Note Revisions\" dialog",
|
||||||
"show-recent-changes": "Shows Recent Changes dialog",
|
"show-recent-changes": "Show \"Recent Changes\" dialog",
|
||||||
"show-sql-console": "Shows SQL Console dialog",
|
"show-sql-console": "Open \"SQL Console\" page",
|
||||||
"show-backend-log": "Shows Backend Log dialog",
|
"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",
|
"text-note-operations": "Text note operations",
|
||||||
"add-link-to-text": "Open dialog to add link to the text",
|
"add-link-to-text": "Open dialog to add link to the text",
|
||||||
"follow-link-under-cursor": "Follow link within which the caret is placed",
|
"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",
|
"open-note-externally": "Open note as a file with default application",
|
||||||
"render-active-note": "Render (re-render) active note",
|
"render-active-note": "Render (re-render) active note",
|
||||||
"run-active-note": "Run active JavaScript (frontend/backend) code 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",
|
"unhoist": "Unhoist from anywhere",
|
||||||
"reload-frontend-app": "Reload frontend App",
|
"reload-frontend-app": "Reload frontend",
|
||||||
"open-dev-tools": "Open dev tools",
|
"open-dev-tools": "Open developer tools",
|
||||||
|
"find-in-text": "Toggle search panel",
|
||||||
"toggle-left-note-tree-panel": "Toggle left (note tree) panel",
|
"toggle-left-note-tree-panel": "Toggle left (note tree) panel",
|
||||||
"toggle-full-screen": "Toggle full screen",
|
"toggle-full-screen": "Toggle full screen",
|
||||||
"zoom-out": "Zoom Out",
|
"zoom-out": "Zoom Out",
|
||||||
@ -88,11 +99,9 @@
|
|||||||
"reset-zoom-level": "Reset zoom level",
|
"reset-zoom-level": "Reset zoom level",
|
||||||
"copy-without-formatting": "Copy selected text without formatting",
|
"copy-without-formatting": "Copy selected text without formatting",
|
||||||
"force-save-revision": "Force creating / saving new note revision of the active note",
|
"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-book-properties": "Toggle Book Properties",
|
||||||
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
|
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
|
||||||
"export-as-pdf": "Exports the current note as a PDF",
|
"export-as-pdf": "Export the current note as a PDF",
|
||||||
"show-cheatsheet": "Shows a modal with common keyboard operations",
|
|
||||||
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
|
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
|
@ -118,6 +118,15 @@
|
|||||||
<% if (themeCssUrl) { %>
|
<% if (themeCssUrl) { %>
|
||||||
<link href="<%= themeCssUrl %>" rel="stylesheet">
|
<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/style.css" rel="stylesheet">
|
||||||
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">
|
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
<link rel="shortcut icon" href="../favicon.ico">
|
<link rel="shortcut icon" href="../favicon.ico">
|
||||||
<% } %>
|
<% } %>
|
||||||
<script src="<%= appPath %>/share.js" type="module"></script>
|
<script src="<%= appPath %>/share.js" type="module"></script>
|
||||||
|
<link href="<%= assetPath %>/src/share.css" rel="stylesheet">
|
||||||
<% if (!note.isLabelTruthy("shareOmitDefaultCss")) { %>
|
<% if (!note.isLabelTruthy("shareOmitDefaultCss")) { %>
|
||||||
<link href="<%= assetPath %>/stylesheets/share.css" rel="stylesheet">
|
<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 BBlob from "./entities/bblob.js";
|
||||||
import BRecentNote from "./entities/brecent_note.js";
|
import BRecentNote from "./entities/brecent_note.js";
|
||||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||||
|
import type BNoteEmbedding from "./entities/bnote_embedding.js";
|
||||||
|
|
||||||
interface AttachmentOpts {
|
interface AttachmentOpts {
|
||||||
includeContentLength?: boolean;
|
includeContentLength?: boolean;
|
||||||
@ -32,6 +33,7 @@ export default class Becca {
|
|||||||
attributeIndex!: Record<string, BAttribute[]>;
|
attributeIndex!: Record<string, BAttribute[]>;
|
||||||
options!: Record<string, BOption>;
|
options!: Record<string, BOption>;
|
||||||
etapiTokens!: Record<string, BEtapiToken>;
|
etapiTokens!: Record<string, BEtapiToken>;
|
||||||
|
noteEmbeddings!: Record<string, BNoteEmbedding>;
|
||||||
|
|
||||||
allNoteSetCache: NoteSet | null;
|
allNoteSetCache: NoteSet | null;
|
||||||
|
|
||||||
@ -48,6 +50,7 @@ export default class Becca {
|
|||||||
this.attributeIndex = {};
|
this.attributeIndex = {};
|
||||||
this.options = {};
|
this.options = {};
|
||||||
this.etapiTokens = {};
|
this.etapiTokens = {};
|
||||||
|
this.noteEmbeddings = {};
|
||||||
|
|
||||||
this.dirtyNoteSetCache();
|
this.dirtyNoteSetCache();
|
||||||
|
|
||||||
|
@ -9,9 +9,10 @@ import BBranch from "./entities/bbranch.js";
|
|||||||
import BAttribute from "./entities/battribute.js";
|
import BAttribute from "./entities/battribute.js";
|
||||||
import BOption from "./entities/boption.js";
|
import BOption from "./entities/boption.js";
|
||||||
import BEtapiToken from "./entities/betapi_token.js";
|
import BEtapiToken from "./entities/betapi_token.js";
|
||||||
|
import BNoteEmbedding from "./entities/bnote_embedding.js";
|
||||||
import cls from "../services/cls.js";
|
import cls from "../services/cls.js";
|
||||||
import entityConstructor from "../becca/entity_constructor.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 type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||||
import ws from "../services/ws.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`)) {
|
for (const row of sql.getRows<EtapiTokenRow>(/*sql*/`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
|
||||||
new BEtapiToken(row);
|
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) {
|
for (const noteId in becca.notes) {
|
||||||
@ -85,7 +90,7 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({ entity
|
|||||||
return;
|
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 EntityClass = entityConstructor.getEntityFromEntityName(entityName);
|
||||||
const primaryKeyName = EntityClass.primaryKeyName;
|
const primaryKeyName = EntityClass.primaryKeyName;
|
||||||
|
|
||||||
@ -143,6 +148,8 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENT
|
|||||||
attributeDeleted(entityId);
|
attributeDeleted(entityId);
|
||||||
} else if (entityName === "etapi_tokens") {
|
} else if (entityName === "etapi_tokens") {
|
||||||
etapiTokenDeleted(entityId);
|
etapiTokenDeleted(entityId);
|
||||||
|
} else if (entityName === "note_embeddings") {
|
||||||
|
noteEmbeddingDeleted(entityId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -278,6 +285,10 @@ function etapiTokenDeleted(etapiTokenId: string) {
|
|||||||
delete becca.etapiTokens[etapiTokenId];
|
delete becca.etapiTokens[etapiTokenId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noteEmbeddingDeleted(embedId: string) {
|
||||||
|
delete becca.noteEmbeddings[embedId];
|
||||||
|
}
|
||||||
|
|
||||||
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
|
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
|
||||||
try {
|
try {
|
||||||
becca.decryptProtectedNotes();
|
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 {
|
updateFromRow(row: NoteEmbeddingRow): void {
|
||||||
this.embedId = row.embedId;
|
this.embedId = row.embedId;
|
||||||
this.noteId = row.noteId;
|
this.noteId = row.noteId;
|
||||||
@ -44,6 +50,10 @@ class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
|
|||||||
this.dateModified = row.dateModified;
|
this.dateModified = row.dateModified;
|
||||||
this.utcDateCreated = row.utcDateCreated;
|
this.utcDateCreated = row.utcDateCreated;
|
||||||
this.utcDateModified = row.utcDateModified;
|
this.utcDateModified = row.utcDateModified;
|
||||||
|
|
||||||
|
if (this.embedId) {
|
||||||
|
this.becca.noteEmbeddings[this.embedId] = this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override beforeSaving() {
|
override beforeSaving() {
|
||||||
|
@ -4,10 +4,10 @@ import eu from "./etapi_utils.js";
|
|||||||
import backupService from "../services/backup.js";
|
import backupService from "../services/backup.js";
|
||||||
|
|
||||||
function register(router: Router) {
|
function register(router: Router) {
|
||||||
eu.route(router, "put", "/etapi/backup/:backupName", async (req, res, next) => {
|
eu.route(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
|
||||||
await backupService.backupNow(req.params.backupName);
|
backupService.backupNow(req.params.backupName)
|
||||||
|
.then(() => res.sendStatus(204))
|
||||||
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 config from "../services/config.js";
|
||||||
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
||||||
import type { ValidatorMap } from "./etapi-interface.js";
|
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";
|
const GENERIC_CODE = "GENERIC";
|
||||||
|
|
||||||
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
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));
|
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));
|
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) {
|
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;
|
const { date } = req.params;
|
||||||
|
|
||||||
if (!isValidDate(date)) {
|
if (!isValidDate(date)) {
|
||||||
throw getDateInvalidError(date);
|
throw getDateInvalidError(date);
|
||||||
}
|
}
|
||||||
const note = await specialNotesService.getInboxNote(date);
|
const note = specialNotesService.getInboxNote(date);
|
||||||
res.json(mappers.mapNoteToPojo(note));
|
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;
|
const { date } = req.params;
|
||||||
|
|
||||||
if (!isValidDate(date)) {
|
if (!isValidDate(date)) {
|
||||||
throw getDateInvalidError(date);
|
throw getDateInvalidError(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await dateNotesService.getDayNote(date);
|
const note = dateNotesService.getDayNote(date);
|
||||||
res.json(mappers.mapNoteToPojo(note));
|
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;
|
const { date } = req.params;
|
||||||
|
|
||||||
if (!isValidDate(date)) {
|
if (!isValidDate(date)) {
|
||||||
throw getDateInvalidError(date);
|
throw getDateInvalidError(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await dateNotesService.getWeekFirstDayNote(date);
|
const note = dateNotesService.getWeekFirstDayNote(date);
|
||||||
res.json(mappers.mapNoteToPojo(note));
|
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;
|
const { week } = req.params;
|
||||||
|
|
||||||
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
|
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
|
||||||
throw getWeekInvalidError(week);
|
throw getWeekInvalidError(week);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await dateNotesService.getWeekNote(week);
|
const note = dateNotesService.getWeekNote(week);
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
throw getWeekNotFoundError(week);
|
throw getWeekNotFoundError(week);
|
||||||
@ -63,14 +63,14 @@ function register(router: Router) {
|
|||||||
res.json(mappers.mapNoteToPojo(note));
|
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;
|
const { month } = req.params;
|
||||||
|
|
||||||
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
|
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
|
||||||
throw getMonthInvalidError(month);
|
throw getMonthInvalidError(month);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await dateNotesService.getMonthNote(month);
|
const note = dateNotesService.getMonthNote(month);
|
||||||
res.json(mappers.mapNoteToPojo(note));
|
res.json(mappers.mapNoteToPojo(note));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import options from "../../services/options.js";
|
|||||||
// Import the index service for knowledge base management
|
// Import the index service for knowledge base management
|
||||||
import indexService from "../../services/llm/index_service.js";
|
import indexService from "../../services/llm/index_service.js";
|
||||||
import restChatService from "../../services/llm/rest_chat_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';
|
import chatStorageService from '../../services/llm/chat_storage_service.js';
|
||||||
|
|
||||||
// Define basic interfaces
|
// Define basic interfaces
|
||||||
@ -190,23 +189,26 @@ async function getSession(req: Request, res: Response) {
|
|||||||
* tags: ["llm"]
|
* tags: ["llm"]
|
||||||
*/
|
*/
|
||||||
async function updateSession(req: Request, res: Response) {
|
async function updateSession(req: Request, res: Response) {
|
||||||
// Get the chat using ChatService
|
// Get the chat using chatStorageService directly
|
||||||
const chatNoteId = req.params.chatNoteId;
|
const chatNoteId = req.params.chatNoteId;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the chat
|
// 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
|
// Update title if provided
|
||||||
if (updates.title) {
|
if (updates.title) {
|
||||||
await chatStorageService.updateChat(chatNoteId, session.messages, updates.title);
|
await chatStorageService.updateChat(chatNoteId, chat.messages, updates.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the updated chat
|
// Return the updated chat
|
||||||
return {
|
return {
|
||||||
id: chatNoteId,
|
id: chatNoteId,
|
||||||
title: updates.title || session.title,
|
title: updates.title || chat.title,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -248,18 +250,18 @@ async function updateSession(req: Request, res: Response) {
|
|||||||
* tags: ["llm"]
|
* tags: ["llm"]
|
||||||
*/
|
*/
|
||||||
async function listSessions(req: Request, res: Response) {
|
async function listSessions(req: Request, res: Response) {
|
||||||
// Get all sessions using ChatService
|
// Get all sessions using chatStorageService directly
|
||||||
try {
|
try {
|
||||||
const sessions = await chatService.getAllSessions();
|
const chats = await chatStorageService.getAllChats();
|
||||||
|
|
||||||
// Format the response
|
// Format the response
|
||||||
return {
|
return {
|
||||||
sessions: sessions.map(session => ({
|
sessions: chats.map(chat => ({
|
||||||
id: session.id,
|
id: chat.id,
|
||||||
title: session.title,
|
title: chat.title,
|
||||||
createdAt: new Date(), // Since we don't have this in chat sessions
|
createdAt: chat.createdAt || new Date(),
|
||||||
lastActive: new Date(), // Since we don't have this in chat sessions
|
lastActive: chat.updatedAt || new Date(),
|
||||||
messageCount: session.messages.length
|
messageCount: chat.messages.length
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -811,17 +813,38 @@ async function streamMessage(req: Request, res: Response) {
|
|||||||
const { content, useAdvancedContext, showThinking, mentions } = req.body;
|
const { content, useAdvancedContext, showThinking, mentions } = req.body;
|
||||||
|
|
||||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
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
|
// Get or create chat directly from storage (simplified approach)
|
||||||
const session = restChatService.getSessions().get(chatNoteId);
|
let chat = await chatStorageService.getChat(chatNoteId);
|
||||||
if (!session) {
|
if (!chat) {
|
||||||
throw new Error('Chat not found');
|
// 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
|
// Add the user message to the chat immediately
|
||||||
session.lastActive = new Date();
|
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
|
// Process mentions if provided
|
||||||
let enhancedContent = content;
|
let enhancedContent = content;
|
||||||
@ -830,7 +853,6 @@ async function streamMessage(req: Request, res: Response) {
|
|||||||
|
|
||||||
// Import note service to get note content
|
// Import note service to get note content
|
||||||
const becca = (await import('../../becca/becca.js')).default;
|
const becca = (await import('../../becca/becca.js')).default;
|
||||||
|
|
||||||
const mentionContexts: string[] = [];
|
const mentionContexts: string[] = [];
|
||||||
|
|
||||||
for (const mention of mentions) {
|
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)
|
// Import the WebSocket service to send immediate feedback
|
||||||
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
|
|
||||||
const wsService = (await import('../../services/ws.js')).default;
|
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({
|
wsService.sendMessageToAllClients({
|
||||||
type: 'llm-stream',
|
type: 'llm-stream',
|
||||||
chatNoteId: chatNoteId,
|
chatNoteId: chatNoteId,
|
||||||
thinking: 'Initializing streaming LLM response...'
|
thinking: showThinking ? 'Initializing streaming LLM response...' : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Let the client know streaming has started via HTTP response
|
// Instead of trying to reimplement the streaming logic ourselves,
|
||||||
return {
|
// delegate to restChatService but set up the correct protocol:
|
||||||
success: true,
|
// 1. We've already sent a success response to the initial POST
|
||||||
message: 'Streaming started',
|
// 2. Now we'll have restChatService process the actual streaming through WebSocket
|
||||||
chatNoteId: chatNoteId
|
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) {
|
} catch (error: any) {
|
||||||
log.error(`Error starting message stream: ${error.message}`);
|
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
|
// Filter and categorize models
|
||||||
const allModels = response.data || [];
|
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
|
const chatModels = allModels
|
||||||
.filter((model) =>
|
.filter((model) =>
|
||||||
// Include GPT models for chat
|
// Exclude models that are explicitly for embeddings
|
||||||
model.id.includes('gpt') ||
|
!model.id.includes('embedding') &&
|
||||||
// Include Claude models via Azure OpenAI
|
!model.id.includes('embed')
|
||||||
model.id.includes('claude')
|
|
||||||
)
|
)
|
||||||
.map((model) => ({
|
.map((model) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
|
@ -799,6 +799,7 @@ class ConsistencyChecks {
|
|||||||
this.runEntityChangeChecks("attributes", "attributeId");
|
this.runEntityChangeChecks("attributes", "attributeId");
|
||||||
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
|
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
|
||||||
this.runEntityChangeChecks("options", "name");
|
this.runEntityChangeChecks("options", "name");
|
||||||
|
this.runEntityChangeChecks("note_embeddings", "embedId");
|
||||||
}
|
}
|
||||||
|
|
||||||
findWronglyNamedAttributes() {
|
findWronglyNamedAttributes() {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
import sanitizeUrl from "@braintree/sanitize-url";
|
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||||
import optionService from "./options.js";
|
import optionService from "./options.js";
|
||||||
|
|
||||||
// Be consistent with `ALLOWED_PROTOCOLS` in `src\public\app\services\link.js`
|
// Be consistent with `ALLOWED_PROTOCOLS` in `src\public\app\services\link.js`
|
||||||
@ -190,6 +190,6 @@ function sanitize(dirtyHtml: string) {
|
|||||||
export default {
|
export default {
|
||||||
sanitize,
|
sanitize,
|
||||||
sanitizeUrl: (url: string) => {
|
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