Merge branch 'develop' of https://github.com/TriliumNext/Notes into develop

This commit is contained in:
Elian Doran 2025-06-06 23:26:34 +03:00
commit acaaf1f1f4
No known key found for this signature in database
246 changed files with 16097 additions and 4724 deletions

View File

@ -111,6 +111,9 @@ jobs:
- dockerfile: Dockerfile
platform: linux/arm/v7
image: ubuntu-24.04-arm
- dockerfile: Dockerfile
platform: linux/arm/v8
image: ubuntu-24.04-arm
runs-on: ${{ matrix.image }}
needs:
- test_docker

View File

@ -37,7 +37,7 @@ jobs:
shell: bash
forge_platform: darwin
- name: linux
image: ubuntu-latest
image: ubuntu-22.04
shell: bash
forge_platform: linux
- name: windows
@ -102,7 +102,7 @@ jobs:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}

View File

@ -73,7 +73,7 @@ jobs:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}

2
.gitignore vendored
View File

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

View File

@ -36,6 +36,7 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e # Fail on any command error
VERSION=`jq -r ".version" package.json`
SERIES=${VERSION:0:4}-latest
sudo docker build -t triliumnext/notes:$VERSION --network host -t triliumnext/notes:$SERIES .
if [[ $VERSION != *"beta"* ]]; then
sudo docker tag triliumnext/notes:$VERSION triliumnext/notes:latest
fi

View File

@ -36,12 +36,12 @@
},
"devDependencies": {
"@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "4.4.0",
"@stylistic/eslint-plugin": "4.4.1",
"@types/express": "5.0.1",
"@types/node": "22.15.29",
"@types/node": "22.15.30",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.1.4",
"eslint": "9.27.0",
"@vitest/coverage-v8": "3.2.2",
"eslint": "9.28.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",

View File

@ -1,12 +0,0 @@
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "1234"
}
> {%
client.assert(response.status === 201);
client.global.set("authToken", response.body.authToken);
%}

View File

@ -1,43 +0,0 @@
### Test regular API metrics endpoint (requires session authentication)
### Get metrics from regular API (default Prometheus format)
GET {{triliumHost}}/api/metrics
> {%
client.test("API metrics endpoint returns Prometheus format by default", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
client.assert(response.body.includes("# HELP"), "Should contain HELP comments");
client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments");
});
%}
### Get metrics in JSON format
GET {{triliumHost}}/api/metrics?format=json
> {%
client.test("API metrics endpoint returns JSON when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json");
client.assert(response.body.version, "Version info not present");
client.assert(response.body.database, "Database info not present");
client.assert(response.body.timestamp, "Timestamp not present");
client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number");
client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number");
client.assert(response.body.noteTypes, "Note types breakdown not present");
client.assert(response.body.attachmentTypes, "Attachment types breakdown not present");
client.assert(response.body.statistics, "Statistics not present");
});
%}
### Test invalid format parameter
GET {{triliumHost}}/api/metrics?format=xml
> {%
client.test("Invalid format parameter returns error", function() {
client.assert(response.status === 500, "Response status should be 500");
client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats");
});
%}

View File

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

View File

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

View File

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

View File

@ -1,158 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "forcedId{{$randomInt}}",
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.note.noteId.startsWith("forcedId"));
client.assert(response.body.note.title == "Hello");
client.assert(response.body.note.dateCreated == "2023-08-21 23:38:51.123+0200");
client.assert(response.body.note.utcDateCreated == "2023-08-21 23:38:51.123Z");
client.assert(response.body.branch.parentNoteId == "root");
client.log(`Created note ` + response.body.note.noteId + ` and branch ` + response.body.branch.branchId);
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.parentNoteId == "_hidden");
client.global.set("clonedBranchId", response.body.branchId);
client.log(`Created cloned branch ` + response.body.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("createdNoteId"));
client.assert(response.body.title == "Hello");
// order is not defined and may fail in the future
client.assert(response.body.parentBranchIds[0] == client.global.get("clonedBranchId"))
client.assert(response.body.parentBranchIds[1] == client.global.get("createdBranchId"));
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body == "Hi there!");
%}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.branchId == client.global.get("createdBranchId"));
client.assert(response.body.parentNoteId == "root");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.branchId == client.global.get("clonedBranchId"));
client.assert(response.body.parentNoteId == "_hidden");
%}
###
POST {{triliumHost}}/etapi/attributes
Content-Type: application/json
Authorization: {{authToken}}
{
"attributeId": "forcedAttributeId{{$randomInt}}",
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {%
client.assert(response.status === 201);
client.assert(response.body.attributeId.startsWith("forcedAttributeId"));
client.global.set("createdAttributeId", response.body.attributeId);
%}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.attributeId == client.global.get("createdAttributeId"));
%}
###
POST {{triliumHost}}/etapi/attachments
Content-Type: application/json
Authorization: {{authToken}}
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "plain/text",
"title": "my attachment",
"content": "my text"
}
> {%
client.assert(response.status === 201);
client.global.set("createdAttachmentId", response.body.attachmentId);
%}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.attachmentId == client.global.get("createdAttachmentId"));
client.assert(response.body.role == "file");
client.assert(response.body.mime == "plain/text");
client.assert(response.body.title == "my attachment");
%}

View File

@ -1,52 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTACHMENT_NOT_FOUND");
%}

View File

@ -1,52 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND");
%}

View File

@ -1,87 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}

View File

@ -1,126 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
### Clone to another location
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"parentNoteId": "_hidden"
}
> {% client.global.set("clonedBranchId", response.body.branchId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
### repeat the DELETE request to test the idempotency
DELETE {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {% client.assert(response.status === 204, "Response status is not 204"); %}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/branches/{{clonedBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code == "BRANCH_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "NOTE_NOT_FOUND");
%}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 404, "Response status is not 404");
client.assert(response.body.code === "ATTRIBUTE_NOT_FOUND");
%}

View File

@ -1,37 +0,0 @@
GET {{triliumHost}}/etapi/notes/root/export
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=html
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=markdown
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.headers.valueOf("Content-Type") == "application/zip");
%}
###
GET {{triliumHost}}/etapi/notes/root/export?format=wrong
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "UNRECOGNIZED_EXPORT_FORMAT");
%}

View File

@ -1,72 +0,0 @@
GET {{triliumHost}}/etapi/inbox/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "DATE_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-01-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "DATE_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/months/2022-01
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/months/2022-1
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "MONTH_INVALID");
%}
###
GET {{triliumHost}}/etapi/calendar/years/2022
Authorization: {{authToken}}
> {% client.assert(response.status === 200); %}
###
GET {{triliumHost}}/etapi/calendar/years/202
Authorization: {{authToken}}
> {%
client.assert(response.status === 400);
client.assert(response.body.code === "YEAR_INVALID");
%}

View File

@ -1,116 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello parent",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("parentNoteId", response.body.note.noteId);
client.global.set("parentBranchId", response.body.branch.branchId);
%}
### Create inheritable parent attribute
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{parentNoteId}}",
"type": "label",
"name": "mylabel",
"value": "",
"isInheritable": true,
"position": 10
}
> {%
client.assert(response.status === 201);
client.global.set("parentAttributeId", response.body.attributeId);
%}
### Create child note under root
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello child",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("childNoteId", response.body.note.noteId);
client.global.set("childBranchId", response.body.branch.branchId);
%}
### Create child attribute
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{childNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": false,
"position": 10
}
> {%
client.assert(response.status === 201);
client.global.set("childAttributeId", response.body.attributeId);
%}
### Clone child to parent
POST {{triliumHost}}/etapi/branches
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{childNoteId}}",
"parentNoteId": "{{parentNoteId}}"
}
> {%
client.assert(response.status === 201);
client.assert(response.body.parentNoteId == client.global.get("parentNoteId"));
%}
###
GET {{triliumHost}}/etapi/notes/{{childNoteId}}
Authorization: {{authToken}}
> {%
function hasAttribute(list, attributeId) {
for (let i = 0; i < list.length; i++) {
if (list[i]["attributeId"] === attributeId) {
return true;
}
}
return false;
}
client.log(JSON.stringify(response.body.attributes));
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("childNoteId"));
client.assert(response.body.attributes.length == 2);
client.assert(hasAttribute(response.body.attributes, client.global.get("parentAttributeId")));
client.assert(hasAttribute(response.body.attributes, client.global.get("childAttributeId")));
%}

View File

@ -1,61 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "GetInheritedAttributes Test Note",
"type": "text",
"content": "Hi there!"
}
> {%
client.assert(response.status === 201);
client.global.set("parentNoteId", response.body.note.noteId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{parentNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "{{parentNoteId}}",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.noteId == client.global.get("createdNoteId"));
client.assert(response.body.attributes.length == 1);
client.assert(response.body.attributes[0].attributeId == client.global.get("createdAttributeId"));
%}

View File

@ -1,25 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body === "Hi there!");
%}

