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
246 changed files with 16097 additions and 4724 deletions

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

@@ -43,4 +43,6 @@ apps/*/out
upload upload
.rollup.cache .rollup.cache
*.tsbuildinfo *.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) * [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content * [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...) * Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
✨ Check out the following third-party resources/communities for more TriliumNext related goodies: ✨ Check out the following third-party resources/communities for more TriliumNext related goodies:

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": { "devDependencies": {
"@playwright/test": "1.52.0", "@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "4.4.0", "@stylistic/eslint-plugin": "4.4.1",
"@types/express": "5.0.1", "@types/express": "5.0.1",
"@types/node": "22.15.29", "@types/node": "22.15.30",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.1.4", "@vitest/coverage-v8": "3.2.2",
"eslint": "9.27.0", "eslint": "9.28.0",
"eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25", "esm": "3.2.25",
"jsdoc": "4.0.4", "jsdoc": "4.0.4",

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

View File

@@ -269,14 +269,32 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true; return true;
} }
const blob = await this.note.getBlob(); // Store the initial decision about read-only status in the viewScope
if (!blob) { // This will be "remembered" until the viewScope is refreshed
return false; if (!this.viewScope) {
this.resetViewScope();
} }
const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode"); const viewScope = this.viewScope!;
return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled"); 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");
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">) { 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 // it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu // might be handled again by top-level menu
return false; return false;
}) });
.on("mouseup", (e) =>{
if (!this.isMobile) {
$item.on("mouseup", (e) =>{
e.stopPropagation(); e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below. // Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide(); this.hide();
return false; return false;
}); });
}
if ("enabled" in item && item.enabled !== undefined && !item.enabled) { if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled"); $item.addClass("disabled");

View File

@@ -8,7 +8,7 @@ interface Entity {
export interface EntityChange { export interface EntityChange {
id?: number | null; id?: number | null;
noteId?: string; noteId?: string;
entityName: EntityRowNames; entityName: EntityType;
entityId: string; entityId: string;
entity?: Entity; entity?: Entity;
positions?: Record<string, number>; positions?: Record<string, number>;
@@ -22,3 +22,5 @@ export interface EntityChange {
changeId?: string | null; changeId?: string | null;
instanceId?: 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); loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === "attachments") { } else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec); processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") { } else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "note_embeddings") {
// NOOP // NOOP - these entities are handled at the backend level and don't require frontend processing
} else { } else {
throw new Error(`Unknown entityName '${ec.entityName}'`); throw new Error(`Unknown entityName '${ec.entityName}'`);
} }

View File

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

View File

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

View File

@@ -48,6 +48,13 @@ export interface ViewScope {
viewMode?: ViewMode; viewMode?: ViewMode;
attachmentId?: string; attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean; 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; highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean; highlightsListTemporarilyHidden?: boolean;
tocTemporarilyHidden?: boolean; tocTemporarilyHidden?: boolean;
@@ -204,11 +211,17 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
return {}; return {};
} }
url = url.trim();
const hashIdx = url.indexOf("#"); const hashIdx = url.indexOf("#");
if (hashIdx === -1) { if (hashIdx === -1) {
return {}; return {};
} }
// Exclude external links that contain #
if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString")) {
return {};
}
const hash = url.substr(hashIdx + 1); // strip also the initial '#' const hash = url.substr(hashIdx + 1); // strip also the initial '#'
let [notePath, paramString] = hash.split("?"); let [notePath, paramString] = hash.split("?");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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-children .close-floating-buttons-button::before,
div.floating-buttons .show-floating-buttons-button::before { div.floating-buttons .show-floating-buttons-button::before {
display: block; display: block;
line-height: 1;
} }
/* "Show buttons" button */ /* "Show buttons" button */

View File

@@ -1431,6 +1431,12 @@
"label": "Automatic read-only size (text notes)", "label": "Automatic read-only size (text notes)",
"unit": "characters" "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": { "i18n": {
"title": "Localization", "title": "Localization",
"language": "Language", "language": "Language",

View File

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

View File

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

View File

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

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="no-edited-notes-found">${t("edited_notes.no_edited_notes_found")}</div>
<div class="edited-notes-list"></div> <div class="edited-notes-list use-tn-links"></div>
</div> </div>
`; `;

View File

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

View File

@@ -189,7 +189,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
{ {
label: "Insert", label: "Insert",
icon: "plus", 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", "outdent",
@@ -244,7 +244,7 @@ export function buildFloatingToolbar() {
{ {
label: "Insert", label: "Insert",
icon: "plus", icon: "plus",
items: ["internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak"] items: ["bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
}, },
"|", "|",
"outdent", "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 TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
import HighlightsListOptions from "./options/text_notes/highlights_list.js"; import HighlightsListOptions from "./options/text_notes/highlights_list.js";
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.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 CodeEditorOptions from "./options/code_notes/code_editor.js";
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js"; import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js"; import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
@@ -88,7 +89,8 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAw
CodeBlockOptions, CodeBlockOptions,
TableOfContentsOptions, TableOfContentsOptions,
HighlightsListOptions, HighlightsListOptions,
TextAutoReadOnlySizeOptions TextAutoReadOnlySizeOptions,
DateTimeFormatOptions
], ],
_optionsCodeNotes: [ _optionsCodeNotes: [
CodeEditorOptions, CodeEditorOptions,

View File

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

View File

@@ -239,6 +239,9 @@ export default class GeoMapTypeWidget extends TypeWidget {
wptIcons: { wptIcons: {
"": this.#buildIcon("bx bx-pin") "": this.#buildIcon("bx bx-pin")
} }
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
} }
}); });
track.addTo(this.geoMapWidget.map); 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", "@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*", "@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.0", "copy-webpack-plugin": "13.0.0",
"electron": "36.3.2", "electron": "36.4.0",
"@electron-forge/cli": "7.8.1", "@electron-forge/cli": "7.8.1",
"@electron-forge/maker-deb": "7.8.1", "@electron-forge/maker-deb": "7.8.1",
"@electron-forge/maker-dmg": "7.8.1", "@electron-forge/maker-dmg": "7.8.1",
@@ -31,7 +31,6 @@
"config": { "config": {
"forge": "./electron-forge/forge.config.cjs" "forge": "./electron-forge/forge.config.cjs"
}, },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"scripts": { "scripts": {
"start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js" "start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js"
}, },

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 options from "@triliumnext/server/src/services/options.js";
import electronDebug from "electron-debug"; import electronDebug from "electron-debug";
import electronDl from "electron-dl"; import electronDl from "electron-dl";
import { deferred } from "@triliumnext/server/src/services/utils.js";
async function main() { async function main() {
const serverInitializedPromise = deferred<void>();
// Prevent Trilium starting twice on first install and on uninstall for the Windows installer. // Prevent Trilium starting twice on first install and on uninstall for the Windows installer.
if ((require("electron-squirrel-startup")).default) { if ((require("electron-squirrel-startup")).default) {
process.exit(0); 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.app.on("will-quit", () => {
electron.globalShortcut.unregisterAll(); electron.globalShortcut.unregisterAll();
@@ -47,7 +54,10 @@ async function main() {
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true"; process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
await initializeTranslations(); 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() { async function onReady() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,11 +14,14 @@
<div class="ck-content"> <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>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> </p>
<ul> <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>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>
<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>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> </li>
</ul> </ul>
</div> </div>

View File

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

View File

@@ -24,6 +24,7 @@
alert("Hello world"); alert("Hello world");
}</code></pre> }</code></pre>
<p>For larger pieces of code it is better to use a code note, which uses <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, 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> 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"> <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 <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> 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>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>Inline math is also possible:&nbsp;<span class="math-tex">\(c^2 = a^2 + b^2\)</span>&nbsp;</p>
<p>&nbsp;</p> <p>&nbsp;</p>
</div> </div>

View File

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

View File

@@ -14,6 +14,7 @@
<div class="ck-content"> <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>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> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -17,6 +17,7 @@
<li>XBox</li> <li>XBox</li>
<li>Candles</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><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>...?</li> <li>...?</li>
</ul> </ul>

View File

@@ -14,8 +14,10 @@
<div class="ck-content"> <div class="ck-content">
<p>Wiki: <a href="https://en.wikipedia.org/wiki/Trusted_timestamping">https://en.wikipedia.org/wiki/Trusted_timestamping</a> <p>Wiki: <a href="https://en.wikipedia.org/wiki/Trusted_timestamping">https://en.wikipedia.org/wiki/Trusted_timestamping</a>
</p> </p>
<p>Bozho: <a href="https://techblog.bozho.net/using-trusted-timestamping-java/">https://techblog.bozho.net/using-trusted-timestamping-java/</a> <p>Bozho: <a href="https://techblog.bozho.net/using-trusted-timestamping-java/">https://techblog.bozho.net/using-trusted-timestamping-java/</a>
</p> </p>
<p><strong>Trusted timestamping</strong> is the process of <a href="https://en.wikipedia.org/wiki/Computer_security">securely</a> keeping <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 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>Miscellaneous notes done on monday ...</p>
<p>&nbsp;</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>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>
<p>&nbsp;</p> <p>&nbsp;</p>
<p>&nbsp;</p> <p>&nbsp;</p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,14 +24,17 @@
<span <span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1" 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> data-footnote-id="6qz4pm021mi" role="doc-noteref" id="fnref6qz4pm021mi"><sup><a href="#fn6qz4pm021mi">[1]</a></sup>
</span> </span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -26,13 +26,16 @@
been brought to its knees.<span class="footnote-reference" data-footnote-reference="" been brought to its knees.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="o6g991vkrwj" role="doc-noteref" data-footnote-index="1" data-footnote-id="o6g991vkrwj" role="doc-noteref"
id="fnrefo6g991vkrwj"><sup><a href="#fno6g991vkrwj">[1]</a></sup></span> id="fnrefo6g991vkrwj"><sup><a href="#fno6g991vkrwj">[1]</a></sup></span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -22,13 +22,16 @@
around 1450 in polished drystone walls.<span class="footnote-reference" around 1450 in polished drystone walls.<span class="footnote-reference"
data-footnote-reference="" data-footnote-index="1" data-footnote-id="4prjheuho88" data-footnote-reference="" data-footnote-index="1" data-footnote-id="4prjheuho88"
role="doc-noteref" id="fnref4prjheuho88"><sup><a href="#fn4prjheuho88">[1]</a></sup></span> role="doc-noteref" id="fnref4prjheuho88"><sup><a href="#fn4prjheuho88">[1]</a></sup></span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -23,13 +23,16 @@
by earthquakes.<span class="footnote-reference" data-footnote-reference="" by earthquakes.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="ej5sd0bakne" role="doc-noteref" data-footnote-index="1" data-footnote-id="ej5sd0bakne" role="doc-noteref"
id="fnrefej5sd0bakne"><sup><a href="#fnej5sd0bakne">[1]</a></sup></span> id="fnrefej5sd0bakne"><sup><a href="#fnej5sd0bakne">[1]</a></sup></span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -26,14 +26,17 @@
<span <span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1" 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> data-footnote-id="4kitkusvyi3" role="doc-noteref" id="fnref4kitkusvyi3"><sup><a href="#fn4kitkusvyi3">[1]</a></sup>
</span> </span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -23,14 +23,17 @@
<span <span
class="footnote-reference" data-footnote-reference="" data-footnote-index="1" 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> data-footnote-id="o0o2das7ljm" role="doc-noteref" id="fnrefo0o2das7ljm"><sup><a href="#fno0o2das7ljm">[1]</a></sup>
</span> </span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

@@ -23,13 +23,16 @@
the complex.<span class="footnote-reference" data-footnote-reference="" the complex.<span class="footnote-reference" data-footnote-reference=""
data-footnote-index="1" data-footnote-id="zzzjn52iwk" role="doc-noteref" data-footnote-index="1" data-footnote-id="zzzjn52iwk" role="doc-noteref"
id="fnrefzzzjn52iwk"><sup><a href="#fnzzzjn52iwk">[1]</a></sup></span> id="fnrefzzzjn52iwk"><sup><a href="#fnzzzjn52iwk">[1]</a></sup></span>
</p> </p>
<ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes"> <ol class="footnote-section footnotes" data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1" <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> 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 <div
class="footnote-content" data-footnote-content=""> 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><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> </p>
</div> </div>
</li> </li>

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
<div class="ck-content"> <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><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>
<p><em><strong>The Black Swan: The Impact of the Highly Improbable</strong></em> is <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> 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 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> 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><a href="https://en.wikipedia.org/wiki/Bash_(Unix_shell)">Bash on Wikipedia</a>
</p> </p>
</div> </div>
</div> </div>

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