View File

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

View File

@ -1,12 +0,0 @@
POST {{triliumHost}}/etapi/notes/root/import
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../db/demo.zip
> {%
client.assert(response.status === 201);
client.assert(response.body.note.title == "Trilium Demo");
client.assert(response.body.branch.parentNoteId == "root");
%}

View File

@ -1,34 +0,0 @@
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "1234"
}
> {%
client.assert(response.status === 201);
client.global.set("testAuthToken", response.body.authToken);
%}
###
GET {{triliumHost}}/etapi/notes/root
Authorization: {{testAuthToken}}
> {% client.assert(response.status === 200); %}
###
POST {{triliumHost}}/etapi/auth/logout
Authorization: {{testAuthToken}}
Content-Type: application/json
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/notes/root
Authorization: {{testAuthToken}}
> {% client.assert(response.status === 401); %}

View File

@ -1,82 +0,0 @@
### Test ETAPI metrics endpoint
# First login to get a token
POST {{triliumHost}}/etapi/auth/login
Content-Type: application/json
{
"password": "{{password}}"
}
> {%
client.test("Login successful", function() {
client.assert(response.status === 201, "Response status is not 201");
client.assert(response.body.authToken, "Auth token not present");
client.global.set("authToken", response.body.authToken);
});
%}
### Get metrics with authentication (default Prometheus format)
GET {{triliumHost}}/etapi/metrics
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns Prometheus format by default", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
client.assert(response.body.includes("# HELP"), "Should contain HELP comments");
client.assert(response.body.includes("# TYPE"), "Should contain TYPE comments");
});
%}
### Get metrics in JSON format
GET {{triliumHost}}/etapi/metrics?format=json
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns JSON when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("application/json"), "Content-Type should be application/json");
client.assert(response.body.version, "Version info not present");
client.assert(response.body.database, "Database info not present");
client.assert(response.body.timestamp, "Timestamp not present");
client.assert(typeof response.body.database.totalNotes === 'number', "Total notes should be a number");
client.assert(typeof response.body.database.activeNotes === 'number', "Active notes should be a number");
});
%}
### Get metrics in Prometheus format explicitly
GET {{triliumHost}}/etapi/metrics?format=prometheus
Authorization: {{authToken}}
> {%
client.test("Metrics endpoint returns Prometheus format when requested", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers["content-type"].includes("text/plain"), "Content-Type should be text/plain");
client.assert(response.body.includes("trilium_info"), "Should contain trilium_info metric");
client.assert(response.body.includes("trilium_notes_total"), "Should contain trilium_notes_total metric");
});
%}
### Test invalid format parameter
GET {{triliumHost}}/etapi/metrics?format=xml
Authorization: {{authToken}}
> {%
client.test("Invalid format parameter returns error", function() {
client.assert(response.status === 400, "Response status should be 400");
client.assert(response.body.code === "INVALID_FORMAT", "Error code should be INVALID_FORMAT");
client.assert(response.body.message.includes("prometheus"), "Error message should mention supported formats");
});
%}
### Test without authentication (should fail)
GET {{triliumHost}}/etapi/metrics
> {%
client.test("Metrics endpoint requires authentication", function() {
client.assert(response.status === 401, "Response status should be 401");
});
%}

View File

@ -1,109 +0,0 @@
GET {{triliumHost}}/etapi/notes?search=aaa
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/notes/root
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/notes/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/notes/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/branches/root
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/branches/root
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/branches/root
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
PATCH {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
DELETE {{triliumHost}}/etapi/attributes/000
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/inbox/2022-02-22
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/days/2022-02-22
Authorization: fakeauth
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/weeks/2022-02-22
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/months/2022-02
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/calendar/years/2022
> {% client.assert(response.status === 401); %}
###
POST {{triliumHost}}/etapi/create-note
> {% client.assert(response.status === 401); %}
###
GET {{triliumHost}}/etapi/app-info
> {% client.assert(response.status === 401); %}
### Fake URL will get a 404 even without token
GET {{triliumHost}}/etapi/zzzzzz
> {% client.assert(response.status === 404); %}

View File

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

View File

@ -1,79 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": "CHANGED",
"position": 999
}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
> {%
client.assert(response.body.title === "CHANGED");
client.assert(response.body.position === 999);
%}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": null
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View File

@ -1,80 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {%
client.global.set("createdNoteId", response.body.note.noteId);
client.global.set("createdBranchId", response.body.branch.branchId);
%}
###
POST {{triliumHost}}/etapi/attributes
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "{{createdNoteId}}",
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
}
> {% client.global.set("createdAttributeId", response.body.attributeId); %}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"value": "CHANGED"
}
###
GET {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
> {%
client.assert(response.body.value === "CHANGED");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"noteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/attributes/{{createdAttributeId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"value": null
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View File

@ -1,66 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"type": "text",
"title": "Hello",
"content": ""
}
> {% client.global.set("createdBranchId", response.body.branch.branchId); %}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"prefix": "pref",
"notePosition": 666,
"isExpanded": true
}
###
GET {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.prefix === 'pref');
client.assert(response.body.notePosition === 666);
client.assert(response.body.isExpanded === true);
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root"
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/branches/{{createdBranchId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"prefix": 123
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View File

@ -1,83 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "application/json",
"content": "{}"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Hello');
client.assert(response.body.type === 'code');
client.assert(response.body.mime === 'application/json');
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": "Wassup",
"type": "html",
"mime": "text/html",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.title === 'Wassup');
client.assert(response.body.type === 'html');
client.assert(response.body.mime === 'text/html');
client.assert(response.body.dateCreated == "2023-08-21 23:38:51.123+0200");
client.assert(response.body.utcDateCreated == "2023-08-21 23:38:51.123Z");
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"isProtected": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_NOT_ALLOWED");
%}
###
PATCH {{triliumHost}}/etapi/notes/{{createdNoteId}}
Authorization: {{authToken}}
Content-Type: application/json
{
"title": true
}
> {%
client.assert(response.status === 400);
client.assert(response.body.code == "PROPERTY_VALIDATION_ERROR");
%}

View File

@ -1,23 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "text/plain",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/notes/{{createdNoteId}}/revision
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}

View File

@ -1,39 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../images/icon-color.png
> {% client.assert(response.status === 204); %}

View File

@ -1,45 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
POST {{triliumHost}}/etapi/attachments
Authorization: {{authToken}}
Content-Type: application/json
{
"ownerId": "{{createdNoteId}}",
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
}
> {% client.global.set("createdAttachmentId", response.body.attachmentId); %}
###
PUT {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/attachments/{{createdAttachmentId}}/content
Authorization: {{authToken}}
> {% client.assert(response.body === "Changed content"); %}

View File

@ -1,25 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "image",
"mime": "image/png",
"content": ""
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
< ../images/icon-color.png
> {% client.assert(response.status === 204); %}

View File

@ -1,30 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "text/plain",
"content": "Hi there!"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
Content-Type: text/plain
Changed content
> {% client.assert(response.status === 204); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {% client.assert(response.body === "Changed content"); %}

View File

@ -1,39 +0,0 @@
POST {{triliumHost}}/etapi/create-note
Authorization: {{authToken}}
Content-Type: application/json
{
"parentNoteId": "root",
"title": "title",
"type": "text",
"content": "{{$uuid}}"
}
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
###
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
Authorization: {{authToken}}
> {% client.global.set("content", response.body); %}
###
GET {{triliumHost}}/etapi/notes?search={{content}}&debug=true
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.results.length === 1);
%}
### Same but with fast search which doesn't look in the content so 0 notes should be found
GET {{triliumHost}}/etapi/notes?search={{content}}&fastSearch=true
Authorization: {{authToken}}
> {%
client.assert(response.status === 200);
client.assert(response.body.results.length === 0);
%}

View File

@ -10,7 +10,7 @@
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.27.0",
"@eslint/js": "9.28.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
@ -64,9 +64,9 @@
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"@types/react-dom": "19.1.6",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "17.5.6",
"happy-dom": "17.6.3",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.0.0"
},

View File

@ -269,14 +269,32 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {
this.resetViewScope();
}
const viewScope = this.viewScope!;
if (viewScope.isReadOnly === undefined) {
const blob = await this.note.getBlob();
if (!blob) {
viewScope.isReadOnly = false;
return false;
}
const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode");
const sizeLimit = this.note.type === "text"
? options.getInt("autoReadonlySizeText")
: options.getInt("autoReadonlySizeCode");
return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled");
viewScope.isReadOnly = Boolean(sizeLimit &&
blob.contentLength > sizeLimit &&
!this.note.isLabelTruthy("autoReadOnlyDisabled"));
}
// Return the cached decision, which won't change until viewScope is reset
return viewScope.isReadOnly || false;
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@ -192,13 +192,16 @@ class ContextMenu {
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
})
.on("mouseup", (e) =>{
});
if (!this.isMobile) {
$item.on("mouseup", (e) =>{
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
});
}
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");

View File

@ -8,7 +8,7 @@ interface Entity {
export interface EntityChange {
id?: number | null;
noteId?: string;
entityName: EntityRowNames;
entityName: EntityType;
entityId: string;
entity?: Entity;
positions?: Record<string, number>;
@ -22,3 +22,5 @@ export interface EntityChange {
changeId?: string | null;
instanceId?: string | null;
}
export type EntityType = "notes" | "branches" | "attributes" | "note_reordering" | "revisions" | "options" | "attachments" | "blobs" | "etapi_tokens" | "note_embeddings";

View File

@ -35,8 +35,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
// NOOP
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "note_embeddings") {
// NOOP - these entities are handled at the backend level and don't require frontend processing
} else {
throw new Error(`Unknown entityName '${ec.entityName}'`);
}

View File

@ -115,6 +115,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
export default {
updateDisplayedShortcuts,
setupActionsForElement,
getAction,
getActions,
getActionsForScope
};

View File

@ -16,4 +16,24 @@ describe("Link", () => {
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("parses notePath with spaces", () => {
const output = parseNavigationStateFromUrl(` #root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("ignores external URL with internal hash anchor", () => {
const output = parseNavigationStateFromUrl(`https://en.wikipedia.org/wiki/Bearded_Collie#Health`);
expect(output).toMatchObject({});
});
it("ignores malformed but hash-containing external URL", () => {
const output = parseNavigationStateFromUrl("https://abc.com/#drop?searchString=firefox");
expect(output).toStrictEqual({});
});
it("ignores non-hash internal path", () => {
const output = parseNavigationStateFromUrl("/root/abc123");
expect(output).toStrictEqual({});
});
});

View File

@ -48,6 +48,13 @@ export interface ViewScope {
viewMode?: ViewMode;
attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean;
/**
* If true, it indicates that the note in the view should be opened in read-only mode (for supported note types such as text or code).
*
* The reason why we store this information here is that a note can become read-only as the user types content in it, and we wouldn't want
* to immediately enter read-only mode.
*/
isReadOnly?: boolean;
highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean;
tocTemporarilyHidden?: boolean;
@ -204,11 +211,17 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
return {};
}
url = url.trim();
const hashIdx = url.indexOf("#");
if (hashIdx === -1) {
return {};
}
// Exclude external links that contain #
if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString")) {
return {};
}
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
let [notePath, paramString] = hash.split("?");

View File

@ -44,9 +44,17 @@ interface OptionRow {}
interface NoteReorderingRow {}
interface ContentNoteIdToComponentIdRow {
interface NoteEmbeddingRow {
embedId: string;
noteId: string;
componentId: string;
providerId: string;
modelId: string;
dimension: number;
version: number;
dateCreated: string;
utcDateCreated: string;
dateModified: string;
utcDateModified: string;
}
type EntityRowMappings = {
@ -56,6 +64,7 @@ type EntityRowMappings = {
options: OptionRow;
revisions: RevisionRow;
note_reordering: NoteReorderingRow;
note_embeddings: NoteEmbeddingRow;
};
export type EntityRowNames = keyof EntityRowMappings;

View File

@ -124,8 +124,12 @@ function formatDateISO(date: Date) {
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
}
function formatDateTime(date: Date) {
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
if (userSuppliedFormat?.trim()) {
return dayjs(date).format(userSuppliedFormat);
} else {
return `${formatDate(date)} ${formatTime(date)}`;
}
}
function localNowDateTime() {

View File

@ -326,6 +326,7 @@ button kbd {
user-select: none;
-webkit-user-select: none;
--bs-dropdown-zindex: 999;
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
}
body.desktop .dropdown-menu {

View File

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

View File

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

View File

@ -108,6 +108,25 @@ div.editability-dropdown a.dropdown-item {
font-size: 0.85em;
}
/*
* Edited notes (for calendar notes)
*/
/* The path of the note */
.edited-notes-list small {
margin-inline-start: 4px;
font-size: inherit;
color: var(--muted-text-color);
}
.edited-notes-list small::before {
content: "(";
}
.edited-notes-list small::after {
content: ")";
}
/*
* Owned attributes
*/

View File

@ -1402,6 +1402,7 @@ div.floating-buttons .show-floating-buttons-button:active {
div.floating-buttons-children .close-floating-buttons-button::before,
div.floating-buttons .show-floating-buttons-button::before {
display: block;
line-height: 1;
}
/* "Show buttons" button */

View File

@ -1431,6 +1431,12 @@
"label": "Automatic read-only size (text notes)",
"unit": "characters"
},
"custom_date_time_format": {
"title": "Custom Date/Time Format",
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
"format_string": "Format string:",
"formatted_time": "Formatted date/time:"
},
"i18n": {
"title": "Localization",
"language": "Language",

View File

@ -6,8 +6,10 @@ import type { SessionResponse } from "./types.js";
/**
* Create a new chat session
* @param currentNoteId - Optional current note ID for context
* @returns The noteId of the created chat note
*/
export async function createChatSession(currentNoteId?: string): Promise<{chatNoteId: string | null, noteId: string | null}> {
export async function createChatSession(currentNoteId?: string): Promise<string | null> {
try {
const resp = await server.post<SessionResponse>('llm/chat', {
title: 'Note Chat',
@ -15,48 +17,42 @@ export async function createChatSession(currentNoteId?: string): Promise<{chatNo
});
if (resp && resp.id) {
// The backend might provide the noteId separately from the chatNoteId
// If noteId is provided, use it; otherwise, we'll need to query for it separately
return {
chatNoteId: resp.id,
noteId: resp.noteId || null
};
// Backend returns the chat note ID as 'id'
return resp.id;
}
} catch (error) {
console.error('Failed to create chat session:', error);
}
return {
chatNoteId: null,
noteId: null
};
return null;
}
/**
* Check if a session exists
* Check if a chat note exists
* @param noteId - The ID of the chat note
*/
export async function checkSessionExists(chatNoteId: string): Promise<boolean> {
export async function checkSessionExists(noteId: string): Promise<boolean> {
try {
// Validate that we have a proper note ID format, not a session ID
// Note IDs in Trilium are typically longer or in a different format
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.warn(`Invalid note ID format detected: ${chatNoteId} appears to be a legacy session ID`);
return false;
}
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${chatNoteId}`);
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${noteId}`);
return !!(sessionCheck && sessionCheck.id);
} catch (error: any) {
console.log(`Error checking chat note ${chatNoteId}:`, error);
console.log(`Error checking chat note ${noteId}:`, error);
return false;
}
}
/**
* Set up streaming response via WebSocket
* @param noteId - The ID of the chat note
* @param messageParams - Message parameters
* @param onContentUpdate - Callback for content updates
* @param onThinkingUpdate - Callback for thinking updates
* @param onToolExecution - Callback for tool execution
* @param onComplete - Callback for completion
* @param onError - Callback for errors
*/
export async function setupStreamingResponse(
chatNoteId: string,
noteId: string,
messageParams: any,
onContentUpdate: (content: string, isDone?: boolean) => void,
onThinkingUpdate: (thinking: string) => void,
@ -64,35 +60,24 @@ export async function setupStreamingResponse(
onComplete: () => void,
onError: (error: Error) => void
): Promise<void> {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
onError(new Error("Invalid note ID format - using a legacy session ID"));
return;
}
return new Promise((resolve, reject) => {
let assistantResponse = '';
let postToolResponse = ''; // Separate accumulator for post-tool execution content
let receivedAnyContent = false;
let receivedPostToolContent = false; // Track if we've started receiving post-tool content
let timeoutId: number | null = null;
let initialTimeoutId: number | null = null;
let cleanupTimeoutId: number | null = null;
let receivedAnyMessage = false;
let toolsExecuted = false; // Flag to track if tools were executed in this session
let toolExecutionCompleted = false; // Flag to track if tool execution is completed
let eventListener: ((event: Event) => void) | null = null;
let lastMessageTimestamp = 0;
// Create a unique identifier for this response process
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${chatNoteId}`);
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${noteId}`);
// Send the initial request to initiate streaming
(async () => {
try {
const streamResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages/stream`, {
const streamResponse = await server.post<any>(`llm/chat/${noteId}/messages/stream`, {
content: messageParams.content,
useAdvancedContext: messageParams.useAdvancedContext,
showThinking: messageParams.showThinking,
@ -129,28 +114,14 @@ export async function setupStreamingResponse(
resolve();
};
// Function to schedule cleanup with ability to cancel
const scheduleCleanup = (delay: number) => {
// Clear any existing cleanup timeout
if (cleanupTimeoutId) {
window.clearTimeout(cleanupTimeoutId);
}
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) {
// Set initial timeout to catch cases where no message is received at all
initialTimeoutId = window.setTimeout(() => {
if (!receivedAnyMessage) {
console.error(`[${responseId}] No initial message received within timeout`);
performCleanup();
} else {
console.log(`[${responseId}] Received message recently, delaying cleanup`);
// Reschedule cleanup
scheduleCleanup(2000);
reject(new Error('No response received from server'));
}
}, delay);
};
}, 10000);
// Create a message handler for CustomEvents
eventListener = (event: Event) => {
@ -158,7 +129,7 @@ export async function setupStreamingResponse(
const message = customEvent.detail;
// Only process messages for our chat note
if (!message || message.chatNoteId !== chatNoteId) {
if (!message || message.chatNoteId !== noteId) {
return;
}
@ -172,12 +143,12 @@ export async function setupStreamingResponse(
cleanupTimeoutId = null;
}
console.log(`[${responseId}] LLM Stream message received via CustomEvent: chatNoteId=${chatNoteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}, type=${message.type || 'llm-stream'}`);
console.log(`[${responseId}] LLM Stream message received: content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`);
// Mark first message received
if (!receivedAnyMessage) {
receivedAnyMessage = true;
console.log(`[${responseId}] First message received for chat note ${chatNoteId}`);
console.log(`[${responseId}] First message received for chat note ${noteId}`);
// Clear the initial timeout since we've received a message
if (initialTimeoutId !== null) {
@ -186,109 +157,33 @@ export async function setupStreamingResponse(
}
}
// Handle specific message types
if (message.type === 'tool_execution_start') {
toolsExecuted = true; // Mark that tools were executed
onThinkingUpdate('Executing tools...');
// Also trigger tool execution UI with a specific format
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Executing tools...'
});
return; // Skip accumulating content from this message
// Handle error
if (message.error) {
console.error(`[${responseId}] Stream error: ${message.error}`);
performCleanup();
reject(new Error(message.error));
return;
}
if (message.type === 'tool_result' && message.toolExecution) {
toolsExecuted = true; // Mark that tools were executed
console.log(`[${responseId}] Processing tool result: ${JSON.stringify(message.toolExecution)}`);
// If tool execution doesn't have an action, add 'result' as the default
if (!message.toolExecution.action) {
message.toolExecution.action = 'result';
// Handle thinking updates - only show if showThinking is enabled
if (message.thinking && messageParams.showThinking) {
console.log(`[${responseId}] Received thinking: ${message.thinking.substring(0, 100)}...`);
onThinkingUpdate(message.thinking);
}
// First send a 'start' action to ensure the container is created
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Tool execution initialized'
});
// Then send the actual tool execution data
// Handle tool execution updates
if (message.toolExecution) {
console.log(`[${responseId}] Tool execution update:`, message.toolExecution);
onToolExecution(message.toolExecution);
// Mark tool execution as completed if this is a result or error
if (message.toolExecution.action === 'result' || message.toolExecution.action === 'complete' || message.toolExecution.action === 'error') {
toolExecutionCompleted = true;
console.log(`[${responseId}] Tool execution completed`);
}
return; // Skip accumulating content from this message
}
if (message.type === 'tool_execution_error' && message.toolExecution) {
toolsExecuted = true; // Mark that tools were executed
toolExecutionCompleted = true; // Mark tool execution as completed
onToolExecution({
...message.toolExecution,
action: 'error',
error: message.toolExecution.error || 'Unknown error during tool execution'
});
return; // Skip accumulating content from this message
}
if (message.type === 'tool_completion_processing') {
toolsExecuted = true; // Mark that tools were executed
toolExecutionCompleted = true; // Tools are done, now processing the result
onThinkingUpdate('Generating response with tool results...');
// Also trigger tool execution UI with a specific format
onToolExecution({
action: 'generating',
tool: 'tools',
result: 'Generating response with tool results...'
});
return; // Skip accumulating content from this message
}
// Handle content updates
if (message.content) {
console.log(`[${responseId}] Received content chunk of length ${message.content.length}, preview: "${message.content.substring(0, 50)}${message.content.length > 50 ? '...' : ''}"`);
// If tools were executed and completed, and we're now getting new content,
// this is likely the final response after tool execution from Anthropic
if (toolsExecuted && toolExecutionCompleted && message.content) {
console.log(`[${responseId}] Post-tool execution content detected`);
// If this is the first post-tool chunk, indicate we're starting a new response
if (!receivedPostToolContent) {
receivedPostToolContent = true;
postToolResponse = ''; // Clear any previous post-tool response
console.log(`[${responseId}] First post-tool content chunk, starting fresh accumulation`);
}
// Accumulate post-tool execution content
postToolResponse += message.content;
console.log(`[${responseId}] Accumulated post-tool content, now ${postToolResponse.length} chars`);
// Update the UI with the accumulated post-tool content
// This replaces the pre-tool content with our accumulated post-tool content
onContentUpdate(postToolResponse, message.done || false);
} else {
// Standard content handling for non-tool cases or initial tool response
// Check if this is a duplicated message containing the same content we already have
if (message.done && assistantResponse.includes(message.content)) {
console.log(`[${responseId}] Ignoring duplicated content in done message`);
} else {
// Add to our accumulated response
// Simply append the new content - no complex deduplication
assistantResponse += message.content;
}
// Update the UI immediately with each chunk
onContentUpdate(assistantResponse, message.done || false);
}
receivedAnyContent = true;
// Reset timeout since we got content
@ -298,151 +193,33 @@ export async function setupStreamingResponse(
// Set new timeout
timeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`);
// Clean up
console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`);
performCleanup();
reject(new Error('Stream timeout'));
}, 30000);
}
// Handle tool execution updates (legacy format and standard format with llm-stream type)
if (message.toolExecution) {
// Only process if we haven't already handled this message via specific message types
if (message.type === 'llm-stream' || !message.type) {
console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`);
toolsExecuted = true; // Mark that tools were executed
// Mark tool execution as completed if this is a result or error
if (message.toolExecution.action === 'result' ||
message.toolExecution.action === 'complete' ||
message.toolExecution.action === 'error') {
toolExecutionCompleted = true;
console.log(`[${responseId}] Tool execution completed via toolExecution message`);
}
onToolExecution(message.toolExecution);
}
}
// Handle tool calls from the raw data or direct in message (OpenAI format)
const toolCalls = message.tool_calls || (message.raw && message.raw.tool_calls);
if (toolCalls && Array.isArray(toolCalls)) {
console.log(`[${responseId}] Received tool calls: ${toolCalls.length} tools`);
toolsExecuted = true; // Mark that tools were executed
// First send a 'start' action to ensure the container is created
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Tool execution initialized'
});
// Then process each tool call
for (const toolCall of toolCalls) {
let args = toolCall.function?.arguments || {};
// Try to parse arguments if they're a string
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
console.log(`[${responseId}] Could not parse tool arguments as JSON: ${e}`);
args = { raw: args };
}
}
onToolExecution({
action: 'executing',
tool: toolCall.function?.name || 'unknown',
toolCallId: toolCall.id,
args: args
});
}
}
// Handle thinking state updates
if (message.thinking) {
console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`);
onThinkingUpdate(message.thinking);
}
// Handle completion
if (message.done) {
console.log(`[${responseId}] Stream completed for chat note ${chatNoteId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`);
console.log(`[${responseId}] Stream completed for chat note ${noteId}, final response: ${assistantResponse.length} chars`);
// Dump message content to console for debugging
if (message.content) {
console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`);
// Check if the done message contains the exact same content as our accumulated response
// We normalize by removing whitespace to avoid false negatives due to spacing differences
const normalizedMessage = message.content.trim();
const normalizedResponse = assistantResponse.trim();
if (normalizedMessage === normalizedResponse) {
console.log(`[${responseId}] Final message is identical to accumulated response, no need to update`);
}
// If the done message is longer but contains our accumulated response, use the done message
else if (normalizedMessage.includes(normalizedResponse) && normalizedMessage.length > normalizedResponse.length) {
console.log(`[${responseId}] Final message is more complete than accumulated response, using it`);
assistantResponse = message.content;
}
// If the done message is different and not already included, append it to avoid duplication
else if (!normalizedResponse.includes(normalizedMessage) && normalizedMessage.length > 0) {
console.log(`[${responseId}] Final message has unique content, using it`);
assistantResponse = message.content;
}
// Otherwise, we already have the content accumulated, so no need to update
else {
console.log(`[${responseId}] Already have this content accumulated, not updating`);
}
}
// Clear timeout if set
// Clear all timeouts
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
// Always mark as done when we receive the done flag
onContentUpdate(assistantResponse, true);
// Set a longer delay before cleanup to allow for post-tool execution messages
// Especially important for Anthropic which may send final message after tool execution
const cleanupDelay = toolsExecuted ? 15000 : 1000; // 15 seconds if tools were used, otherwise 1 second
console.log(`[${responseId}] Setting cleanup delay of ${cleanupDelay}ms since toolsExecuted=${toolsExecuted}`);
scheduleCleanup(cleanupDelay);
// Schedule cleanup after a brief delay to ensure all processing is complete
cleanupTimeoutId = window.setTimeout(() => {
performCleanup();
}, 100);
}
};
// Register event listener for the custom event
try {
// Register the event listener for WebSocket messages
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
initialTimeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] No messages received for initial period in chat note ${chatNoteId}`);
if (!receivedAnyMessage) {
console.error(`[${responseId}] WebSocket connection not established for chat note ${chatNoteId}`);
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
// Clean up
cleanupEventListener(eventListener);
// Show error message to user
reject(new Error('WebSocket connection not established'));
}
}, 10000);
console.log(`[${responseId}] Event listener registered, waiting for messages...`);
});
}
@ -463,15 +240,9 @@ function cleanupEventListener(listener: ((event: Event) => void) | null): void {
/**
* Get a direct response from the server without streaming
*/
export async function getDirectResponse(chatNoteId: string, messageParams: any): Promise<any> {
export async function getDirectResponse(noteId: string, messageParams: any): Promise<any> {
try {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
throw new Error("Invalid note ID format - using a legacy session ID");
}
const postResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages`, {
const postResponse = await server.post<any>(`llm/chat/${noteId}/messages`, {
message: messageParams.content,
includeContext: messageParams.useAdvancedContext,
options: {

View File

@ -37,9 +37,10 @@ export default class LlmChatPanel extends BasicWidget {
private thinkingBubble!: HTMLElement;
private thinkingText!: HTMLElement;
private thinkingToggle!: HTMLElement;
private chatNoteId: string | null = null;
private noteId: string | null = null; // The actual noteId for the Chat Note
private currentNoteId: string | null = null;
// Simplified to just use noteId - this represents the AI Chat note we're working with
private noteId: string | null = null;
private currentNoteId: string | null = null; // The note providing context (for regular notes)
private _messageHandlerId: number | null = null;
private _messageHandler: any = null;
@ -68,7 +69,6 @@ export default class LlmChatPanel extends BasicWidget {
totalTokens?: number;
};
} = {
model: 'default',
temperature: 0.7,
toolExecutions: []
};
@ -90,12 +90,21 @@ export default class LlmChatPanel extends BasicWidget {
this.messages = messages;
}
public getChatNoteId(): string | null {
return this.chatNoteId;
public getNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(chatNoteId: string | null): void {
this.chatNoteId = chatNoteId;
public setNoteId(noteId: string | null): void {
this.noteId = noteId;
}
// Deprecated - keeping for backward compatibility but mapping to noteId
public getChatNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(noteId: string | null): void {
this.noteId = noteId;
}
public getNoteContextChatMessages(): HTMLElement {
@ -307,16 +316,22 @@ export default class LlmChatPanel extends BasicWidget {
}
}
const dataToSave: ChatData = {
// Only save if we have a valid note ID
if (!this.noteId) {
console.warn('Cannot save chat data: no noteId available');
return;
}
const dataToSave = {
messages: this.messages,
chatNoteId: this.chatNoteId,
noteId: this.noteId,
chatNoteId: this.noteId, // For backward compatibility
toolSteps: toolSteps,
// Add sources if we have them
sources: this.sources || [],
// Add metadata
metadata: {
model: this.metadata?.model || 'default',
model: this.metadata?.model || undefined,
provider: this.metadata?.provider || undefined,
temperature: this.metadata?.temperature || 0.7,
lastUpdated: new Date().toISOString(),
@ -325,7 +340,7 @@ export default class LlmChatPanel extends BasicWidget {
}
};
console.log(`Saving chat data with chatNoteId: ${this.chatNoteId}, noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
console.log(`Saving chat data with noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
// Save the data to the note attribute via the callback
// This is the ONLY place we should save data, letting the container widget handle persistence
@ -347,9 +362,44 @@ export default class LlmChatPanel extends BasicWidget {
const savedData = await this.onGetData() as ChatData;
if (savedData?.messages?.length > 0) {
// Check if we actually have new content to avoid unnecessary UI rebuilds
const currentMessageCount = this.messages.length;
const savedMessageCount = savedData.messages.length;
// If message counts are the same, check if content is different
const hasNewContent = savedMessageCount > currentMessageCount ||
JSON.stringify(this.messages) !== JSON.stringify(savedData.messages);
if (!hasNewContent) {
console.log("No new content detected, skipping UI rebuild");
return true;
}
console.log(`Loading saved data: ${currentMessageCount} -> ${savedMessageCount} messages`);
// Store current scroll position if we need to preserve it
const shouldPreserveScroll = savedMessageCount > currentMessageCount && currentMessageCount > 0;
const currentScrollTop = shouldPreserveScroll ? this.chatContainer.scrollTop : 0;
const currentScrollHeight = shouldPreserveScroll ? this.chatContainer.scrollHeight : 0;
// Load messages
const oldMessages = [...this.messages];
this.messages = savedData.messages;
// Only rebuild UI if we have significantly different content
if (savedMessageCount > currentMessageCount) {
// We have new messages - just add the new ones instead of rebuilding everything
const newMessages = savedData.messages.slice(currentMessageCount);
console.log(`Adding ${newMessages.length} new messages to UI`);
newMessages.forEach(message => {
const role = message.role as 'user' | 'assistant';
this.addMessageToChat(role, message.content);
});
} else {
// Content changed but count is same - need to rebuild
console.log("Message content changed, rebuilding UI");
// Clear and rebuild the chat UI
this.noteContextChatMessages.innerHTML = '';
@ -357,6 +407,7 @@ export default class LlmChatPanel extends BasicWidget {
const role = message.role as 'user' | 'assistant';
this.addMessageToChat(role, message.content);
});
}
// Restore tool execution steps if they exist
if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) {
@ -400,13 +451,33 @@ export default class LlmChatPanel extends BasicWidget {
// Load Chat Note ID if available
if (savedData.noteId) {
console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`);
this.chatNoteId = savedData.noteId;
this.noteId = savedData.noteId;
} else {
console.log(`No noteId found in saved data, cannot load chat session`);
return false;
}
// Restore scroll position if we were preserving it
if (shouldPreserveScroll) {
// Calculate the new scroll position to maintain relative position
const newScrollHeight = this.chatContainer.scrollHeight;
const scrollDifference = newScrollHeight - currentScrollHeight;
const newScrollTop = currentScrollTop + scrollDifference;
// Only scroll down if we're near the bottom, otherwise preserve exact position
const wasNearBottom = (currentScrollTop + this.chatContainer.clientHeight) >= (currentScrollHeight - 50);
if (wasNearBottom) {
// User was at bottom, scroll to new bottom
this.chatContainer.scrollTop = newScrollHeight;
console.log("User was at bottom, scrolling to new bottom");
} else {
// User was not at bottom, try to preserve their position
this.chatContainer.scrollTop = newScrollTop;
console.log(`Preserving scroll position: ${currentScrollTop} -> ${newScrollTop}`);
}
}
return true;
}
} catch (error) {
@ -550,6 +621,15 @@ export default class LlmChatPanel extends BasicWidget {
// Get current note context if needed
const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null;
// For AI Chat notes, the note itself IS the chat session
// So currentNoteId and noteId should be the same
if (this.noteId && currentActiveNoteId === this.noteId) {
// We're in an AI Chat note - don't reset, just load saved data
console.log(`Refreshing AI Chat note ${this.noteId} - loading saved data`);
await this.loadSavedData();
return;
}
// If we're switching to a different note, we need to reset
if (this.currentNoteId !== currentActiveNoteId) {
console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`);
@ -557,7 +637,6 @@ export default class LlmChatPanel extends BasicWidget {
// Reset the UI and data
this.noteContextChatMessages.innerHTML = '';
this.messages = [];
this.chatNoteId = null;
this.noteId = null; // Also reset the chat note ID
this.hideSources(); // Hide any sources from previous note
@ -569,7 +648,7 @@ export default class LlmChatPanel extends BasicWidget {
const hasSavedData = await this.loadSavedData();
// Only create a new session if we don't have a session or saved data
if (!this.chatNoteId || !this.noteId || !hasSavedData) {
if (!this.noteId || !hasSavedData) {
// Create a new chat session
await this.createChatSession();
}
@ -580,19 +659,15 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async createChatSession() {
try {
// Create a new chat session, passing the current note ID if it exists
const { chatNoteId, noteId } = await createChatSession(
this.currentNoteId ? this.currentNoteId : undefined
);
// If we already have a noteId (for AI Chat notes), use it
const contextNoteId = this.noteId || this.currentNoteId;
if (chatNoteId) {
// If we got back an ID from the API, use it
this.chatNoteId = chatNoteId;
// For new sessions, the noteId should equal the chatNoteId
// This ensures we're using the note ID consistently
this.noteId = noteId || chatNoteId;
// Create a new chat session, passing the context note ID
const noteId = await createChatSession(contextNoteId ? contextNoteId : undefined);
if (noteId) {
// Set the note ID for this chat
this.noteId = noteId;
console.log(`Created new chat session with noteId: ${this.noteId}`);
} else {
throw new Error("Failed to create chat session - no ID returned");
@ -645,7 +720,7 @@ export default class LlmChatPanel extends BasicWidget {
const showThinking = this.showThinkingCheckbox.checked;
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@ -695,11 +770,11 @@ export default class LlmChatPanel extends BasicWidget {
await validateEmbeddingProviders(this.validationWarning);
// Make sure we have a valid session
if (!this.chatNoteId) {
if (!this.noteId) {
// If no session ID, create a new session
await this.createChatSession();
if (!this.chatNoteId) {
if (!this.noteId) {
// If still no session ID, show error and return
console.error("Failed to create chat session");
toastService.showError("Failed to create chat session");
@ -730,7 +805,7 @@ export default class LlmChatPanel extends BasicWidget {
await this.saveCurrentData();
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@ -767,12 +842,12 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async handleDirectResponse(messageParams: any): Promise<boolean> {
try {
if (!this.chatNoteId) return false;
if (!this.noteId) return false;
console.log(`Getting direct response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Getting direct response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Get a direct response from the server
const postResponse = await getDirectResponse(this.chatNoteId, messageParams);
const postResponse = await getDirectResponse(this.noteId, messageParams);
// If the POST request returned content directly, display it
if (postResponse && postResponse.content) {
@ -845,11 +920,11 @@ export default class LlmChatPanel extends BasicWidget {
* Set up streaming response via WebSocket
*/
private async setupStreamingResponse(messageParams: any): Promise<void> {
if (!this.chatNoteId) {
if (!this.noteId) {
throw new Error("No session ID available");
}
console.log(`Setting up streaming response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Setting up streaming response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Store tool executions captured during streaming
const toolExecutionsCache: Array<{
@ -862,7 +937,7 @@ export default class LlmChatPanel extends BasicWidget {
}> = [];
return setupStreamingResponse(
this.chatNoteId,
this.noteId,
messageParams,
// Content update handler
(content: string, isDone: boolean = false) => {
@ -898,7 +973,7 @@ export default class LlmChatPanel extends BasicWidget {
similarity?: number;
content?: string;
}>;
}>(`llm/chat/${this.chatNoteId}`)
}>(`llm/chat/${this.noteId}`)
.then((sessionData) => {
console.log("Got updated session data:", sessionData);
@ -933,9 +1008,9 @@ export default class LlmChatPanel extends BasicWidget {
}
}
// Save the updated data to the note
this.saveCurrentData()
.catch(err => console.error("Failed to save data after streaming completed:", err));
// DON'T save here - let the server handle saving the complete conversation
// to avoid race conditions between client and server saves
console.log("Updated metadata after streaming completion, server should save");
})
.catch(err => console.error("Error fetching session data after streaming:", err));
}
@ -973,11 +1048,9 @@ export default class LlmChatPanel extends BasicWidget {
console.log(`Cached tool execution for ${toolData.tool} to be saved later`);
// Save immediately after receiving a tool execution
// This ensures we don't lose tool execution data if streaming fails
this.saveCurrentData().catch(err => {
console.error("Failed to save tool execution data:", err);
});
// DON'T save immediately during streaming - let the server handle saving
// to avoid race conditions between client and server saves
console.log(`Tool execution cached, will be saved by server`);
}
},
// Complete handler
@ -995,23 +1068,19 @@ export default class LlmChatPanel extends BasicWidget {
* Update the UI with streaming content
*/
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
// Parse and handle thinking content if present
if (!isDone) {
const thinkingContent = this.parseThinkingContent(assistantResponse);
if (thinkingContent) {
this.updateThinkingText(thinkingContent);
// Don't display the raw response with think tags in the chat
return;
}
}
// Track if we have a streaming message in progress
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
// Get the existing assistant message or create a new one
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
// Create a new message element or use the existing streaming one
let assistantMessageEl: HTMLElement;
if (!assistantMessageEl) {
// If no assistant message yet, create one
if (hasStreamingMessage) {
// Use the existing streaming message
assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!;
} else {
// Create a new message element
assistantMessageEl = document.createElement('div');
assistantMessageEl.className = 'assistant-message message mb-3';
assistantMessageEl.className = 'assistant-message message mb-3 streaming';
this.noteContextChatMessages.appendChild(assistantMessageEl);
// Add assistant profile icon
@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget {
assistantMessageEl.appendChild(messageContent);
}
// Clean the response to remove thinking tags before displaying
const cleanedResponse = this.removeThinkingTags(assistantResponse);
// Update the content
// Update the content with the current response
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
messageContent.innerHTML = formatMarkdown(cleanedResponse);
messageContent.innerHTML = formatMarkdown(assistantResponse);
// Apply syntax highlighting if this is the final update
// When the response is complete
if (isDone) {
// Remove the streaming class to mark this message as complete
assistantMessageEl.classList.remove('streaming');
// Apply syntax highlighting
formatCodeBlocks($(assistantMessageEl as HTMLElement));
// Hide the thinking display when response is complete
this.hideThinkingDisplay();
// Update message in the data model for storage
// Find the last assistant message to update, or add a new one if none exists
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
const lastAssistantMsgIndex = assistantMessages.length > 0 ?
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
if (lastAssistantMsgIndex >= 0) {
// Update existing message with cleaned content
this.messages[lastAssistantMsgIndex].content = cleanedResponse;
} else {
// Add new message with cleaned content
// Always add a new message to the data model
// This ensures we preserve all distinct assistant messages
this.messages.push({
role: 'assistant',
content: cleanedResponse
content: assistantResponse,
timestamp: new Date()
});
}
// 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
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
/**
* Remove thinking tags from response content
*/
private removeThinkingTags(content: string): string {
if (!content) return content;
// Remove <think>...</think> blocks from the content
return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
/**
* Handle general errors in the send message flow
*/

View File

@ -11,7 +11,7 @@ export interface ChatResponse {
export interface SessionResponse {
id: string;
title: string;
noteId?: string;
noteId: string; // The ID of the chat note
}
export interface ToolExecutionStep {
@ -33,8 +33,8 @@ export interface MessageData {
export interface ChatData {
messages: MessageData[];
chatNoteId: string | null;
noteId?: string | null;
noteId: string; // The ID of the chat note
chatNoteId?: string; // Deprecated - kept for backward compatibility, should equal noteId
toolSteps: ToolExecutionStep[];
sources?: Array<{
noteId: string;

View File

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

View File

@ -94,6 +94,11 @@ export default class AiChatTypeWidget extends TypeWidget {
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
// Set the note ID for the chat panel
if (note) {
this.llmChatPanel.setNoteId(note.noteId);
}
// This will load saved data via the getData callback
await this.llmChatPanel.refresh();
this.isInitialized = true;
@ -130,7 +135,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Reset the chat panel UI
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
this.llmChatPanel.setChatNoteId(null);
this.llmChatPanel.setNoteId(this.note.noteId);
}
// Call the parent method to refresh
@ -152,6 +157,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Make sure the chat panel has the current note ID
if (this.note) {
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
this.llmChatPanel.setNoteId(this.note.noteId);
}
this.initPromise = (async () => {
@ -186,7 +192,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Format the data properly - this is the canonical format of the data
const formattedData = {
messages: data.messages || [],
chatNoteId: data.chatNoteId || this.note.noteId,
noteId: this.note.noteId, // Always use the note's own ID
toolSteps: data.toolSteps || [],
sources: data.sources || [],
metadata: {

View File

@ -189,7 +189,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
{
label: "Insert",
icon: "plus",
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak"]
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
"outdent",
@ -244,7 +244,7 @@ export function buildFloatingToolbar() {
{
label: "Insert",
icon: "plus",
items: ["internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak"]
items: ["bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
"outdent",

View File

@ -8,6 +8,7 @@ import HeadingStyleOptions from "./options/text_notes/heading_style.js";
import TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
import HighlightsListOptions from "./options/text_notes/highlights_list.js";
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js";
import DateTimeFormatOptions from "./options/text_notes/date_time_format.js";
import CodeEditorOptions from "./options/code_notes/code_editor.js";
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
@ -88,7 +89,8 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAw
CodeBlockOptions,
TableOfContentsOptions,
HighlightsListOptions,
TextAutoReadOnlySizeOptions
TextAutoReadOnlySizeOptions,
DateTimeFormatOptions
],
_optionsCodeNotes: [
CodeEditorOptions,

View File

@ -266,7 +266,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
item.on("change:isOpen", () => {
if (!("isOpen" in item) || !item.isOpen ) {
if (!("isOpen" in item) || !item.isOpen) {
return;
}
@ -377,7 +377,8 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
insertDateTimeToTextCommand() {
const date = new Date();
const dateString = utils.formatDateTime(date);
const customDateTimeFormat = options.get("customDateTimeFormat");
const dateString = utils.formatDateTime(date, customDateTimeFormat);
this.addTextToEditor(dateString);
}

View File

@ -239,6 +239,9 @@ export default class GeoMapTypeWidget extends TypeWidget {
wptIcons: {
"": this.#buildIcon("bx bx-pin")
}
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
}
});
track.addTo(this.geoMapWidget.map);

View File

@ -0,0 +1,67 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
import utils from "../../../../services/utils.js";
import keyboardActionsService from "../../../../services/keyboard_actions.js";
import linkService from "../../../.././services/link.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("custom_date_time_format.title")}</h4>
<p class="description">
${t("custom_date_time_format.description")}
</p>
<div class="form-group row align-items-center">
<div class="col-6">
<label for="custom-date-time-format">${t("custom_date_time_format.format_string")}</label>
<input type="text" id="custom-date-time-format" class="form-control custom-date-time-format" placeholder="YYYY-MM-DD HH:mm">
</div>
<div class="col-6">
<label>${t("custom_date_time_format.formatted_time")}</label>
<div class="formatted-date"></div>
</div>
</div>
</div>
`;
export default class DateTimeFormatOptions extends OptionsWidget {
private $formatInput!: JQuery<HTMLInputElement>;
private $formattedDate!: JQuery<HTMLInputElement>;
doRender() {
this.$widget = $(TPL);
this.$formatInput = this.$widget.find("input.custom-date-time-format");
this.$formattedDate = this.$widget.find(".formatted-date");
this.$formatInput.on("input", () => {
const dateString = utils.formatDateTime(new Date(), this.$formatInput.val());
this.$formattedDate.text(dateString);
});
this.$formatInput.on('blur keydown', (e) => {
if (e.type === 'blur' || (e.type === 'keydown' && e.key === 'Enter')) {
this.updateOption("customDateTimeFormat", this.$formatInput.val());
}
});
return this.$widget;
}
async optionsLoaded(options: OptionMap) {
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
"title": shortcutKey,
"showTooltip": false
});
this.$widget.find(".description").find("kbd").replaceWith($link);
const customDateTimeFormat = options.customDateTimeFormat || "YYYY-MM-DD HH:mm";
this.$formatInput.val(customDateTimeFormat);
const dateString = utils.formatDateTime(new Date(), customDateTimeFormat);
this.$formattedDate.text(dateString);
}
}

View File

@ -17,7 +17,7 @@
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.0",
"electron": "36.3.2",
"electron": "36.4.0",
"@electron-forge/cli": "7.8.1",
"@electron-forge/maker-deb": "7.8.1",
"@electron-forge/maker-dmg": "7.8.1",
@ -31,7 +31,6 @@
"config": {
"forge": "./electron-forge/forge.config.cjs"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"scripts": {
"start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js"
},

View File

@ -7,8 +7,11 @@ import tray from "@triliumnext/server/src/services/tray.js";
import options from "@triliumnext/server/src/services/options.js";
import electronDebug from "electron-debug";
import electronDl from "electron-dl";
import { deferred } from "@triliumnext/server/src/services/utils.js";
async function main() {
const serverInitializedPromise = deferred<void>();
// Prevent Trilium starting twice on first install and on uninstall for the Windows installer.
if ((require("electron-squirrel-startup")).default) {
process.exit(0);
@ -37,7 +40,11 @@ async function main() {
}
});
electron.app.on("ready", onReady);
electron.app.on("ready", async () => {
await serverInitializedPromise;
console.log("Starting Electron...");
await onReady();
});
electron.app.on("will-quit", () => {
electron.globalShortcut.unregisterAll();
@ -47,7 +54,10 @@ async function main() {
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
await initializeTranslations();
await import("@triliumnext/server/src/main.js");
const startTriliumServer = (await import("@triliumnext/server/src/www.js")).default;
await startTriliumServer();
console.log("Server loaded");
serverInitializedPromise.resolve();
}
async function onReady() {

View File

@ -12,7 +12,7 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/mime-types": "^2.1.4",
"@types/mime-types": "^3.0.0",
"@types/yargs": "^17.0.33"
},
"nx": {

View File

@ -454,19 +454,19 @@
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "child:child:child:template",
"value": "kr6HIBBuXRwm",
"isInheritable": false,
"position": 20
},
{
"type": "label",
"name": "iconClass",
"value": "bx bx-calendar",
"isInheritable": false,
"position": 30
},
{
"type": "relation",
"name": "dateTemplate",
"value": "kr6HIBBuXRwm",
"isInheritable": false,
"position": 20
}
],
"format": "html",

View File

@ -18,22 +18,28 @@
height="150">
</figure>
<p><strong>Welcome to TriliumNext Notes!</strong>
</p>
<p>This is initial "demo" document provided by TriliumNext by default to
showcase some of its features and also give you some ideas how you might
structure your notes. You can play with it, modify note content and tree
structure as you wish.</p>
<p>If you need any help, visit TriliumNext website: <a href="https://github.com/TriliumNext">https://github.com/TriliumNext</a>
</p>
<h3>Cleanup</h3>
<p>Once you're finished with experimenting and want to cleanup these pages,
you can simply delete them all.</p>
<h3>Formatting</h3>
<p>TriliumNext supports classic formatting like <em>italic</em>, <strong>bold</strong>, <em><strong>bold and italic</strong></em>.
Of course you can add links like this one pointing to <a href="http://www.google.com">google.com</a>
</p>
<p>Lists</p>
<p><strong>Ordered:</strong>
</p>
<ol>
<li>First Item</li>
@ -48,6 +54,7 @@
</li>
</ol>
<p><strong>Unordered:</strong>
</p>
<ul>
<li>Item</li>

View File

@ -14,17 +14,22 @@
<div class="ck-content">
<h2>Main characters</h2>
<p>… here put main characters …</p>
<p>&nbsp;</p>
<h2>Plot</h2>
<p>… describe main plot lines …</p>
<p>&nbsp;</p>
<h2>Tone</h2>
<p>&nbsp;</p>
<h2>Genre</h2>
<p>scifi / drama / romance</p>
<p>&nbsp;</p>
<h2>Similar books</h2>
<ul>
<li></li>
</ul>

View File

@ -14,11 +14,14 @@
<div class="ck-content">
<p>Checkout Kindle daily deals: <a href="https://www.amazon.com/gp/feature.html?docId=1000677541">https://www.amazon.com/gp/feature.html?docId=1000677541</a>
</p>
<ul>
<li>Cixin Liu - <a href="https://www.amazon.com/Dark-Forest-Remembrance-Earths-Past/dp/0765386690/ref=pd_bxgy_14_img_2?_encoding=UTF8&amp;pd_rd_i=0765386690&amp;pd_rd_r=AB0J179TM9NTEAMHE240&amp;pd_rd_w=FAhxX&amp;pd_rd_wg=pLGK7&amp;psc=1&amp;refRID=AB0J179TM9NTEAMHE240">The Dark Forest</a>
</li>
<li>Ann Leckie - <a href="https://www.amazon.com/Ancillary-Sword-Imperial-Radch-Leckie/dp/0316246654/ref=pd_sim_14_1?_encoding=UTF8&amp;pd_rd_i=0316246654&amp;pd_rd_r=D7KDTGZFP7YM1YSYVY4G&amp;pd_rd_w=jkn28&amp;pd_rd_wg=JVhtw&amp;psc=1&amp;refRID=D7KDTGZFP7YM1YSYVY4G">Ancillary Sword</a>
</li>
</ul>
</div>

View File

@ -18,21 +18,25 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">buy milk&nbsp;&nbsp;</span>
</label>
</li>
<li>
<label class="todo-list__label">
<input type="checkbox" checked="checked" disabled="disabled"><span class="todo-list__label__description">do the laundry&nbsp;&nbsp;</span>
</label>
</li>
<li>
<label class="todo-list__label">
<input type="checkbox" checked="checked" disabled="disabled"><span class="todo-list__label__description">watch TV&nbsp;&nbsp;</span>
</label>
</li>
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">eat ice cream&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -24,6 +24,7 @@
alert("Hello world");
}</code></pre>
<p>For larger pieces of code it is better to use a code note, which uses
a fully-fledged code editor (CodeMirror). For an example of a code note,
see&nbsp;<a class="reference-link" href="../Scripting%20examples/Custom%20request%20handler.js">Custom request handler</a>.</p>

View File

@ -15,7 +15,9 @@
<div class="ck-content">
<p><span class="math-tex">\(% \f is defined as #1f(#2) using the macro \f\relax{x} = \int_{-\infty}^\infty &nbsp; &nbsp; \f\hat\xi\,e^{2 \pi i \xi x} &nbsp; &nbsp; \,d\xi\)</span>Some
math examples:</p><span class="math-tex">\[\displaystyle \frac{1}{\Bigl(\sqrt{\phi \sqrt{5}}-\phi\Bigr) e^{\frac25 \pi}} = 1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}} {1+\frac{e^{-8\pi}} {1+\cdots} } } }\]</span>
<p>Another:</p><span class="math-tex">\[\displaystyle \left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)\]</span>
<p>Inline math is also possible:&nbsp;<span class="math-tex">\(c^2 = a^2 + b^2\)</span>&nbsp;</p>
<p>&nbsp;</p>
</div>

View File

@ -22,6 +22,7 @@
<p>This page demonstrates two things:</p>
<ul>
<li>possibility to <a href="#root/_hidden/_help/_help_KSZ04uQ2D1St/_help_iPIMuisry3hd/_help_nBAXQFj20hS1">include one note into another</a>
</li>
<li>PDF preview - you can read PDFs directly in Trilium!</li>
</ul>

View File

@ -14,6 +14,7 @@
<div class="ck-content">
<p>You can read some explanation on how this journal works here: <a href="https://github.com/zadam/trilium/wiki/Day-notes">https://github.com/zadam/trilium/wiki/Day-notes</a>
</p>
</div>
</div>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -17,6 +17,7 @@
<li>XBox</li>
<li>Candles</li>
<li><a href="https://www.amazon.ca/Anker-SoundCore-Portable-Bluetooth-Resistance/dp/B01MTB55WH?pd_rd_wg=honW8&amp;pd_rd_r=c9bb7c0f-0051-4da7-991f-4ca711a1b3e3&amp;pd_rd_w=ciUpR&amp;ref_=pd_gw_simh&amp;pf_rd_r=K10XKX0NGPDNTYYP4BS4&amp;pf_rd_p=5f1b460b-78c1-580e-929e-2878fe4859e8">Portable speakers</a>
</li>
<li>...?</li>
</ul>

View File

@ -14,8 +14,10 @@
<div class="ck-content">
<p>Wiki: <a href="https://en.wikipedia.org/wiki/Trusted_timestamping">https://en.wikipedia.org/wiki/Trusted_timestamping</a>
</p>
<p>Bozho: <a href="https://techblog.bozho.net/using-trusted-timestamping-java/">https://techblog.bozho.net/using-trusted-timestamping-java/</a>
</p>
<p><strong>Trusted timestamping</strong> is the process of <a href="https://en.wikipedia.org/wiki/Computer_security">securely</a> keeping
track of the creation and modification time of a document. Security here

View File

@ -16,6 +16,7 @@
<p>Miscellaneous notes done on monday ...</p>
<p>&nbsp;</p>
<p>Interesting video: <a href="https://www.youtube.com/watch?v=_eSAF_qT_FY&amp;feature=youtu.be">https://www.youtube.com/watch?v=_eSAF_qT_FY&amp;feature=youtu.be</a>
</p>
<p>&nbsp;</p>
<p>&nbsp;</p>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
width="209" height="300">
</figure>
<p>Maybe CodeNames? <a href="https://boardgamegeek.com/boardgame/178900/codenames">https://boardgamegeek.com/boardgame/178900/codenames</a>
</p>
</div>
</div>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -18,6 +18,7 @@
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">&nbsp;&nbsp;</span>
</label>
</li>
</ul>

View File

@ -24,14 +24,17 @@
<span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1"
data-footnote-id="6qz4pm021mi" role="doc-noteref" id="fnref6qz4pm021mi"><sup><a href="#fn6qz4pm021mi">[1]</a></sup>
</span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="6qz4pm021mi" role="doc-endnote" id="fn6qz4pm021mi"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="6qz4pm021mi"><sup><strong><a href="#fnref6qz4pm021mi">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -26,13 +26,16 @@
been brought to its knees.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="o6g991vkrwj" role="doc-noteref"
id="fnrefo6g991vkrwj"><sup><a href="#fno6g991vkrwj">[1]</a></sup></span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="o6g991vkrwj" role="doc-endnote" id="fno6g991vkrwj"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="o6g991vkrwj"><sup><strong><a href="#fnrefo6g991vkrwj">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -22,13 +22,16 @@
around 1450 in polished drystone walls.<span class="footnote-reference"
data-footnote-reference="" data-footnote-index="1" data-footnote-id="4prjheuho88"
role="doc-noteref" id="fnref4prjheuho88"><sup><a href="#fn4prjheuho88">[1]</a></sup></span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="4prjheuho88" role="doc-endnote" id="fn4prjheuho88"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="4prjheuho88"><sup><strong><a href="#fnref4prjheuho88">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -23,13 +23,16 @@
by earthquakes.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="ej5sd0bakne" role="doc-noteref"
id="fnrefej5sd0bakne"><sup><a href="#fnej5sd0bakne">[1]</a></sup></span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="ej5sd0bakne" role="doc-endnote" id="fnej5sd0bakne"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="ej5sd0bakne"><sup><strong><a href="#fnrefej5sd0bakne">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -26,14 +26,17 @@
<span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1"
data-footnote-id="4kitkusvyi3" role="doc-noteref" id="fnref4kitkusvyi3"><sup><a href="#fn4kitkusvyi3">[1]</a></sup>
</span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="4kitkusvyi3" role="doc-endnote" id="fn4kitkusvyi3"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="4kitkusvyi3"><sup><strong><a href="#fnref4kitkusvyi3">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -23,14 +23,17 @@
<span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1"
data-footnote-id="o0o2das7ljm" role="doc-noteref" id="fnrefo0o2das7ljm"><sup><a href="#fno0o2das7ljm">[1]</a></sup>
</span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="o0o2das7ljm" role="doc-endnote" id="fno0o2das7ljm"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="o0o2das7ljm"><sup><strong><a href="#fnrefo0o2das7ljm">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -23,13 +23,16 @@
the complex.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="zzzjn52iwk" role="doc-noteref"
id="fnrefzzzjn52iwk"><sup><a href="#fnzzzjn52iwk">[1]</a></sup></span>
</p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="zzzjn52iwk" role="doc-endnote" id="fnzzzjn52iwk"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="zzzjn52iwk"><sup><strong><a href="#fnrefzzzjn52iwk">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p><a href="https://www.thecollector.com/what-are-the-seven-wonders-of-the-world/">What Are the 7 Wonders of the World? (with HD Images) | TheCollector</a>
</p>
</div>
</li>

View File

@ -15,6 +15,7 @@
<div class="ck-content">
<p>This is a simple TODO/Task manager. You can see some description and explanation
here: <a href="https://github.com/zadam/trilium/wiki/Task-manager">https://github.com/zadam/trilium/wiki/Task-manager</a>
</p>
<p>Please note that this is meant as scripting example only and feature/bug
support is very limited.</p>

View File

@ -18,6 +18,7 @@
width="209" height="300">
</figure>
<p>Maybe CodeNames? <a href="https://boardgamegeek.com/boardgame/178900/codenames">https://boardgamegeek.com/boardgame/178900/codenames</a>
</p>
</div>
</div>

View File

@ -14,6 +14,7 @@
<div class="ck-content">
<p><a href="https://en.wikipedia.org/wiki/The_Black_Swan:_The_Impact_of_the_Highly_Improbable">https://en.wikipedia.org/wiki/The_Black_Swan:_The_Impact_of_the_Highly_Improbable</a>
</p>
<p><em><strong>The Black Swan: The Impact of the Highly Improbable</strong></em> is
a 2007 book by author and former <a href="https://en.wikipedia.org/wiki/Options_trader">options trader</a>

View File

@ -25,6 +25,7 @@
and <a href="https://en.wikipedia.org/wiki/Apple_Inc.">Apple's</a> <a href="https://en.wikipedia.org/wiki/MacOS">macOS</a> (formerly
OS X). A version <a href="https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux">is also available for Windows 10</a>.</p>
<p><a href="https://en.wikipedia.org/wiki/Bash_(Unix_shell)">Bash on Wikipedia</a>
</p>
</div>
</div>

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