From 1acbf5ba45d1b1eef8a86f42a0503e597e33437e Mon Sep 17 00:00:00 2001
From: Elian Doran The resulting collection will contain all the children of the collection,
while maintaining the hierarchy.
#printLandscape.#printPageSize attribute,
with one of the following values: A0,
For example, to change the font of the document from the one defined by - the theme or the user to a serif one:
body {
- --main-font-family: serif !important;
- --detail-font-family: var(--main-font-family) !important;
+ the theme or the user to a serif one::root {
+ --print-font-family: serif;
+ --print-font-size: 11pt;
}
To remark:
- Multiple CSS notes can be add by using multiple
~printCss relations.
- - If the note pointing to the
printCss doesn't
+ - If the note pointing to the
printCss doesn't
have the right note type or mime type, it will be ignored.
- - If migrating from a previous version where Custom app-wide CSS, there's no need for
-
@media print { since the style-sheet is used only for printing.
+ - If migrating from a previous version where Custom app-wide CSS, there's no need for
+
@media print { since the style-sheet is used only for printing.
Under the hood
Both printing and exporting as PDF use the same mechanism: a note is rendered
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note types with split view.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note types with split view.html
index 0f3b39ee11..605062dcaa 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note types with split view.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note types with split view.html
@@ -1,8 +1,8 @@
-
Split view is a feature of Mermaid Diagrams and
+
Split view is a feature of Mermaid Diagrams and
Markdown notes which displays both the source code on one side
+ class="reference-link" href="#root/_help_6RM1Q7ppFVoj">Markdown notes which displays both the source code on one side
and the preview of the content on the other.
-Mermaid Diagrams also
+
Mermaid Diagrams also
allow changing between a horizontal or a vertical split, to accommodate
for the various sizes of diagrams.
Display modes and interaction
@@ -20,12 +20,12 @@
Preview which displays only the rendering of the diagram or text
in full screen, especially useful for read-only notes.
-These buttons can be found near the Note buttons section
- on the New Layout,
- or in the Floating buttons on
+
These buttons can be found near the Note buttons section
+ on the New Layout,
+ or in the Floating buttons on
the old layout.
The display node is stored at note level.
Relation to read-only notes
-If a note is marked as read-only,
- the source view will not be editable. While in preview mode, marking a
- note as read-only has no effect since the preview itself is not editable.
\ No newline at end of file
+If a note is marked as read-only, the
+ source view will not be editable. While in preview mode, marking a note
+ as read-only has no effect since the preview itself is not editable.
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Data directory.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Data directory.html
index fbd042717a..ecd52a4302 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Data directory.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Data directory.html
@@ -70,6 +70,21 @@
this:TRILIUM_DATA_DIR=/home/myuser/data/my-trilium-data trilium
You can then save the above command as a shell script on your path for
convenience.
+Electron user data directory (desktop only)
+When running the desktop application, Electron stores internal data (caches,
+ spell-check dictionaries, session storage, etc.) separately from the Trilium
+ data directory. By default this goes to the system's application data folder
+ (e.g. %APPDATA% on Windows), which may be
+ undesirable in corporate environments with roaming profiles or when running
+ in portable mode.
+When TRILIUM_DATA_DIR is set (e.g. via the
+ trilium-portablescript), the Electron user data is automatically
+ redirected into ${TRILIUM_DATA_DIR}/electron-user-data/,
+ so no files are written to the system's roaming profile.
+If you need to store the Electron data in a different location than the
+ Trilium data directory, you can set the TRILIUM_ELECTRON_DATA_DIR environment
+ variable to an explicit path.
Fine-grained directory/path location
Apart from the data directory, some of the subdirectories of it can be
moved elsewhere by changing an environment variable:
@@ -129,5 +144,13 @@
Path to Configuration (config.ini or environment variables) file.
+
+ TRILIUM_ELECTRON_DATA_DIR
+
+ ${TRILIUM_DATA_DIR}/electron-user-data (portable)
+ or system appData (default)
+ Directory where Electron stores internal data such as caches and spell-check
+ dictionaries (desktop only).
+
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Desktop Installation.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Desktop Installation.html
index 40d7ddd89c..e1ea2fc9b7 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Desktop Installation.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Desktop Installation.html
@@ -23,7 +23,9 @@
trilium-portable: Launches Trilium in
portable mode, where the data directory is
created within the application's directory, making it easy to move the
- entire setup.
+ entire setup. Electron's internal data (caches, dictionaries, etc.) is
+ also stored within the data directory, so no files are written to the system's
+ roaming profile.
trilium-safe-mode: Boots Trilium in "safe
mode," disabling any startup scripts that might cause the application to
crash.
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types.html
index 2607200081..d468c589c9 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types.html
@@ -9,8 +9,7 @@
note where to place the new one and select:
- Insert note after, to put the new note underneath the one selected.
- - Insert child note, to insert the note as a child of the selected
+
- Insert child note, to insert the note as a child of the selected
note.
@@ -21,8 +20,7 @@
When adding a link in a Text note, type the desired title of
the new note and press Enter. Afterwards the type of the note will be asked.
- Similarly, when creating a new tab, type the desired title and press Enter.
+ Similarly, when creating a new tab, type the desired title and press Enter.
Changing the type of a note
It is possible to change the type of a note after it has been created
@@ -32,96 +30,94 @@
edit the source of a note.
Supported note types
The following note types are supported by Trilium:
-
-
-
-
- Note Type
- Description
-
-
-
-
- Text
-
- The default note type, which allows for rich text formatting, images,
- admonitions and right-to-left support.
-
-
- Code
-
- Uses a mono-space font and can be used to store larger chunks of code
- or plain text than a text note, and has better syntax highlighting.
-
-
- Saved Search
-
- Stores the information about a search (the search text, criteria, etc.)
- for later use. Can be used for quick filtering of a large amount of notes,
- for example. The search can easily be triggered.
-
-
- Relation Map
-
- Allows easy creation of notes and relations between them. Can be used
- for mainly relational data such as a family tree.
-
-
- Note Map
-
- Displays the relationships between the notes, whether via relations or
- their hierarchical structure.
-
-
- Render Note
-
- Used in Scripting,
- it displays the HTML content of another note. This allows displaying any
- kind of content, provided there is a script behind it to generate it.
-
-
- Collections
-
- Displays the children of the note either as a grid, a list, or for a more
- specialized case: a calendar.
-
-
Generally useful for easy reading of short notes.
-
-
- Mermaid Diagrams
-
- Displays diagrams such as bar charts, flow charts, state diagrams, etc.
- Requires a bit of technical knowledge since the diagrams are written in
- a specialized format.
-
-
- Canvas
-
- Allows easy drawing of sketches, diagrams, handwritten content. Uses the
- same technology behind excalidraw.com.
-
-
- Web View
-
- Displays the content of an external web page, similar to a browser.
-
-
- Mind Map
-
- Easy for brainstorming ideas, by placing them in a hierarchical layout.
-
-
- Geo Map
-
- Displays the children of the note as a geographical map, one use-case
- would be to plan vacations. It even has basic support for tracks. Notes
- can also be created from it.
-
-
- File
-
- Represents an uploaded file such as PDFs, images, video or audio files.
-
-
-
-
\ No newline at end of file
+
+
+
+ Note Type
+ Description
+
+
+
+
+ Text
+
+ The default note type, which allows for rich text formatting, images,
+ admonitions and right-to-left support.
+
+
+ Code
+
+ Uses a mono-space font and can be used to store larger chunks of code
+ or plain text than a text note, and has better syntax highlighting.
+
+
+ Saved Search
+
+ Stores the information about a search (the search text, criteria, etc.)
+ for later use. Can be used for quick filtering of a large amount of notes,
+ for example. The search can easily be triggered.
+
+
+ Relation Map
+
+ Allows easy creation of notes and relations between them. Can be used
+ for mainly relational data such as a family tree.
+
+
+ Note Map
+
+ Displays the relationships between the notes, whether via relations or
+ their hierarchical structure.
+
+
+ Render Note
+
+ Used in Scripting,
+ it displays the HTML content of another note. This allows displaying any
+ kind of content, provided there is a script behind it to generate it.
+
+
+ Collections
+
+ Displays the children of the note either as a grid, a list, or for a more
+ specialized case: a calendar.
+
+
Generally useful for easy reading of short notes.
+
+
+ Mermaid Diagrams
+
+ Displays diagrams such as bar charts, flow charts, state diagrams, etc.
+ Requires a bit of technical knowledge since the diagrams are written in
+ a specialized format.
+
+
+ Canvas
+
+ Allows easy drawing of sketches, diagrams, handwritten content. Uses the
+ same technology behind excalidraw.com.
+
+
+ Web View
+
+ Displays the content of an external web page, similar to a browser.
+
+
+ Mind Map
+
+ Easy for brainstorming ideas, by placing them in a hierarchical layout.
+
+
+ Geo Map
+
+ Displays the children of the note as a geographical map, one use-case
+ would be to plan vacations. It even has basic support for tracks. Notes
+ can also be created from it.
+
+
+ File
+
+ Represents an uploaded file such as PDFs, images, video or audio files.
+
+
+
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File.html
index 152140d5ed..f64ba7eade 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File.html
@@ -5,8 +5,7 @@
create a File note type directly:
- Drag a file into the Note Tree.
- - Right click a note and select Import into note and point it to
+
- Right click a note and select Import into note and point it to
one of the supported files.
Supported file types
@@ -83,30 +82,28 @@
href="#root/_help_BlN9DFI679QC">Ribbon.
- Download, which will download the file for local use.
- - Open, will will open the file with the system-default application.
- - Upload new revision to replace the file with a new one.
+ - Open, will will open the file with the system-default application.
+ - Upload new revision to replace the file with a new one.
-
- It is not possible to change the note type of a File note.
- Convert into an attachment from the note menu.
+
+ It is not possible to change the note type of a File note.
+ Convert into an attachment from the note menu.
Relation with other notes
-
Files are also displayed in the Note List based
on their type:
-
-
-
+
+
+ -
+
Non-image files can be embedded into text notes as read-only widgets via
+ the Include Note functionality.
+
+ -
+
Image files can be embedded into text notes like normal images via
+ Image references.
- - Non-image files can be embedded into text notes as read-only widgets via
- the Include Note functionality.
- - Image files can be embedded into text notes like normal images via
- Image references.
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Markdown.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Markdown.html
index 74cf113776..2d3d41e7f9 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Markdown.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Markdown.html
@@ -1,13 +1,12 @@
-Trilium has always supported Markdown through its import feature,
+
Trilium has always supported Markdown through its import feature,
however the file was either transformed to a Text note
- (converted to Trilium's internal HTML format) or saved as a Code note
+ href="#root/_help_iPIMuisry3hd">Text note (converted to Trilium's internal
+ HTML format) or saved as a Code note
with only syntax highlight.
This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See Note types with split view for
- more information.
+ href="#root/_help_SL5f1Auq7sVN">Note types with split view for more
+ information.
Rationale
The goal of this note type is to fill a gap: rendering Markdown but not
altering its structure or its whitespace which would inevitably change
@@ -33,65 +32,53 @@
The following features are supported by Trilium's Markdown format and
will show up in the preview pane:
- - All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)
- - Code blocks with syntax highlight (e.g.
```js)
- and automatic syntax highlight
- - Block quotes & admonitions
-
- - Math Equations
-
- - Mermaid Diagrams using
-
```mermaid
-
- -
-
Include Note (no
- builtin Markdown syntax, but HTML syntax works just fine):
<section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable">
-
+ -
+
All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)
+
+ -
+
Code blocks with syntax highlight (e.g. <!--CODE_BLOCK_1776493385878_0-->mermaid
+
+
+ -
+
Include Note (no
+ builtin Markdown syntax, but HTML syntax works just fine):
<section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable">
+
</section>n
-
- -
-
Internal (reference) links via
- its HTML syntax, or through a Wikilinks-like format (only
- Note ID):
[[Hg8TS5ZOxti6]]
-
+
+ -
+
Internal (reference) links via
+ its HTML syntax, or through a Wikilinks-like format (only
+ Note ID):
[[Hg8TS5ZOxti6]]
+
Creating Markdown notes
There are two ways to create a Markdown note:
- - Create a new note (e.g. in the Note Tree)
+
- Create a new note (e.g. in the Note Tree)
and select the type Markdown, just like all the other note types.
- - Create a note of type Code and
- select as the language either Markdown or GitHub-Flavored Markdown.
+
- Create a note of type Code and
+ select as the language either Markdown or GitHub-Flavored Markdown.
This maintains compatibility with your existing notes prior to the introduction
of this feature.
Import/export
-
By default, when importing a single Markdown file it automatically gets
- converted to a Text note.
+ converted to a Text note.
To avoid that and have it imported as a Markdown note instead:
- -
-
Right click the Note Tree and
- select Import into note.
-
- -
-
Select the file normally.
-
- -
-
Uncheck Import HTML, Markdown and TXT as text notes if it's unclear from the metadata.
-
+ - Right click the Note Tree and
+ select Import into note.
+ - Select the file normally.
+ - Uncheck Import HTML, Markdown and TXT as text notes if it's unclear from the metadata.
-
@@ -105,9 +92,8 @@
Conversion between text notes and Markdown notes
Currently there is no built-in functionality to convert a Text note
- into a Markdown note or vice-versa. We do have plans to address this in
- the future.
+ href="#root/_help_iPIMuisry3hd">Text note into a Markdown note or vice-versa.
+ We do have plans to address this in the future.
This can be achieved manually, for a single note:
- Export the file as Markdown, with single format.
@@ -135,6 +121,6 @@
This feature of synchronizing the scroll is based on blocks but it's provided
on a best-effort basis since our underlying Markdown library doesn't support
this feature natively, so we had to implement our own algorithm. Feel free
- to report issues,
- but always provide a sample Markdown file to be able to reproduce it.
+ to report issues, but always provide a
+ sample Markdown file to be able to reproduce it.
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Mermaid Diagrams.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Mermaid Diagrams.html
index d6ff44cd93..a17a7ea0a8 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Mermaid Diagrams.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Mermaid Diagrams.html
@@ -12,8 +12,8 @@
the diagram.
This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See Note types with split view for
- more information.
+ href="#root/_help_SL5f1Auq7sVN">Note types with split view for more
+ information.
Sample diagrams
Starting with v0.103.0, Mermaid diagrams no longer start with a sample
flowchart, but instead a pane at the bottom will show all the supported
@@ -52,34 +52,30 @@
- The preview can be moved around by holding the left mouse button and dragging.
- - Zooming can also be done by using the scroll wheel.
- - The zoom and position on the preview will remain fixed as the diagram
- changes, to be able to work more easily with large diagrams.
-
+ - Zooming can also be done by using the scroll wheel.
+ - The zoom and position on the preview will remain fixed as the diagram
+ changes, to be able to work more easily with large diagrams.
+
- The size of the source/preview panes can be adjusted by hovering over
the border between them and dragging it with the mouse.
- In the Floating buttons area:
- The source/preview can be laid out left-right or bottom-top via the Move editing pane to the left / bottom option.
- - Press Lock editing to automatically mark the note as read-only.
+
- Press Lock editing to automatically mark the note as read-only.
In this mode, the code pane is hidden and the diagram is displayed full-size.
Similarly, press Unlock editing to mark a read-only note as editable.
- - Press the Copy image reference to the clipboard to be able to insert
- the image representation of the diagram into a text note. See Image references for more information.
- - Press the Export diagram as SVG to download a scalable/vector rendering
- of the diagram. Can be used to present the diagram without degrading when
- zooming.
+ - Press the Copy image reference to the clipboard to be able to insert
+ the image representation of the diagram into a text note. See Image references for more information.
+ - Press the Export diagram as SVG to download a scalable/vector rendering
+ of the diagram. Can be used to present the diagram without degrading when
+ zooming.
- Press the Export diagram as PNG to download a normal image (at
1x scale, raster) of the diagram. Can be used to send the diagram in more
traditional channels such as e-mail.
-
-
+
+
Errors in the diagram
If there is an error in the source code, the error will be displayed in
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Render Note.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Render Note.html
index 4a33c30bfe..efff5c09b9 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Render Note.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Render Note.html
@@ -13,13 +13,11 @@
- HTML language for the legacy/vanilla method, with what needs to be displayed
(for example
<p>Hello world.</p>).
- - JSX for the Preact-based approach (see below).
-
+ - JSX for the Preact-based approach (see below).
+
Create a Render Note.
- Assign the renderNote relation to
+ Assign the renderNote relation to
point at the previously created code note.
Legacy scripting using jQuery
@@ -48,9 +46,10 @@ $dateEl.text(new Date());
need to provide a HTML anymore.
Here are the steps to creating a simple render note:
Create a note of type Render Note.
+Create a child Code note
with JSX as the language.
As an example, use the following content:
export default function() {
@@ -60,17 +59,20 @@ $dateEl.text(new Date());
</>
);
}
- ~renderNote relation
- pointing to the newly created child.In the parent render note, define a ~renderNote relation
+ pointing to the newly created child.
Refresh the render note and it should display a “Hello world” message.
+It's possible to refresh the note via:
If you would like us to work on these features, consider supporting us.
Fore more information see Formatting toolbar.
Here's a list of various features supported by text notes:
-| Dedicated article | -Feature | -
|---|---|
| General formatting - | -
-
|
-
| Lists - | -
-
|
-
| Block quotes & admonitions - | -
-
|
-
| Tables - | -
-
|
-
| Developer-specific formatting - | -
-
|
-
| Footnotes - | -
-
|
-
| Images - | -
-
|
-
| Links - | -
-
|
-
| Include Note - | -
-
|
-
| Insert buttons - | -
-
|
-
| Other features - | -
-
|
-
| Premium features - | -
-
|
-
Text notes are usually opened in edit mode. However, they may open in - read-only mode if the note is too big or the note is explicitly marked - as read-only. For more information, see Read-Only Notes.
-There are numerous keyboard shortcuts to format the text without having - to use the mouse. For a reference of all the key combinations, see - Keyboard Shortcuts. In addition, see Markdown-like formatting as an alternative - to the keyboard shortcuts.
-For the text editing functionality, Trilium uses a commercial product - (with an open-source base) called CKEditor. - This brings the benefit of having a powerful WYSIWYG (What You See Is What - You Get) editor.
\ No newline at end of file +| Dedicated article | +Feature | +
|---|---|
| General formatting + | +
+
|
+
| Lists + | +
+
|
+
| Block quotes & admonitions + | +
+
|
+
| Tables + | +
+
|
+
| Developer-specific formatting + | +
+
|
+
| Footnotes + | +
+
|
+
| Images + | +
+
|
+
| Links + | +
+
|
+
| Include Note + | +
+
|
+
| Insert buttons + | +
+
|
+
| Other features + | +
+
|
+
| Premium features + | +
+
|
+
Text notes are usually opened in edit mode. However, they may open in + read-only mode if the note is too big or the note is explicitly marked + as read-only. For more information, see Read-Only Notes.
+There are numerous keyboard shortcuts to format the text without having + to use the mouse. For a reference of all the key combinations, see + Keyboard Shortcuts. In addition, see Markdown-like formatting as an alternative + to the keyboard shortcuts.
+For the text editing functionality, Trilium uses a commercial product + (with an open-source base) called CKEditor. + This brings the benefit of having a powerful WYSIWYG (What You See Is What + You Get) editor.
\ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Documentation.md b/docs/Developer Guide/Developer Guide/Documentation.md index aae807a49d..8b2fdc9998 100644 --- a/docs/Developer Guide/Developer Guide/Documentation.md +++ b/docs/Developer Guide/Developer Guide/Documentation.md @@ -1,5 +1,5 @@ # Documentation -There are multiple types of documentation for Trilium:
+There are multiple types of documentation for Trilium:
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing F1.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.
diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json
index d1b8f5d265..92af202232 100644
--- a/docs/User Guide/!!!meta.json
+++ b/docs/User Guide/!!!meta.json
@@ -3984,42 +3984,42 @@
"name": "internalLink",
"value": "s1aBHPd79XYj",
"isInheritable": false,
- "position": 30
+ "position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "6RM1Q7ppFVoj",
"isInheritable": false,
- "position": 40
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "CoFPLs3dRlXc",
- "isInheritable": false,
- "position": 50
+ "position": 20
},
{
"type": "relation",
"name": "internalLink",
"value": "8YBEPzcpUgxw",
"isInheritable": false,
- "position": 60
+ "position": 30
},
{
"type": "relation",
"name": "internalLink",
"value": "IjZS7iK5EXtb",
"isInheritable": false,
- "position": 70
+ "position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "XpOYSgsLkTJy",
"isInheritable": false,
- "position": 80
+ "position": 50
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "CoFPLs3dRlXc",
+ "isInheritable": false,
+ "position": 60
},
{
"type": "label",
@@ -10147,17 +10147,24 @@
{
"type": "relation",
"name": "internalLink",
- "value": "XpOYSgsLkTJy",
+ "value": "SL5f1Auq7sVN",
"isInheritable": false,
"position": 20
},
{
"type": "relation",
"name": "internalLink",
- "value": "0Ofbk1aSuVRu",
+ "value": "XpOYSgsLkTJy",
"isInheritable": false,
"position": 30
},
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "0Ofbk1aSuVRu",
+ "isInheritable": false,
+ "position": 40
+ },
{
"type": "label",
"name": "shareAlias",
@@ -10171,13 +10178,6 @@
"value": "bx bx-selection",
"isInheritable": false,
"position": 20
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "SL5f1Auq7sVN",
- "isInheritable": false,
- "position": 40
}
],
"format": "markdown",
@@ -10839,39 +10839,53 @@
"type": "text",
"mime": "text/html",
"attributes": [
- {
- "type": "label",
- "name": "iconClass",
- "value": "bx bxl-markdown",
- "isInheritable": false,
- "position": 30
- },
- {
- "type": "label",
- "name": "shareAlias",
- "value": "markdown",
- "isInheritable": false,
- "position": 40
- },
{
"type": "relation",
"name": "internalLink",
"value": "Oau6X9rCuegd",
"isInheritable": false,
- "position": 50
+ "position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "iPIMuisry3hd",
"isInheritable": false,
- "position": 60
+ "position": 20
},
{
"type": "relation",
"name": "internalLink",
"value": "6f9hih2hXXZk",
"isInheritable": false,
+ "position": 30
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "SL5f1Auq7sVN",
+ "isInheritable": false,
+ "position": 40
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "nBAXQFj20hS1",
+ "isInheritable": false,
+ "position": 50
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "hrZ1D00cLbal",
+ "isInheritable": false,
+ "position": 60
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "m1lbrzyKDaRB",
+ "isInheritable": false,
"position": 70
},
{
@@ -10886,56 +10900,21 @@
"name": "internalLink",
"value": "wy8So3yZZlH9",
"isInheritable": false,
- "position": 150
+ "position": 90
},
{
- "type": "relation",
- "name": "internalLink",
- "value": "SL5f1Auq7sVN",
+ "type": "label",
+ "name": "iconClass",
+ "value": "bx bxl-markdown",
"isInheritable": false,
- "position": 160
+ "position": 30
},
{
- "type": "relation",
- "name": "internalLink",
- "value": "NwBbFdNZ9h7O",
+ "type": "label",
+ "name": "shareAlias",
+ "value": "markdown",
"isInheritable": false,
- "position": 170
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "YfYAtQBcfo5V",
- "isInheritable": false,
- "position": 180
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "s1aBHPd79XYj",
- "isInheritable": false,
- "position": 190
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "nBAXQFj20hS1",
- "isInheritable": false,
- "position": 200
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "hrZ1D00cLbal",
- "isInheritable": false,
- "position": 210
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "m1lbrzyKDaRB",
- "isInheritable": false,
- "position": 220
+ "position": 40
}
],
"format": "markdown",
diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
index b110d09b03..221ba93c9a 100644
--- a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
+++ b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
@@ -99,9 +99,9 @@ To do so:
For example, to change the font of the document from the one defined by the theme or the user to a serif one:
```
-body {
- --main-font-family: serif !important;
- --detail-font-family: var(--main-font-family) !important;
+:root {
+ --print-font-family: serif;
+ --print-font-size: 11pt;
}
```
diff --git a/docs/User Guide/User Guide/Installation & Setup/Data directory.md b/docs/User Guide/User Guide/Installation & Setup/Data directory.md
index 3a447f82f2..b9df162b9e 100644
--- a/docs/User Guide/User Guide/Installation & Setup/Data directory.md
+++ b/docs/User Guide/User Guide/Installation & Setup/Data directory.md
@@ -97,4 +97,4 @@ Apart from the data directory, some of the subdirectories of it can be moved els
| `TRILIUM_TMP_DIR` | `${TRILIUM_DATA_DIR}/tmp` | Directory where temporary files are stored (for example when opening in an external app). |
| `TRILIUM_ANONYMIZED_DB_DIR` | `${TRILIUM_DATA_DIR}/anonymized-db` | Directory where a Anonymized Database is stored. |
| `TRILIUM_CONFIG_INI_PATH` | `${TRILIUM_DATA_DIR}/config.ini` | Path to Configuration (config.ini or environment variables) file. |
-| `TRILIUM_ELECTRON_DATA_DIR` | `${TRILIUM_DATA_DIR}/electron-user-data` (portable) or system appData (default) | Directory where Electron stores internal data such as caches and spell-check dictionaries (desktop only). |
+| `TRILIUM_ELECTRON_DATA_DIR` | `${TRILIUM_DATA_DIR}/electron-user-data` (portable) or system appData (default) | Directory where Electron stores internal data such as caches and spell-check dictionaries (desktop only). |
\ No newline at end of file
diff --git a/docs/User Guide/User Guide/Note Types/Markdown.md b/docs/User Guide/User Guide/Note Types/Markdown.md
index b96b627329..4913eb5f83 100644
--- a/docs/User Guide/User Guide/Note Types/Markdown.md
+++ b/docs/User Guide/User Guide/Note Types/Markdown.md
@@ -25,15 +25,12 @@ Even if Markdown is now specially treated by having a preview mechanism, Trilium
The following features are supported by Trilium's Markdown format and will show up in the preview pane:
* All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)
-* Code blocks with syntax highlight (e.g. ` ```js `) and automatic syntax highlight
-* Block quotes & admonitions
-* Math Equations
-* Mermaid Diagrams using ` ```mermaid `
+* Code blocks with syntax highlight (e.g. `mermaid`
* Include Note (no builtin Markdown syntax, but HTML syntax works just fine):
```
For example, to change the font of the document from the one defined by - the theme or the user to a serif one:
:root {
+ the theme or the user to a serif one:body {
--print-font-family: serif;
--print-font-size: 11pt;
}
+
To remark:
- Multiple CSS notes can be add by using multiple
~printCss relations.
diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
index 221ba93c9a..7055825a43 100644
--- a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
+++ b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.md
@@ -99,12 +99,15 @@ To do so:
For example, to change the font of the document from the one defined by the theme or the user to a serif one:
```
-:root {
+body {
--print-font-family: serif;
--print-font-size: 11pt;
}
```
+> [!IMPORTANT]
+> When altering `--print-font-family`, make sure the change is done at `body` level and not `:root`, since otherwise it won't be picked up due to specificity rules.
+
To remark:
* Multiple CSS notes can be add by using multiple `~printCss` relations.
From cc010e15680713aea8ed704827075b77341bf0ca Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 09:54:19 +0300
Subject: [PATCH 13/79] chore(desktop/appimage): fix path for packaging
---
apps/desktop/scripts/build-appimage.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/scripts/build-appimage.sh b/apps/desktop/scripts/build-appimage.sh
index d4016ce134..ec728470a9 100755
--- a/apps/desktop/scripts/build-appimage.sh
+++ b/apps/desktop/scripts/build-appimage.sh
@@ -30,7 +30,7 @@ case "$ARCH" in
esac
# Find the packaged app directory
-PACKAGED_DIR="$DESKTOP_DIR/out/$PRODUCT_NAME-linux-$ARCH"
+PACKAGED_DIR="$DESKTOP_DIR/dist/out/$PRODUCT_NAME-linux-$ARCH"
if [ ! -d "$PACKAGED_DIR" ]; then
echo "Error: Packaged app not found at $PACKAGED_DIR"
echo "Run 'electron-forge make' or 'electron-forge package' first."
@@ -40,7 +40,7 @@ fi
echo "Building AppImage from: $PACKAGED_DIR"
# Create AppDir structure
-APPDIR="$DESKTOP_DIR/out/$PRODUCT_NAME.AppDir"
+APPDIR="$DESKTOP_DIR/dist/out/$PRODUCT_NAME.AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR"
From c0b1ff31e5c51c539510b3d65e58dba0d95f678c Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 09:56:41 +0300
Subject: [PATCH 14/79] fix(server): safe import strips out bookmarks from note
---
.../src/services/html_sanitizer.spec.ts | 22 +++++++++++++++++++
apps/server/src/services/html_sanitizer.ts | 1 +
2 files changed, 23 insertions(+)
diff --git a/apps/server/src/services/html_sanitizer.spec.ts b/apps/server/src/services/html_sanitizer.spec.ts
index dfbba8fd71..6af366a329 100644
--- a/apps/server/src/services/html_sanitizer.spec.ts
+++ b/apps/server/src/services/html_sanitizer.spec.ts
@@ -50,4 +50,26 @@ describe("sanitize", () => {
`;
expect(html_sanitizer.sanitize(dirty)).toBe(clean);
});
+
+ describe("bookmark anchors", () => {
+ it("preserves id attribute on empty tags (CKEditor bookmarks)", () => {
+ const dirty = ``;
+ expect(html_sanitizer.sanitize(dirty)).toBe(dirty);
+ });
+
+ it("preserves id attribute on tags with bookmark class", () => {
+ const dirty = ``;
+ expect(html_sanitizer.sanitize(dirty)).toBe(dirty);
+ });
+
+ it("strips id attribute from non-anchor tags to prevent DOM clobbering", () => {
+ const dirty = `content`;
+ expect(html_sanitizer.sanitize(dirty)).toBe(`content`);
+ });
+
+ it("strips id attribute from
tags to prevent DOM clobbering", () => {
+ const dirty = `
`;
+ expect(html_sanitizer.sanitize(dirty)).toBe(`
`);
+ });
+ });
});
diff --git a/apps/server/src/services/html_sanitizer.ts b/apps/server/src/services/html_sanitizer.ts
index f304dcf150..76262c2a32 100644
--- a/apps/server/src/services/html_sanitizer.ts
+++ b/apps/server/src/services/html_sanitizer.ts
@@ -42,6 +42,7 @@ function sanitize(dirtyHtml: string) {
allowedTags: allowedTags as string[],
allowedAttributes: {
"*": ["class", "style", "title", "src", "href", "hash", "disabled", "align", "alt", "center", "data-*"],
+ a: ["id"], // CKEditor bookmark anchors use
input: ["type", "checked"],
img: ["width", "height"],
code: [ "spellcheck" ]
From 5b957dd111c0f6b9443a69dfc3ee9832ab7cdf52 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:06:32 +0300
Subject: [PATCH 15/79] chore(server): start processing bookmarks
---
apps/server/src/services/notes.spec.ts | 34 ++++++++++++++++++
apps/server/src/services/notes.ts | 49 ++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
create mode 100644 apps/server/src/services/notes.spec.ts
diff --git a/apps/server/src/services/notes.spec.ts b/apps/server/src/services/notes.spec.ts
new file mode 100644
index 0000000000..f19b9be4b7
--- /dev/null
+++ b/apps/server/src/services/notes.spec.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { findBookmarks } from "./notes.js";
+
+describe("findBookmarks", () => {
+ it("extracts bookmark IDs from empty anchor tags", () => {
+ const content = `Hello
World
`;
+ expect(findBookmarks(content)).toEqual(["chapter-1"]);
+ });
+
+ it("extracts multiple bookmarks", () => {
+ const content = `Text
`;
+ expect(findBookmarks(content)).toEqual(["intro", "conclusion"]);
+ });
+
+ it("returns empty array when no bookmarks exist", () => {
+ const content = `No bookmarks here
`;
+ expect(findBookmarks(content)).toEqual([]);
+ });
+
+ it("ignores anchor tags with href (regular links, not bookmarks)", () => {
+ const content = `link`;
+ expect(findBookmarks(content)).toEqual([]);
+ });
+
+ it("handles bookmarks with various valid ID characters", () => {
+ const content = ``;
+ expect(findBookmarks(content)).toEqual(["my_bookmark-2.0"]);
+ });
+
+ it("does not produce duplicates", () => {
+ const content = ``;
+ expect(findBookmarks(content)).toEqual(["same"]);
+ });
+});
diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts
index 708cab285d..719afbe225 100644
--- a/apps/server/src/services/notes.ts
+++ b/apps/server/src/services/notes.ts
@@ -454,6 +454,54 @@ function findImageLinks(content: string, foundLinks: FoundLink[]) {
return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/');
}
+/**
+ * Extracts bookmark IDs from CKEditor bookmark anchors (`` without href).
+ * Bookmarks are stored as labels on the note so they can be looked up without parsing content.
+ */
+export function findBookmarks(content: string): string[] {
+ const re = /]*><\/a>/g;
+ const bookmarks: string[] = [];
+ let match;
+
+ while ((match = re.exec(content))) {
+ // Skip anchors that also have an href (those are regular links, not bookmarks)
+ if (match[0].includes("href=")) {
+ continue;
+ }
+
+ const id = match[1];
+ if (!bookmarks.includes(id)) {
+ bookmarks.push(id);
+ }
+ }
+
+ return bookmarks;
+}
+
+function saveBookmarks(note: BNote, content: string) {
+ const foundBookmarks = findBookmarks(content);
+ const existingBookmarks = note.getLabels("internalBookmark");
+
+ for (const bookmarkId of foundBookmarks) {
+ const existing = existingBookmarks.find((l) => l.value === bookmarkId);
+
+ if (!existing) {
+ new BAttribute({
+ noteId: note.noteId,
+ type: "label",
+ name: "internalBookmark",
+ value: bookmarkId
+ }).save();
+ }
+ }
+
+ // Remove bookmarks that are no longer in the content
+ const unusedBookmarks = existingBookmarks.filter((l) => !foundBookmarks.includes(l.value));
+ for (const unused of unusedBookmarks) {
+ unused.markAsDeleted();
+ }
+}
+
function findInternalLinks(content: string, foundLinks: FoundLink[]) {
const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g;
let match;
@@ -695,6 +743,7 @@ function saveLinks(note: BNote, content: string | Buffer) {
content = findImageLinks(content, foundLinks);
content = findInternalLinks(content, foundLinks);
content = findIncludeNoteLinks(content, foundLinks);
+ saveBookmarks(note, content);
({ forceFrontendReload, content } = checkImageAttachments(note, content));
} else if (note.type === "relationMap" && typeof content === "string") {
From 79d639108b75a03d297064b5ffe47e5045b568af Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:12:41 +0300
Subject: [PATCH 16/79] feat(status_bar): display system links in dev mode
---
apps/client/src/widgets/layout/StatusBar.css | 3 +-
apps/client/src/widgets/layout/StatusBar.tsx | 6 ++
.../widgets/ribbon/AutoLinkAttributesTab.tsx | 65 +++++++++++++++++++
3 files changed, 73 insertions(+), 1 deletion(-)
create mode 100644 apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx
diff --git a/apps/client/src/widgets/layout/StatusBar.css b/apps/client/src/widgets/layout/StatusBar.css
index c8d01c83be..f0a10ab8b8 100644
--- a/apps/client/src/widgets/layout/StatusBar.css
+++ b/apps/client/src/widgets/layout/StatusBar.css
@@ -87,7 +87,8 @@
font-weight: 600;
}
- .inherited-attributes-widget {
+ .inherited-attributes-widget,
+ .auto-link-attributes-widget {
display: inline;
> div {
diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx
index 077c59ef4f..50657867cd 100644
--- a/apps/client/src/widgets/layout/StatusBar.tsx
+++ b/apps/client/src/widgets/layout/StatusBar.tsx
@@ -26,6 +26,7 @@ import LinkButton from "../react/LinkButton";
import { ParentComponent } from "../react/react_utils";
import { ContentLanguagesModal, NoteTypeCodeNoteList, NoteTypeOptionsModal, useLanguageSwitcher, useMimeTypes } from "../ribbon/BasicPropertiesTab";
import AttributeEditor, { AttributeEditorImperativeHandlers } from "../ribbon/components/AttributeEditor";
+import AutoLinkAttributesTab from "../ribbon/AutoLinkAttributesTab";
import InheritedAttributesTab from "../ribbon/InheritedAttributesTab";
import { NoteSizeWidget, useNoteMetadata } from "../ribbon/NoteInfoTab";
import { NotePathsWidget, useSortedNotePaths } from "../ribbon/NotePathsTab";
@@ -401,6 +402,11 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown
{t("inherited_attribute_list.title")}
+ {glob.isDev &&
+ {t("auto_link_attribute_list.title")}
+
+ }
+
;
+
+export default function AutoLinkAttributesTab({ note, componentId }: AutoLinkAttributesTabArgs) {
+ const [autoLinkAttributes, setAutoLinkAttributes] = useState();
+
+ function refresh() {
+ if (!note) return;
+ const attrs = note.getAttributes().filter((attr) => attr.isAutoLink && attr.noteId === note.noteId);
+ setAutoLinkAttributes(attrs);
+ }
+
+ useEffect(refresh, [note]);
+ useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
+ if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
+ refresh();
+ }
+ });
+
+ if (!autoLinkAttributes?.length) {
+ return null;
+ }
+
+ return (
+
+
+ {joinElements(autoLinkAttributes.map((attribute) => (
+
+ )), " ")}
+
+
+ );
+}
+
+function AutoLinkAttribute({ attribute }: { attribute: FAttribute }) {
+ const [html, setHtml] = useState("");
+
+ useEffect(() => {
+ renderAutoLink(attribute).then(setHtml);
+ }, [attribute]);
+
+ return ;
+}
+
+async function renderAutoLink(attribute: FAttribute) {
+ const note = await froca.getNote(attribute.value);
+ if (!note) return "";
+
+ const link = `${escapeHtml(note.title)}`;
+ return `~${escapeHtml(attribute.name)}=${link}`;
+}
+
+function escapeHtml(text: string) {
+ const el = document.createElement("span");
+ el.textContent = text;
+ return el.innerHTML;
+}
From bdf4e40577b712d9deb619894572358a35c9d9d1 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:14:58 +0300
Subject: [PATCH 17/79] fix(server): bookmarks not processed due to
self-closing tag
---
apps/client/src/translations/en/translation.json | 3 +++
apps/server/src/services/notes.spec.ts | 8 ++++++++
apps/server/src/services/notes.ts | 2 +-
3 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index e284c16b78..ef322e1fb1 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -860,6 +860,9 @@
"no_inherited_attributes": "No inherited attributes.",
"none": "none"
},
+ "auto_link_attribute_list": {
+ "title": "System Links"
+ },
"note_info_widget": {
"note_id": "Note ID",
"created": "Created",
diff --git a/apps/server/src/services/notes.spec.ts b/apps/server/src/services/notes.spec.ts
index f19b9be4b7..38d99fc7d7 100644
--- a/apps/server/src/services/notes.spec.ts
+++ b/apps/server/src/services/notes.spec.ts
@@ -31,4 +31,12 @@ describe("findBookmarks", () => {
const content = ``;
expect(findBookmarks(content)).toEqual(["same"]);
});
+
+ it("matches self-closing bookmark anchors (CKEditor empty elements)", () => {
+ const content = `Text
More
`;
+ // CKEditor may also output without closing tag
+ const contentNoClose = `Text
More
`;
+ expect(findBookmarks(content)).toEqual(["my-bookmark"]);
+ expect(findBookmarks(contentNoClose)).toEqual(["my-bookmark"]);
+ });
});
diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts
index 719afbe225..56499f0288 100644
--- a/apps/server/src/services/notes.ts
+++ b/apps/server/src/services/notes.ts
@@ -459,7 +459,7 @@ function findImageLinks(content: string, foundLinks: FoundLink[]) {
* Bookmarks are stored as labels on the note so they can be looked up without parsing content.
*/
export function findBookmarks(content: string): string[] {
- const re = /]*><\/a>/g;
+ const re = /]*>(<\/a>)?/g;
const bookmarks: string[] = [];
let match;
From 84fff307230ff288b054f85a49294d7fa872143c Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:17:39 +0300
Subject: [PATCH 18/79] feat(server): mark bookmarks as internal links
---
apps/client/src/entities/fattribute.ts | 10 +++++++++-
apps/server/src/services/attributes.ts | 2 +-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/entities/fattribute.ts b/apps/client/src/entities/fattribute.ts
index 07a2b22c4f..bdccead31d 100644
--- a/apps/client/src/entities/fattribute.ts
+++ b/apps/client/src/entities/fattribute.ts
@@ -66,7 +66,15 @@ class FAttribute {
}
get isAutoLink() {
- return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
+ if (this.type === "relation") {
+ return ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
+ }
+
+ if (this.type === "label") {
+ return this.name === "internalBookmark";
+ }
+
+ return false;
}
get toString() {
diff --git a/apps/server/src/services/attributes.ts b/apps/server/src/services/attributes.ts
index 2e1a207447..c9642d4777 100644
--- a/apps/server/src/services/attributes.ts
+++ b/apps/server/src/services/attributes.ts
@@ -77,7 +77,7 @@ function getAttributeNames(type: string, nameLike: string) {
}
}
- names = names.filter((name) => !["internalLink", "imageLink", "includeNoteLink", "relationMapLink"].includes(name));
+ names = names.filter((name) => !["internalLink", "imageLink", "includeNoteLink", "relationMapLink", "internalBookmark"].includes(name));
names.sort((a, b) => {
const aPrefix = a.toLowerCase().startsWith(nameLike);
From 4dcbd36b2de94d37f1e9ef32dd366695cf762350 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:22:58 +0300
Subject: [PATCH 19/79] feat(text): add a slash command for bookmarks
---
packages/ckeditor5/src/extra_slash_commands.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/packages/ckeditor5/src/extra_slash_commands.ts b/packages/ckeditor5/src/extra_slash_commands.ts
index ee6068dc3d..fb86e18efa 100644
--- a/packages/ckeditor5/src/extra_slash_commands.ts
+++ b/packages/ckeditor5/src/extra_slash_commands.ts
@@ -17,6 +17,8 @@ import internalLinkIcon from './icons/trilium.svg?raw';
import noteIcon from './icons/note.svg?raw';
import importMarkdownIcon from './icons/markdown-mark.svg?raw';
import { icons as mathIcons, MathUI } from '@triliumnext/ckeditor5-math';
+import { BookmarkUI } from "ckeditor5";
+import bxBookmark from "boxicons/svg/regular/bx-bookmark.svg?raw";
type SlashCommandDefinition = SlashCommandEditorConfig["extraCommands"][number];
@@ -74,6 +76,19 @@ export default function buildExtraCommands(): SlashCommandDefinition[] {
description: "Import a markdown file into this note",
icon: importMarkdownIcon,
commandName: MARKDOWN_IMPORT_COMMAND
+ },
+ {
+ id: "bookmark",
+ title: "Bookmark",
+ description: "Insert a bookmark anchor for internal linking",
+ aliases: [ "anchor" ],
+ icon: bxBookmark,
+ execute: (editor: Editor) => {
+ // Defer to the next event loop tick so the slash command fully finishes
+ // its DOM/selection cleanup; _showFormView needs the view and mapper to
+ // be in a settled state for balloon positioning.
+ setTimeout(() => (editor.plugins.get(BookmarkUI) as any)._showFormView(), 0);
+ }
}
];
}
From ae004c4334c4b2c69862fc2ac8eb833b913c325a Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:28:29 +0300
Subject: [PATCH 20/79] feat(text): basic insert link with anchors
---
apps/client/src/services/link.ts | 4 ++-
.../src/translations/en/translation.json | 4 ++-
apps/client/src/widgets/dialogs/add_link.tsx | 32 +++++++++++++++++--
.../type_widgets/text/EditableText.tsx | 8 +++++
.../type_widgets/text/ReadOnlyText.tsx | 14 +++++++-
apps/server/src/becca/entities/battribute.ts | 10 +++++-
.../commons/src/lib/builtin_attributes.ts | 1 +
7 files changed, 67 insertions(+), 6 deletions(-)
diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts
index 3405161dd5..b6c5dbfdb6 100644
--- a/apps/client/src/services/link.ts
+++ b/apps/client/src/services/link.ts
@@ -60,6 +60,8 @@ export interface ViewScope {
*/
tocPreviousVisible?: boolean;
tocCollapsedHeadings?: Set;
+ /** When set, scrolls to a bookmark anchor within the note after navigation. */
+ bookmark?: string;
}
interface CreateLinkOptions {
@@ -244,7 +246,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
hoistedNoteId = value;
} else if (name === "searchString") {
searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla
- } else if (["viewMode", "attachmentId"].includes(name)) {
+ } else if (["viewMode", "attachmentId", "bookmark"].includes(name)) {
(viewScope as any)[name] = value;
} else if (name === "popup") {
openInPopup = true;
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index ef322e1fb1..3145ff7f03 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -41,6 +41,8 @@
"link_title_mirrors": "link title mirrors the note's current title",
"link_title_arbitrary": "link title can be changed arbitrarily",
"link_title": "Link title",
+ "bookmark": "Bookmark (optional)",
+ "bookmark_none": "None (link to note)",
"button_add_link": "Add link"
},
"branch_prefix": {
@@ -861,7 +863,7 @@
"none": "none"
},
"auto_link_attribute_list": {
- "title": "System Links"
+ "title": "System Attributes"
},
"note_info_widget": {
"note_id": "Note ID",
diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx
index 4bb1d1711c..9d1f5522ff 100644
--- a/apps/client/src/widgets/dialogs/add_link.tsx
+++ b/apps/client/src/widgets/dialogs/add_link.tsx
@@ -5,6 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree";
+import froca from "../../services/froca";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js";
@@ -24,6 +25,8 @@ export default function AddLinkDialog() {
const [ linkTitle, setLinkTitle ] = useState("");
const [ linkType, setLinkType ] = useState();
const [ suggestion, setSuggestion ] = useState(null);
+ const [ bookmarks, setBookmarks ] = useState([]);
+ const [ selectedBookmark, setSelectedBookmark ] = useState("");
const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false);
@@ -61,6 +64,11 @@ export default function AddLinkDialog() {
const noteId = tree.getNoteIdFromUrl(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
+ froca.getNote(noteId).then((note) => {
+ const bkms = note?.getLabels("internalBookmark").map((l) => l.value) ?? [];
+ setBookmarks(bkms);
+ setSelectedBookmark("");
+ });
}
resetExternalLink();
}
@@ -114,8 +122,11 @@ export default function AddLinkDialog() {
hasSubmittedRef.current = false;
if (suggestion.notePath) {
- // Handle note link
- opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
+ // Handle note link, optionally with a bookmark anchor
+ const path = selectedBookmark
+ ? `${suggestion.notePath}?bookmark=${encodeURIComponent(selectedBookmark)}`
+ : suggestion.notePath;
+ opts.addLink(path, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) {
// Handle external link
opts.addLink(suggestion.externalLink, linkTitle, true);
@@ -123,6 +134,8 @@ export default function AddLinkDialog() {
}
setSuggestion(null);
+ setBookmarks([]);
+ setSelectedBookmark("");
setShown(false);
}}
show={shown}
@@ -138,6 +151,21 @@ export default function AddLinkDialog() {
/>
+ {bookmarks.length > 0 && (
+
+
+
+ )}
+
{!opts?.hasSelection && (
{(linkType !== "external-link") && (
diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
index 8cacc40e25..a002cde7e9 100644
--- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
@@ -263,6 +263,14 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
// We are not using CKEditor's built-in watch dog content, instead we are using the data we store regularly in the spaced update (see `dataSaved`).
editor.setData(contentRef.current);
parentComponent?.triggerEvent("textEditorRefreshed", { ntxId, editor });
+
+ // Scroll to bookmark anchor if navigated with ?bookmark=...
+ const viewScope = noteContext?.viewScope;
+ if (viewScope?.bookmark) {
+ const el = editor.editing.view.getDomRoot()?.querySelector(`[id="${CSS.escape(viewScope.bookmark)}"]`);
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
+ viewScope.bookmark = undefined;
+ }
}}
/>}
diff --git a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
index da7e08ebab..a58b21de54 100644
--- a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
@@ -6,7 +6,7 @@ import "@triliumnext/ckeditor5";
import clsx from "clsx";
import { Ref } from "preact";
-import { useEffect, useLayoutEffect, useMemo } from "preact/hooks";
+import { useEffect, useLayoutEffect, useMemo, useRef as usePreactRef } from "preact/hooks";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
@@ -24,6 +24,17 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetProps) {
const blob = useNoteBlob(note);
const { isRtl } = useNoteLanguage(note);
+ const readOnlyContentRef = usePreactRef(null);
+
+ // Scroll to bookmark anchor if navigated with ?bookmark=...
+ useEffect(() => {
+ const viewScope = noteContext?.viewScope;
+ if (!viewScope?.bookmark || !readOnlyContentRef.current) return;
+
+ const el = readOnlyContentRef.current.querySelector(`[id="${CSS.escape(viewScope.bookmark)}"]`);
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
+ viewScope.bookmark = undefined;
+ }, [blob]);
return (
<>
@@ -31,6 +42,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
html={blob?.content ?? ""}
ntxId={ntxId}
dir={isRtl ? "rtl" : "ltr"}
+ contentRef={readOnlyContentRef}
/>
diff --git a/apps/server/src/becca/entities/battribute.ts b/apps/server/src/becca/entities/battribute.ts
index dbb6502113..997b297750 100644
--- a/apps/server/src/becca/entities/battribute.ts
+++ b/apps/server/src/becca/entities/battribute.ts
@@ -119,7 +119,15 @@ class BAttribute extends AbstractBeccaEntity {
}
isAutoLink() {
- return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
+ if (this.type === "relation") {
+ return ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
+ }
+
+ if (this.type === "label") {
+ return this.name === "internalBookmark";
+ }
+
+ return false;
}
get note() {
diff --git a/packages/commons/src/lib/builtin_attributes.ts b/packages/commons/src/lib/builtin_attributes.ts
index 76cdf033dc..64a2ad48d7 100644
--- a/packages/commons/src/lib/builtin_attributes.ts
+++ b/packages/commons/src/lib/builtin_attributes.ts
@@ -91,6 +91,7 @@ export default [
{ type: "label", name: "printPageSize" },
{ type: "label", name: "printScale" },
{ type: "label", name: "printMargins" },
+ { type: "label", name: "internalBookmark" },
// relation names
{ type: "relation", name: "internalLink" },
From 480da09bcca6d43305230a336b0ba47bbdcaa42f Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:30:03 +0300
Subject: [PATCH 21/79] fix(client): `internalBookmark` not hidden
---
apps/client/src/services/attribute_renderer.ts | 7 ++++---
apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx | 4 ++++
apps/client/src/widgets/type_widgets/text/EditableText.tsx | 7 -------
3 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/apps/client/src/services/attribute_renderer.ts b/apps/client/src/services/attribute_renderer.ts
index 01d6337367..c93620b3b2 100644
--- a/apps/client/src/services/attribute_renderer.ts
+++ b/apps/client/src/services/attribute_renderer.ts
@@ -7,6 +7,10 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
const isInheritable = renderIsInheritable && attribute.isInheritable ? `(inheritable)` : "";
const $attr = $("");
+ if (attribute.isAutoLink) {
+ return $attr;
+ }
+
if (attribute.type === "label") {
$attr.append(document.createTextNode(`#${attribute.name}${isInheritable}`));
@@ -15,9 +19,6 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
$attr.append(document.createTextNode(formatValue(attribute.value)));
}
} else if (attribute.type === "relation") {
- if (attribute.isAutoLink) {
- return $attr;
- }
// when the relation has just been created, then it might not have a value
if (attribute.value) {
diff --git a/apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx b/apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx
index 132a55a8a3..1d54edbfb6 100644
--- a/apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx
+++ b/apps/client/src/widgets/ribbon/AutoLinkAttributesTab.tsx
@@ -51,6 +51,10 @@ function AutoLinkAttribute({ attribute }: { attribute: FAttribute }) {
}
async function renderAutoLink(attribute: FAttribute) {
+ if (attribute.type === "label") {
+ return `#${escapeHtml(attribute.name)}=${escapeHtml(attribute.value)}`;
+ }
+
const note = await froca.getNote(attribute.value);
if (!note) return "";
diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
index a002cde7e9..760e00e489 100644
--- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
@@ -264,13 +264,6 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
editor.setData(contentRef.current);
parentComponent?.triggerEvent("textEditorRefreshed", { ntxId, editor });
- // Scroll to bookmark anchor if navigated with ?bookmark=...
- const viewScope = noteContext?.viewScope;
- if (viewScope?.bookmark) {
- const el = editor.editing.view.getDomRoot()?.querySelector(`[id="${CSS.escape(viewScope.bookmark)}"]`);
- el?.scrollIntoView({ behavior: "smooth", block: "center" });
- viewScope.bookmark = undefined;
- }
}}
/>}
From 131e10f4fe5810efa055173dd5f49bc85c057ec9 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:35:46 +0300
Subject: [PATCH 22/79] fix(text): clicking on a bookmark link won't scroll
properly
---
.../src/widgets/type_widgets/text/EditableText.tsx | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
index 760e00e489..26d021168b 100644
--- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
@@ -61,6 +61,17 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
onContentChange(newContent) {
contentRef.current = newContent;
watchdogRef.current?.editor?.setData(newContent);
+
+ // Scroll to bookmark anchor if navigated with ?bookmark=...
+ const viewScope = noteContext?.viewScope;
+ if (viewScope?.bookmark) {
+ requestAnimationFrame(() => {
+ const el = watchdogRef.current?.editor?.editing.view.getDomRoot()
+ ?.querySelector(`[id="${CSS.escape(viewScope.bookmark!)}"]`);
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
+ viewScope.bookmark = undefined;
+ });
+ }
},
dataSaved(savedData) {
// Store back the saved data in order to retrieve it in case the CKEditor crashes.
From 7219fc875d6622ea35cec202aa45dddaaff901d9 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:43:14 +0300
Subject: [PATCH 23/79] feat(text): improve display of reference links with
bookmarks
---
apps/client/src/services/link.ts | 15 +++++++++++++--
.../src/stylesheets/theme-next/notes/text.css | 9 +++++++++
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts
index b6c5dbfdb6..1e375a994a 100644
--- a/apps/client/src/services/link.ts
+++ b/apps/client/src/services/link.ts
@@ -434,6 +434,13 @@ async function loadReferenceLinkTitle($el: JQuery, href: string | n
const title = await getReferenceLinkTitle(href);
$el.text(title);
+ if (viewScope?.bookmark) {
+ $el.append($("").append(
+ $("").addClass("bx bx-bookmark"),
+ document.createTextNode(viewScope.bookmark)
+ ));
+ }
+
if (note) {
const icon = await getLinkIcon(noteId, viewScope.viewMode);
@@ -459,8 +466,8 @@ async function getReferenceLinkTitle(href: string) {
return attachment ? attachment.title : "[missing attachment]";
}
- return note.title;
+ return note.title;
}
function getReferenceLinkTitleSync(href: string) {
@@ -483,8 +490,12 @@ function getReferenceLinkTitleSync(href: string) {
return attachment ? attachment.title : "[missing attachment]";
}
- return note.title;
+ if (viewScope?.bookmark) {
+ return `${note.title} - ${viewScope.bookmark}`;
+ }
+
+ return note.title;
}
if (glob.device !== "print") {
diff --git a/apps/client/src/stylesheets/theme-next/notes/text.css b/apps/client/src/stylesheets/theme-next/notes/text.css
index 5e58895ef5..8832d9a9cf 100644
--- a/apps/client/src/stylesheets/theme-next/notes/text.css
+++ b/apps/client/src/stylesheets/theme-next/notes/text.css
@@ -714,6 +714,15 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
text-decoration: underline;
}
+.ck-content a.reference-link small {
+ margin-left: 0.25em;
+ opacity: 0.5;
+
+ >span {
+ font-size: 0.7em;
+ }
+}
+
/*
* Read-only text content
*/
From b01feed4a291909c656fb4affa5487542e21e572 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:45:51 +0300
Subject: [PATCH 24/79] feat(text): add bookmark title for non-mirrored link
---
apps/client/src/widgets/dialogs/add_link.tsx | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx
index 9d1f5522ff..e61e476190 100644
--- a/apps/client/src/widgets/dialogs/add_link.tsx
+++ b/apps/client/src/widgets/dialogs/add_link.tsx
@@ -27,6 +27,7 @@ export default function AddLinkDialog() {
const [ suggestion, setSuggestion ] = useState(null);
const [ bookmarks, setBookmarks ] = useState([]);
const [ selectedBookmark, setSelectedBookmark ] = useState("");
+ const [ noteTitle, setNoteTitle ] = useState("");
const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false);
@@ -44,8 +45,9 @@ export default function AddLinkDialog() {
}, [ opts ]);
async function setDefaultLinkTitle(noteId: string) {
- const noteTitle = await tree.getNoteTitle(noteId);
- setLinkTitle(noteTitle);
+ const title = await tree.getNoteTitle(noteId);
+ setNoteTitle(title);
+ setLinkTitle(title);
}
function resetExternalLink() {
@@ -79,6 +81,14 @@ export default function AddLinkDialog() {
}
}, [suggestion]);
+ useEffect(() => {
+ if (selectedBookmark) {
+ setLinkTitle(`${noteTitle} - ${selectedBookmark}`);
+ } else {
+ setLinkTitle(noteTitle);
+ }
+ }, [selectedBookmark, noteTitle]);
+
function onShown() {
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
if (!opts?.text) {
@@ -136,6 +146,7 @@ export default function AddLinkDialog() {
setSuggestion(null);
setBookmarks([]);
setSelectedBookmark("");
+ setNoteTitle("");
setShown(false);
}}
show={shown}
From 15c121f95025c963ed9270a3f359a03a62f5ea92 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 10:54:57 +0300
Subject: [PATCH 25/79] docs(user): document linking to bookmarks
---
.../Navigation/Bookmarks.html | 5 ++
.../Notes/Printing & Exporting as PDF.html | 21 +++----
.../User Guide/Note Types/Text/Bookmarks.html | 60 ++++++++++++++++---
.../Developer Guide/Documentation.md | 2 +-
docs/User Guide/!!!meta.json | 42 +++++++++++++
.../Navigation/Bookmarks.md | 3 +
.../User Guide/Note Types/Text/Bookmarks.md | 25 +++++++-
7 files changed, 133 insertions(+), 25 deletions(-)
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.html
index 07943c9924..32231b4d69 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.html
@@ -1,3 +1,8 @@
+
Frequently used notes can be bookmarked, which will make them appear in
the Launch Bar for
easy access.
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.html
index 95992cf3d4..721b20ed26 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF.html
@@ -61,8 +61,7 @@ class="admonition note">
- To print in landscape mode instead of portrait (useful for big diagrams
or slides), add
#printLandscape.
- - By default, the resulting PDF will be in Letter format. It is possible
+
- By default, the resulting PDF will be in Letter format. It is possible
to adjust it to another page size via the
#printPageSize attribute,
with one of the following values: A0,
- First create a collection.
- Configure it to use List View.
- - Print the collection note normally.
+ - Print the collection note normally.
The resulting collection will contain all the children of the collection,
while maintaining the hierarchy.
@@ -102,9 +100,9 @@ class="admonition note">
href="#root/_help_4TIF1oA4VQRO">Options and assigning a key combination
for:
- - Print Active Note
+
- Print Active Note
- - Export Active Note as PDF
+
- Export Active Note as PDF
Constraints & limitations
@@ -180,13 +178,12 @@ class="admonition note">
To remark:
- Multiple CSS notes can be add by using multiple
~printCss relations.
- - If the note pointing to the
printCss doesn't
+ - If the note pointing to the
printCss doesn't
have the right note type or mime type, it will be ignored.
- - If migrating from a previous version where Custom app-wide CSS, there's no need for
-
@media print { since the style-sheet is used only for printing.
+ - If migrating from a previous version where Custom app-wide CSS, there's no need for
+
@media print { since the style-sheet is used only for printing.
Under the hood
Both printing and exporting as PDF use the same mechanism: a note is rendered
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Bookmarks.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Bookmarks.html
index bc7b346e33..25cb62942d 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Bookmarks.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Bookmarks.html
@@ -1,5 +1,12 @@
+
Bookmarks allows creating links to a certain
- part of a note, such as referencing a particular heading.
+ part of a note, such as referencing a particular heading or section within
+ a note.
Technically, bookmarks are HTML anchors.
This feature was introduced in TriliumNext 0.94.0.
Interaction
@@ -7,13 +14,16 @@
- To create a bookmark:
- Place the cursor at the desired position where to place the bookmark.
- - Look for the
+
- Look for the
button in the Formatting toolbar,
and then press the
button.
-
+ - Alternatively, use Slash Commands and
+ look for Bookmark.
+
- To place a link to a bookmark:
@@ -23,9 +33,41 @@
-Limitations
-
- - Currently it's not possible to create a link to a bookmark from a different
- note. This functionality will be added after the internal links feature
- is enhanced to support bookmarks.
-
\ No newline at end of file
+Linking across notes
+Trilium v0.103.0 introduces cross-note bookmarks, which makes it possible
+ to create Internal (reference) links which
+ point to a specific bookmark in that document.
+To do so:
+
+ -
+
First, create a bookmark in the target note using the same process as
+ described above.
+
+ -
+
In another note, press Ctrl+L to insert an internal
+ link. Select the target note containing bookmarks.
+
+ -
+
If the target note contains bookmarks, a section will appear underneath
+ the note selector with the list of bookmarks.
+
+ -
+
Add the link normally.
+
+
+Clicking on a reference link pointing to a bookmark will automatically
+ scroll to the desired section.
+
\ No newline at end of file
diff --git a/docs/Developer Guide/Developer Guide/Documentation.md b/docs/Developer Guide/Developer Guide/Documentation.md
index 8b2fdc9998..18b7705caf 100644
--- a/docs/Developer Guide/Developer Guide/Documentation.md
+++ b/docs/Developer Guide/Developer Guide/Documentation.md
@@ -1,5 +1,5 @@
# Documentation
-There are multiple types of documentation for Trilium:
+There are multiple types of documentation for Trilium:
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing F1.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.
diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json
index 92af202232..61858de2ff 100644
--- a/docs/User Guide/!!!meta.json
+++ b/docs/User Guide/!!!meta.json
@@ -5413,6 +5413,20 @@
"value": "bx bx-bookmarks",
"isInheritable": false,
"position": 30
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "oSuaNgyyKnhu",
+ "isInheritable": false,
+ "position": 40
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "iPIMuisry3hd",
+ "isInheritable": false,
+ "position": 50
}
],
"format": "markdown",
@@ -7305,6 +7319,34 @@
"value": "bookmarks",
"isInheritable": false,
"position": 30
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "u3YFHC9tQlpm",
+ "isInheritable": false,
+ "position": 40
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "xYmIYSP6wE3F",
+ "isInheritable": false,
+ "position": 50
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "ZlN4nump6EbW",
+ "isInheritable": false,
+ "position": 60
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "hrZ1D00cLbal",
+ "isInheritable": false,
+ "position": 70
}
],
"format": "markdown",
diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.md b/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.md
index 332ec24172..321cf83f21 100644
--- a/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.md
+++ b/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks.md
@@ -1,4 +1,7 @@
# Bookmarks
+> [!NOTE]
+> Not to be confused with the Bookmarks concept for Text notes which acts like anchors, allowing navigation to a particular section.
+
Frequently used notes can be bookmarked, which will make them appear in the Launch Bar for easy access.
## Configuring the launch bar
diff --git a/docs/User Guide/User Guide/Note Types/Text/Bookmarks.md b/docs/User Guide/User Guide/Note Types/Text/Bookmarks.md
index e6321b5b89..6a699b139c 100644
--- a/docs/User Guide/User Guide/Note Types/Text/Bookmarks.md
+++ b/docs/User Guide/User Guide/Note Types/Text/Bookmarks.md
@@ -1,5 +1,8 @@
# Bookmarks
-Bookmarks allows creating [links](Links.md) to a certain part of a note, such as referencing a particular heading.
+> [!NOTE]
+> Not to be confused with [bookmarked notes](../../Basic%20Concepts%20and%20Features/Navigation/Bookmarks.md), which simply pins a particular note to the Launch Bar for easy access.
+
+Bookmarks allows creating [links](Links.md) to a certain part of a note, such as referencing a particular heading or section within a note.
Technically, bookmarks are HTML anchors.
@@ -10,10 +13,26 @@ This feature was introduced in TriliumNext 0.94.0.
* To create a bookmark:
* Place the cursor at the desired position where to place the bookmark.
* Look for the
button in the Formatting toolbar, and then press the
button.
+ * Alternatively, use Slash Commands and look for _Bookmark_.
* To place a link to a bookmark:
* Place the cursor at the desired position of the link.
* From the [link](Links.md) pane, select the _Bookmarks_ section and select the desired bookmark.
-## Limitations
+## Linking across notes
-* Currently it's not possible to create a link to a bookmark from a different note. This functionality will be added after the internal links feature is enhanced to support bookmarks.
\ No newline at end of file
+Trilium v0.103.0 introduces cross-note bookmarks, which makes it possible to create Internal (reference) links which point to a specific bookmark in that document.
+
+To do so:
+
+1. First, create a bookmark in the target note using the same process as described above.
+2. In another note, press Ctrl+L to insert an internal link. Select the target note containing bookmarks.
+3. If the target note contains bookmarks, a section will appear underneath the note selector with the list of bookmarks.
+4. Add the link normally.
+
+Clicking on a reference link pointing to a bookmark will automatically scroll to the desired section.
+
+> [!NOTE]
+> For notes created prior to Trilium v0.103.0, you might notice that the bookmarks might not be identified:
+>
+> * To fix this, simply go that note and make any change (e.g. inserting a space), this will trigger the recalculation of the links.
+> * This limitation is intentional in order not to have to re-process all the notes, looking for anchors.
\ No newline at end of file
From 4244b66ceaccceb5515b5c6a645c43b0e85d23df Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 11:04:20 +0300
Subject: [PATCH 26/79] feat(text): rebrand bookmarks to anchors
---
.../src/translations/en/translation.json | 4 +-
apps/client/src/widgets/dialogs/add_link.tsx | 4 +-
.../doc_notes/en/User Guide/!!!meta.json | 2 +-
.../Navigation/Bookmarks.html | 5 -
..._Bookmarks_plus.png => 1_Anchors_plus.png} | Bin
.../User Guide/Note Types/Text/Anchors.html | 62 ++++++
.../{Bookmarks_plus.png => Anchors_plus.png} | Bin
.../User Guide/Note Types/Text/Bookmarks.html | 73 -------
.../Links/Internal (reference) links.html | 7 +-
docs/User Guide/!!!meta.json | 197 ++++++++----------
.../Navigation/Bookmarks.md | 3 -
..._Bookmarks_plus.png => 1_Anchors_plus.png} | Bin
.../User Guide/Note Types/Text/Anchors.md | 36 ++++
.../{Bookmarks_plus.png => Anchors_plus.png} | Bin
.../User Guide/Note Types/Text/Bookmarks.md | 38 ----
.../Note Types/Text/Insert buttons.md | 2 +-
.../ckeditor5/src/extra_slash_commands.ts | 8 +-
.../ckeditor5/src/translation_overrides.ts | 11 +-
18 files changed, 210 insertions(+), 242 deletions(-)
rename apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/{1_Bookmarks_plus.png => 1_Anchors_plus.png} (100%)
create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Anchors.html
rename apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/{Bookmarks_plus.png => Anchors_plus.png} (100%)
delete mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Bookmarks.html
rename docs/User Guide/User Guide/Note Types/Text/{1_Bookmarks_plus.png => 1_Anchors_plus.png} (100%)
create mode 100644 docs/User Guide/User Guide/Note Types/Text/Anchors.md
rename docs/User Guide/User Guide/Note Types/Text/{Bookmarks_plus.png => Anchors_plus.png} (100%)
delete mode 100644 docs/User Guide/User Guide/Note Types/Text/Bookmarks.md
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 3145ff7f03..79cf207780 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -41,8 +41,8 @@
"link_title_mirrors": "link title mirrors the note's current title",
"link_title_arbitrary": "link title can be changed arbitrarily",
"link_title": "Link title",
- "bookmark": "Bookmark (optional)",
- "bookmark_none": "None (link to note)",
+ "anchor": "Anchor (optional)",
+ "anchor_none": "None (link to note)",
"button_add_link": "Add link"
},
"branch_prefix": {
diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx
index e61e476190..f68e38bfc4 100644
--- a/apps/client/src/widgets/dialogs/add_link.tsx
+++ b/apps/client/src/widgets/dialogs/add_link.tsx
@@ -163,13 +163,13 @@ export default function AddLinkDialog() {
{bookmarks.length > 0 && (
-
+
);
}
+function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () => void }) {
+ const [ description, setDescription ] = useState("");
+
+ return (
+
+ setDescription((e.target as HTMLInputElement).value)}
+ style={{ width: "200px" }}
+ />
+ {
+ await server.post(`notes/${noteId}/revision`, { description });
+ setDescription("");
+ toast.showMessage(t("revisions.revision_saved"));
+ onSaved();
+ }}
+ />
+
+ );
+}
+
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
return (
@@ -150,20 +199,30 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
value={item.revisionId}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
- {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
+
+ {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
+ {item.description && (
+
+ {item.description}
+
+ )}
+
)}
);
}
-function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: {
+function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
noteContent?: string,
revisionItem?: RevisionItem,
showDiff: boolean,
setShown: Dispatch>,
- onRevisionDeleted?: () => void
+ onRevisionDeleted?: () => void,
+ onDescriptionUpdated?: (revisionId: string, description: string) => void
}) {
const [ fullRevision, setFullRevision ] = useState();
+ const [ editingDescription, setEditingDescription ] = useState(false);
+ const [ descriptionDraft, setDescriptionDraft ] = useState("");
useEffect(() => {
if (revisionItem) {
@@ -171,6 +230,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
} else {
setFullRevision(undefined);
}
+ setEditingDescription(false);
}, [revisionItem]);
return (
@@ -215,6 +275,25 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
}
)}
+ {revisionItem && (
+ {
+ setDescriptionDraft(revisionItem.description || "");
+ setEditingDescription(true);
+ }}
+ onDraftChange={setDescriptionDraft}
+ onSave={async () => {
+ await server.patch(`revisions/${revisionItem.revisionId}`, { description: descriptionDraft });
+ setEditingDescription(false);
+ toast.showMessage(t("revisions.description_updated"));
+ onDescriptionUpdated?.(revisionItem.revisionId!, descriptionDraft);
+ }}
+ onCancel={() => setEditingDescription(false)}
+ />
+ )}
void,
+ onDraftChange: (val: string) => void,
+ onSave: () => void,
+ onCancel: () => void
+}) {
+ if (editing) {
+ return (
+
+ onDraftChange((e.target as HTMLInputElement).value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") onSave();
+ if (e.key === "Escape") onCancel();
+ }}
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ autoFocus
+ style={{ flexGrow: 1 }}
+ />
+
+
+
+ );
+ }
+
+ return (
+
+
+ {revisionItem.description || t("revisions.description_placeholder")}
+
+
+
+ );
+}
+
const IMAGE_STYLE: CSSProperties = {
maxWidth: "100%",
maxHeight: "90%",
diff --git a/apps/server/src/assets/db/schema.sql b/apps/server/src/assets/db/schema.sql
index 27e38bf205..361a6ccdf4 100644
--- a/apps/server/src/assets/db/schema.sql
+++ b/apps/server/src/assets/db/schema.sql
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
+ `description` TEXT DEFAULT '' NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,
diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts
index 06a9fd41dc..63b447c2b3 100644
--- a/apps/server/src/becca/entities/bnote.ts
+++ b/apps/server/src/becca/entities/bnote.ts
@@ -1543,7 +1543,7 @@ class BNote extends AbstractBeccaEntity {
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
}
- saveRevision(): BRevision {
+ saveRevision(description?: string): BRevision {
return sql.transactional(() => {
let noteContent = this.getContent();
@@ -1552,6 +1552,7 @@ class BNote extends AbstractBeccaEntity {
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
+ description: description || "",
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
diff --git a/apps/server/src/becca/entities/brevision.ts b/apps/server/src/becca/entities/brevision.ts
index 88f647db29..d9bf2e1c72 100644
--- a/apps/server/src/becca/entities/brevision.ts
+++ b/apps/server/src/becca/entities/brevision.ts
@@ -31,7 +31,7 @@ class BRevision extends AbstractBeccaEntity {
return "revisionId";
}
static get hashedProperties() {
- return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
+ return ["revisionId", "noteId", "title", "description", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
}
revisionId?: string;
@@ -39,6 +39,7 @@ class BRevision extends AbstractBeccaEntity {
type!: NoteType;
mime!: string;
title!: string;
+ description!: string;
dateLastEdited?: string;
utcDateLastEdited?: string;
contentLength?: number;
@@ -61,6 +62,7 @@ class BRevision extends AbstractBeccaEntity {
this.mime = row.mime;
this.isProtected = !!row.isProtected;
this.title = row.title;
+ this.description = row.description || "";
this.blobId = row.blobId;
this.dateLastEdited = row.dateLastEdited;
this.dateCreated = row.dateCreated;
@@ -193,6 +195,7 @@ class BRevision extends AbstractBeccaEntity {
mime: this.mime,
isProtected: this.isProtected,
title: this.title,
+ description: this.description,
blobId: this.blobId,
dateLastEdited: this.dateLastEdited,
dateCreated: this.dateCreated,
diff --git a/apps/server/src/etapi/mappers.ts b/apps/server/src/etapi/mappers.ts
index 4748122390..bff50af5c6 100644
--- a/apps/server/src/etapi/mappers.ts
+++ b/apps/server/src/etapi/mappers.ts
@@ -73,6 +73,7 @@ function mapRevisionToPojo(revision: BRevision) {
mime: revision.mime,
isProtected: revision.isProtected,
title: revision.title,
+ description: revision.description,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,
diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts
index 935868ef78..96206f2dc6 100644
--- a/apps/server/src/etapi/notes.ts
+++ b/apps/server/src/etapi/notes.ts
@@ -192,9 +192,10 @@ function register(router: Router) {
eu.route<{ noteId: string }>(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
- note.saveRevision();
+ const description = req.body?.description || "";
+ const revision = note.saveRevision(description);
- return res.sendStatus(204);
+ res.status(201).json(mappers.mapRevisionToPojo(revision));
});
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
diff --git a/apps/server/src/migrations/migrations.ts b/apps/server/src/migrations/migrations.ts
index 5468312c55..93b25be8f1 100644
--- a/apps/server/src/migrations/migrations.ts
+++ b/apps/server/src/migrations/migrations.ts
@@ -6,6 +6,14 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
+ // Add description column to revisions table for manual revision comments
+ {
+ version: 238,
+ sql: /*sql*/`
+ ALTER TABLE revisions ADD COLUMN description TEXT DEFAULT '' NOT NULL;
+ `,
+ ignoreErrors: true
+ },
// Clean up obsolete keyboard shortcut options from renamed actions
{
version: 237,
diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts
index e95ac75e96..9cca431f91 100644
--- a/apps/server/src/routes/api/notes.ts
+++ b/apps/server/src/routes/api/notes.ts
@@ -351,7 +351,12 @@ function forceSaveRevision(req: Request<{ noteId: string }>) {
throw new ValidationError(`Note revision of a protected note cannot be created outside of a protected session.`);
}
- note.saveRevision();
+ const description = req.body?.description || "";
+ const revision = note.saveRevision(description);
+
+ return {
+ revisionId: revision.revisionId
+ };
}
function convertNoteToAttachment(req: Request<{ noteId: string }>) {
diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts
index 34dbce728e..8598810791 100644
--- a/apps/server/src/routes/api/revisions.ts
+++ b/apps/server/src/routes/api/revisions.ts
@@ -111,6 +111,18 @@ function eraseRevision(req: Request<{ revisionId: string }>) {
eraseService.eraseRevisions([req.params.revisionId]);
}
+function updateRevisionDescription(req: Request<{ revisionId: string }>) {
+ const revision = becca.getRevisionOrThrow(req.params.revisionId);
+ const { description } = req.body;
+
+ if (typeof description !== "string") {
+ return [400, "Description must be a string."];
+ }
+
+ revision.description = description;
+ revision.save();
+}
+
function eraseAllExcessRevisions() {
const allNoteIds = sql.getRows("SELECT noteId FROM notes WHERE SUBSTRING(noteId, 1, 1) != '_'") as { noteId: string }[];
allNoteIds.forEach((row) => {
@@ -222,5 +234,6 @@ export default {
eraseAllRevisions,
eraseAllExcessRevisions,
eraseRevision,
- restoreRevision
+ restoreRevision,
+ updateRevisionDescription
};
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index 62872891ca..c689d46075 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -186,6 +186,7 @@ function register(app: express.Application) {
apiRoute(GET, "/api/revisions/:revisionId", revisionsApiRoute.getRevision);
apiRoute(GET, "/api/revisions/:revisionId/blob", revisionsApiRoute.getRevisionBlob);
apiRoute(DEL, "/api/revisions/:revisionId", revisionsApiRoute.eraseRevision);
+ apiRoute(PATCH, "/api/revisions/:revisionId", revisionsApiRoute.updateRevisionDescription);
apiRoute(PST, "/api/revisions/:revisionId/restore", revisionsApiRoute.restoreRevision);
route(GET, "/api/revisions/:revisionId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromRevision);
diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts
index 200b567023..977120c21f 100644
--- a/packages/commons/src/lib/rows.ts
+++ b/packages/commons/src/lib/rows.ts
@@ -28,6 +28,7 @@ export interface RevisionRow {
mime: string;
isProtected?: boolean;
title: string;
+ description?: string;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;
diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts
index 84b16482bb..c43b155094 100644
--- a/packages/commons/src/lib/server_api.ts
+++ b/packages/commons/src/lib/server_api.ts
@@ -33,6 +33,7 @@ export interface RevisionItem {
contentLength?: number;
type: NoteType;
title: string;
+ description?: string;
isProtected?: boolean;
mime: string;
}
@@ -44,6 +45,7 @@ export interface RevisionPojo {
mime: string;
isProtected?: boolean;
title: string;
+ description?: string;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;
From 676a988433190f862673fb4524cf28906dbdad9c Mon Sep 17 00:00:00 2001
From: green
Date: Sat, 18 Apr 2026 05:13:55 +0200
Subject: [PATCH 37/79] Translated using Weblate (Japanese)
Currently translated at 99.9% (1961 of 1962 strings)
Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
---
apps/client/src/translations/ja/translation.json | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json
index cdbe639287..2c99b6e57c 100644
--- a/apps/client/src/translations/ja/translation.json
+++ b/apps/client/src/translations/ja/translation.json
@@ -615,7 +615,8 @@
"collections": "コレクション",
"ai-chat": "AI チャット",
"spreadsheet": "スプレッドシート",
- "llm-chat": "AI チャット"
+ "llm-chat": "AI チャット",
+ "markdown": "Markdown"
},
"edited_notes": {
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
@@ -2479,5 +2480,10 @@
},
"launcher_button_context_menu": {
"remove_from_launch_bar": "ランチャーバーから削除"
+ },
+ "display_mode": {
+ "source": "ソースビュー",
+ "split": "分割ビュー",
+ "preview": "プレビュー"
}
}
From a915c60c38af4fa4b4ee1f542ef68256762ddd96 Mon Sep 17 00:00:00 2001
From: "Francis C."
Date: Sat, 18 Apr 2026 11:23:46 +0200
Subject: [PATCH 38/79] Translated using Weblate (Chinese (Traditional Han
script))
Currently translated at 99.2% (401 of 404 strings)
Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
---
apps/server/src/assets/translations/tw/server.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/server/src/assets/translations/tw/server.json b/apps/server/src/assets/translations/tw/server.json
index 8a549ec7a8..a6a411b9ab 100644
--- a/apps/server/src/assets/translations/tw/server.json
+++ b/apps/server/src/assets/translations/tw/server.json
@@ -45,7 +45,7 @@
"show-note-source": "顯示筆記來源對話方塊",
"show-options": "打開選項頁面",
"show-revisions": "顯示筆記歷史版本對話方塊",
- "show-recent-changes": "顯示最近更改對話方塊",
+ "show-recent-changes": "顯示最近修改對話方塊",
"show-sql-console": "打開 SQL 控制台頁面",
"show-backend-log": "打開後端日誌頁面",
"text-note-operations": "文字筆記操作",
@@ -261,7 +261,7 @@
"show-note-source": "顯示筆記原始碼",
"show-options": "顯示選項",
"show-revisions": "顯示歷史版本",
- "show-recent-changes": "顯示最近更改",
+ "show-recent-changes": "顯示最近修改",
"show-sql-console": "顯示 SQL 控制台",
"show-backend-log": "顯示後端日誌",
"show-help": "顯示說明",
From ec7b9e08e3971a1fb139726b3d827745f352644b Mon Sep 17 00:00:00 2001
From: passkal4
Date: Sat, 18 Apr 2026 11:40:50 +0200
Subject: [PATCH 39/79] Translated using Weblate (Uyghur)
Currently translated at 23.0% (93 of 404 strings)
Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ug/
---
apps/server/src/assets/translations/ug/server.json | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/assets/translations/ug/server.json b/apps/server/src/assets/translations/ug/server.json
index 7f7380ddaa..66c2f76a28 100644
--- a/apps/server/src/assets/translations/ug/server.json
+++ b/apps/server/src/assets/translations/ug/server.json
@@ -85,6 +85,13 @@
"reload-frontend-app": "ئالدى تەرەپ ئەپىنى قايتا يۈكلەش",
"open-dev-tools": "تەتقىقاتچى قوراللىرىنى ئېچىش",
"find-in-text": "تېكىست ئىچىدىن ئىزدەش",
- "toggle-left-note-tree-panel": "سول تەرەپ (خاتىرە دەرىخى) تاختىسىنى ئالماشتۇرۇش"
+ "toggle-left-note-tree-panel": "سول تەرەپ (خاتىرە دەرىخى) تاختىسىنى ئالماشتۇرۇش",
+ "toggle-full-screen": "پۈتۈن ئېكران شەكلىگە ئالماشتۇرۇش",
+ "zoom-out": "كىچىكلىتىش",
+ "zoom-in": "چوڭايتىش",
+ "note-navigation": "خاتىرە يولباشچىسى",
+ "reset-zoom-level": "چوڭ-كىچىكلىك دەرىجىسىنى ئەسلىگە كەلتۈرۈش",
+ "copy-without-formatting": "تاللانغان تېكىستنى فارماٹسىز كۆچۈرۈش",
+ "force-save-revision": "نۆۋەتتىكى خاتىرىنىڭ يېڭى نەشرىنى مەجبۇرىي قۇرۇش/ساقلاش"
}
}
From 7dfdc7f31aa9c941eeccb987f4e7e6b65b5bc199 Mon Sep 17 00:00:00 2001
From: passkal4
Date: Sat, 18 Apr 2026 11:41:24 +0200
Subject: [PATCH 40/79] Translated using Weblate (Uyghur)
Currently translated at 78.9% (94 of 119 strings)
Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/ug/
---
docs/README-ug.md | 31 ++++++++++++++++---------------
1 file changed, 16 insertions(+), 15 deletions(-)
diff --git a/docs/README-ug.md b/docs/README-ug.md
index b20038be1e..9b081dba6c 100644
--- a/docs/README-ug.md
+++ b/docs/README-ug.md
@@ -285,23 +285,24 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
### تەتقىقاتچى ھۆججەتلىرى
-Please view the [documentation
-guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
-for details. If you have more questions, feel free to reach out via the links
-described in the "Discuss with us" section above.
+تەپسىلاتلار ئۈچۈن [ھۆججەت
+يېتەكچىسى](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)گە
+قاراڭ. ئەگەر تېخىمۇ كۆپ سوئاللىرىڭىز بولسا، ئۈستىدىكى "بىز بىلەن ئالاقىلىشىڭ"
+بۆلىكىدە تەمىنلەنگەن ئۇلىنىشلار ئارقىلىق بىز بىلەن ئالاقىلىشىڭنى قارشى ئالىمىز.
-## 👏 Shoutouts
+## 👏 مىننەتدارلىق
-* [zadam](https://github.com/zadam) for the original concept and implementation
- of the application.
-* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
- application icon.
-* [nriver](https://github.com/nriver) for his work on internationalization.
-* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
-* [antoniotejada](https://github.com/nriver) for the original syntax highlight
- widget.
-* [Dosu](https://dosu.dev/) for providing us with the automated responses to
- GitHub issues and discussions.
+* ئەپنىڭ ئەسلى ئۇقۇم لاھىيەسى ۋە ئەمەلگە ئاشۇرۇلۇشىغا تۆھپە قوشقان
+ [zadam](https://github.com/zadam).
+* ئەپ سىنبەلگىسىنى لاھىيەلىگەن [Sarah
+ Hussein](https://github.com/Sarah-Hussein).
+* خەلقئارالاشتۇرۇش خىزمىتىگە تۆھپە قوشقان [nriver](https://github.com/nriver).
+* Canvas جەھەتتىكى ئەسلى ئىجادىي خىزمەتلىرى ئۈچۈن [Thomas
+ Frei](https://github.com/thfrei).
+* ئەسلى گرامماتىكا گەۋدىلەندۈرۈش كىچىك زاپچاسلارنى ئاپتورى
+ [antoniotejada](https://github.com/nriver).
+* GitHub مەسىلىلىرى ۋە مۇنازىرىلىرىگە ئاپتوماتىك جاۋاب قايتۇرۇش بىلەن تەمىنلىگەن
+ [Dosu](https://dosu.dev/).
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
Trilium would not be possible without the technologies behind it:
From 0bdebca2b694583e843788763099fd7807faa14f Mon Sep 17 00:00:00 2001
From: "Francis C."
Date: Sat, 18 Apr 2026 11:29:07 +0200
Subject: [PATCH 41/79] Translated using Weblate (Chinese (Traditional Han
script))
Currently translated at 89.7% (1760 of 1962 strings)
Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
---
.../src/translations/tw/translation.json | 30 +++++++++++++++----
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json
index cc0497127e..09b044c264 100644
--- a/apps/client/src/translations/tw/translation.json
+++ b/apps/client/src/translations/tw/translation.json
@@ -89,13 +89,21 @@
},
"delete_notes": {
"delete_all_clones_description": "同時刪除所有克隆(可以在最近修改中撤消)",
- "erase_notes_description": "通常(軟)刪除僅標記筆記為已刪除,可以在一段時間內透過最近修改對話方塊撤消。勾選此選項將立即擦除筆記,無法撤銷。",
+ "erase_notes_description": "立即刪除筆記,而非執行軟刪除。此操作無法撤銷,且會強制重新載入應用程式。",
"erase_notes_warning": "永久擦除筆記(無法撤銷),包括所有克隆。這將強制應用程式重新載入。",
- "notes_to_be_deleted": "將刪除以下筆記 ({{notesCount}})",
+ "notes_to_be_deleted": "待刪除筆記 ({{notesCount}})",
"no_note_to_delete": "沒有筆記將被刪除(僅克隆)。",
- "broken_relations_to_be_deleted": "將刪除以下關聯並斷開連接 ({{ relationCount}})",
+ "broken_relations_to_be_deleted": "斷開的關聯 ({{ relationCount}})",
"cancel": "取消",
- "close": "關閉"
+ "close": "關閉",
+ "title": "刪除筆記",
+ "clones_label": "克隆",
+ "delete_clones_description_one": "同時刪除 {{count}} 個其他克隆。此操作可在最近修改中撤銷。",
+ "erase_notes_label": "永久擦除",
+ "table_note_with_relation": "有關聯的筆記",
+ "table_relation": "關聯",
+ "table_points_to": "指向 (已刪除)",
+ "delete": "刪除"
},
"export": {
"export_note_title": "匯出筆記",
@@ -206,7 +214,8 @@
"box_size_small": "小型(顯示大約 10 行)",
"box_size_medium": "中型 (顯示大約30行)",
"box_size_full": "完整顯示(完整文字框)",
- "button_include": "內嵌筆記"
+ "button_include": "內嵌筆記",
+ "box_size_expandable": "可展開(預設為摺疊狀態)"
},
"info": {
"modalTitle": "資訊消息",
@@ -1430,7 +1439,7 @@
"expand-subtree": "展開子階層",
"collapse-subtree": "收摺子階層",
"sort-by": "排序方式…",
- "recent-changes-in-subtree": "子階層中的最近更改",
+ "recent-changes-in-subtree": "子階層中的最近修改",
"convert-to-attachment": "轉換為附件",
"copy-note-path-to-clipboard": "複製筆記路徑至剪貼簿",
"protect-subtree": "保護子階層",
@@ -2334,5 +2343,14 @@
"history": "對話歷史",
"recent_chats": "最近的對話",
"no_chats": "無先前的對話記錄"
+ },
+ "revisions": {
+ "note_revisions": "筆記歷史版本",
+ "delete_all_revisions": "刪除此筆記的所有歷史版本",
+ "delete_all_button": "刪除所有歷史版本",
+ "help_title": "關於筆記歷史版本的說明",
+ "confirm_delete_all": "您要刪除此筆記的所有歷史版本嗎?",
+ "no_revisions": "尚無此筆記的歷史版本...",
+ "restore_button": "還原"
}
}
From e5f97b6fdd35af201ade1cf4642e5cc954e78c18 Mon Sep 17 00:00:00 2001
From: green
Date: Sat, 18 Apr 2026 05:14:25 +0200
Subject: [PATCH 42/79] Translated using Weblate (Japanese)
Currently translated at 100.0% (116 of 116 strings)
Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/ja/
---
docs/README-ja.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/README-ja.md b/docs/README-ja.md
index 6804c08b7c..9f667bc589 100644
--- a/docs/README-ja.md
+++ b/docs/README-ja.md
@@ -63,7 +63,7 @@ Trilium Notes
* ノートは任意の深さのツリーに配置できます。1つのノートをツリー内の複数の場所に配置できます([クローン](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning)を参照)
* 豊富な WYSIWYG ノートエディター 例:
- 表、画像、[数式](https://docs.triliumnotes.org/user-guide/note-types/text) とマークダウン
+ 表、画像、[数式](https://docs.triliumnotes.org/user-guide/note-types/text) と markdown
[自動フォーマット](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
など
* 構文ハイライト表示を含む
From 1c508b830e4f20f3b2bf7375c8f01ea8e86a8719 Mon Sep 17 00:00:00 2001
From: green
Date: Sat, 18 Apr 2026 05:12:40 +0200
Subject: [PATCH 43/79] Translated using Weblate (Japanese)
Currently translated at 100.0% (404 of 404 strings)
Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
---
apps/server/src/assets/translations/ja/server.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/assets/translations/ja/server.json b/apps/server/src/assets/translations/ja/server.json
index 2197272282..6e41b1934f 100644
--- a/apps/server/src/assets/translations/ja/server.json
+++ b/apps/server/src/assets/translations/ja/server.json
@@ -382,7 +382,8 @@
"migration": {
"old_version": "現在のバージョンからの直接的な移行はサポートされていません。まず最新のv0.60.4にアップグレードしてから、このバージョンにアップグレードしてください。",
"error_message": "バージョン {{version}} への移行中にエラーが発生しました: {{stack}}",
- "wrong_db_version": "データベースのバージョン({{version}})は、アプリケーションが想定しているバージョン({{targetVersion}})よりも新しく、互換性のないバージョンによって作成された可能性があります。この問題を解決するには、Triliumを最新バージョンにアップグレードしてください。"
+ "wrong_db_version": "データベースのバージョン({{version}})は、アプリケーションが想定しているバージョン({{targetVersion}})よりも新しく、互換性のないバージョンによって作成された可能性があります。この問題を解決するには、Triliumを最新バージョンにアップグレードしてください。",
+ "invalid_db_version": "データベースのバージョン番号が無効です。これは通常、データベース内の 'dbVersion' オプションが破損していることを示しています。バックアップから復元してください。"
},
"modals": {
"error_title": "エラー"
From 4b35881889a4e0209c470e3354b0a250a5d43769 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 12:46:13 +0300
Subject: [PATCH 44/79] feat(revisions): add a source field
---
.../src/translations/en/translation.json | 7 ++++++-
apps/client/src/widgets/dialogs/revisions.tsx | 18 +++++++++++++++++-
apps/server/src/assets/db/schema.sql | 1 +
apps/server/src/becca/entities/bnote.ts | 7 ++++---
apps/server/src/becca/entities/brevision.ts | 7 +++++--
apps/server/src/etapi/mappers.ts | 1 +
apps/server/src/etapi/notes.ts | 2 +-
apps/server/src/migrations/migrations.ts | 1 +
apps/server/src/routes/api/notes.ts | 2 +-
apps/server/src/routes/api/revisions.ts | 2 +-
.../src/services/llm/tools/note_tools.ts | 4 ++--
packages/commons/src/lib/rows.ts | 4 ++++
packages/commons/src/lib/server_api.ts | 4 +++-
13 files changed, 47 insertions(+), 13 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 796e4c08fa..9fbf88bc26 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -310,7 +310,12 @@
"description_placeholder": "Add a description (optional)",
"revision_saved": "Note revision has been saved.",
"edit_description": "Edit description",
- "description_updated": "Revision description has been updated."
+ "description_updated": "Revision description has been updated.",
+ "source_auto": "Auto",
+ "source_manual": "Manual",
+ "source_etapi": "ETAPI",
+ "source_llm": "LLM",
+ "source_restore": "Restore"
},
"sort_child_notes": {
"sort_children_by": "Sort children by...",
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 06a44ed859..b3d3803960 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -200,7 +200,23 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
- {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
+
+ {item.dateCreated && item.dateCreated.substr(0, 16)}
+ {item.source && item.source !== "auto" && (
+
+ {t(`revisions.source_${item.source}`)}
+
+ )}
+
+
+ {item.contentLength && utils.formatSize(item.contentLength)}
+
{item.description && (
{item.description}
diff --git a/apps/server/src/assets/db/schema.sql b/apps/server/src/assets/db/schema.sql
index 361a6ccdf4..60f0485471 100644
--- a/apps/server/src/assets/db/schema.sql
+++ b/apps/server/src/assets/db/schema.sql
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
`description` TEXT DEFAULT '' NOT NULL,
+ `source` TEXT DEFAULT 'auto' NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,
diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts
index 63b447c2b3..56251e76ea 100644
--- a/apps/server/src/becca/entities/bnote.ts
+++ b/apps/server/src/becca/entities/bnote.ts
@@ -1,4 +1,4 @@
-import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons";
+import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow, RevisionSource } from "@triliumnext/commons";
import { dayjs, getNoteIcon } from "@triliumnext/commons";
import cloningService from "../../services/cloning.js";
@@ -1543,7 +1543,7 @@ class BNote extends AbstractBeccaEntity {
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
}
- saveRevision(description?: string): BRevision {
+ saveRevision(opts: { description?: string; source?: RevisionSource } = {}): BRevision {
return sql.transactional(() => {
let noteContent = this.getContent();
@@ -1552,7 +1552,8 @@ class BNote extends AbstractBeccaEntity {
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
- description: description || "",
+ description: opts.description || "",
+ source: opts.source || "auto",
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
diff --git a/apps/server/src/becca/entities/brevision.ts b/apps/server/src/becca/entities/brevision.ts
index d9bf2e1c72..4e90471cd2 100644
--- a/apps/server/src/becca/entities/brevision.ts
+++ b/apps/server/src/becca/entities/brevision.ts
@@ -7,7 +7,7 @@ import becca from "../becca.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import sql from "../../services/sql.js";
import BAttachment from "./battachment.js";
-import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
+import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow, RevisionSource } from "@triliumnext/commons";
import eraseService from "../../services/erase.js";
interface ContentOpts {
@@ -31,7 +31,7 @@ class BRevision extends AbstractBeccaEntity {
return "revisionId";
}
static get hashedProperties() {
- return ["revisionId", "noteId", "title", "description", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
+ return ["revisionId", "noteId", "title", "description", "source", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
}
revisionId?: string;
@@ -40,6 +40,7 @@ class BRevision extends AbstractBeccaEntity {
mime!: string;
title!: string;
description!: string;
+ source!: RevisionSource;
dateLastEdited?: string;
utcDateLastEdited?: string;
contentLength?: number;
@@ -63,6 +64,7 @@ class BRevision extends AbstractBeccaEntity {
this.isProtected = !!row.isProtected;
this.title = row.title;
this.description = row.description || "";
+ this.source = row.source || "auto";
this.blobId = row.blobId;
this.dateLastEdited = row.dateLastEdited;
this.dateCreated = row.dateCreated;
@@ -196,6 +198,7 @@ class BRevision extends AbstractBeccaEntity {
isProtected: this.isProtected,
title: this.title,
description: this.description,
+ source: this.source,
blobId: this.blobId,
dateLastEdited: this.dateLastEdited,
dateCreated: this.dateCreated,
diff --git a/apps/server/src/etapi/mappers.ts b/apps/server/src/etapi/mappers.ts
index bff50af5c6..04e59b1000 100644
--- a/apps/server/src/etapi/mappers.ts
+++ b/apps/server/src/etapi/mappers.ts
@@ -74,6 +74,7 @@ function mapRevisionToPojo(revision: BRevision) {
isProtected: revision.isProtected,
title: revision.title,
description: revision.description,
+ source: revision.source,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,
diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts
index 96206f2dc6..8edc583435 100644
--- a/apps/server/src/etapi/notes.ts
+++ b/apps/server/src/etapi/notes.ts
@@ -193,7 +193,7 @@ function register(router: Router) {
const note = eu.getAndCheckNote(req.params.noteId);
const description = req.body?.description || "";
- const revision = note.saveRevision(description);
+ const revision = note.saveRevision({ description, source: "etapi" });
res.status(201).json(mappers.mapRevisionToPojo(revision));
});
diff --git a/apps/server/src/migrations/migrations.ts b/apps/server/src/migrations/migrations.ts
index 93b25be8f1..0c9dd4e8d1 100644
--- a/apps/server/src/migrations/migrations.ts
+++ b/apps/server/src/migrations/migrations.ts
@@ -11,6 +11,7 @@ const MIGRATIONS: (SqlMigration | JsMigration)[] = [
version: 238,
sql: /*sql*/`
ALTER TABLE revisions ADD COLUMN description TEXT DEFAULT '' NOT NULL;
+ ALTER TABLE revisions ADD COLUMN source TEXT DEFAULT 'auto' NOT NULL;
`,
ignoreErrors: true
},
diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts
index 9cca431f91..8aec006a09 100644
--- a/apps/server/src/routes/api/notes.ts
+++ b/apps/server/src/routes/api/notes.ts
@@ -352,7 +352,7 @@ function forceSaveRevision(req: Request<{ noteId: string }>) {
}
const description = req.body?.description || "";
- const revision = note.saveRevision(description);
+ const revision = note.saveRevision({ description, source: "manual" });
return {
revisionId: revision.revisionId
diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts
index 8598810791..12e1c990ac 100644
--- a/apps/server/src/routes/api/revisions.ts
+++ b/apps/server/src/routes/api/revisions.ts
@@ -137,7 +137,7 @@ function restoreRevision(req: Request<{ revisionId: string }>) {
const note = revision.getNote();
sql.transactional(() => {
- note.saveRevision();
+ note.saveRevision({ source: "restore" });
for (const oldNoteAttachment of note.getAttachments()) {
oldNoteAttachment.markAsDeleted();
diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts
index 8a354175f3..d1ce83e3e0 100644
--- a/apps/server/src/services/llm/tools/note_tools.ts
+++ b/apps/server/src/services/llm/tools/note_tools.ts
@@ -116,7 +116,7 @@ export const noteTools = defineTools({
return { error: `Cannot update content for note type: ${note.type}` };
}
- note.saveRevision();
+ note.saveRevision({ source: "llm" });
setNoteContentFromLlm(note, content);
return {
success: true,
@@ -158,7 +158,7 @@ export const noteTools = defineTools({
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
- note.saveRevision();
+ note.saveRevision({ source: "llm" });
note.setContent(newContent);
return {
success: true,
diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts
index 977120c21f..5f198aa02f 100644
--- a/packages/commons/src/lib/rows.ts
+++ b/packages/commons/src/lib/rows.ts
@@ -21,6 +21,9 @@ export interface AttachmentRow {
encoding?: "base64";
}
+export const REVISION_SOURCES = ["auto", "manual", "etapi", "llm", "restore"] as const;
+export type RevisionSource = (typeof REVISION_SOURCES)[number];
+
export interface RevisionRow {
revisionId?: string;
noteId: string;
@@ -29,6 +32,7 @@ export interface RevisionRow {
isProtected?: boolean;
title: string;
description?: string;
+ source?: RevisionSource;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;
diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts
index c43b155094..63e5c63680 100644
--- a/packages/commons/src/lib/server_api.ts
+++ b/packages/commons/src/lib/server_api.ts
@@ -1,4 +1,4 @@
-import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "./rows.js";
+import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType, RevisionSource } from "./rows.js";
type Response = {
success: true,
@@ -34,6 +34,7 @@ export interface RevisionItem {
type: NoteType;
title: string;
description?: string;
+ source?: RevisionSource;
isProtected?: boolean;
mime: string;
}
@@ -46,6 +47,7 @@ export interface RevisionPojo {
isProtected?: boolean;
title: string;
description?: string;
+ source?: RevisionSource;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;
From 62189cfa0497900aef23ba18ef355cfdb10595a1 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 12:48:03 +0300
Subject: [PATCH 45/79] refactor(revisions): extract inline styles to CSS
---
apps/client/src/widgets/dialogs/revisions.css | 67 +++++++++++++++++++
apps/client/src/widgets/dialogs/revisions.tsx | 27 +++-----
2 files changed, 76 insertions(+), 18 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 7d02c9cdbb..0f1ffe0314 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -71,6 +71,73 @@ body.mobile .revisions-dialog {
flex-shrink: 0;
}
+ .save-revision-controls {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ margin-inline-end: 10px;
+
+ .save-revision-input {
+ width: 200px;
+ }
+ }
+
+ .revision-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .revision-source-badge {
+ font-size: 0.75em;
+ padding: 0 4px;
+ border-radius: 3px;
+ background-color: var(--accented-background-color);
+ white-space: nowrap;
+ }
+
+ .revision-item-size {
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+
+ .revision-item-description {
+ font-size: 0.85em;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .revision-description-editor {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ margin: 3px 0;
+
+ input {
+ flex-grow: 1;
+ }
+ }
+
+ .revision-description-display {
+ display: flex;
+ align-items: center;
+ margin: 3px 0;
+ gap: 5px;
+ min-height: 24px;
+ }
+
+ .revision-description-text {
+ font-size: 0.9em;
+
+ &.empty {
+ opacity: 0.5;
+ font-style: italic;
+ }
+ }
+
.revision-content.type-file {
display: flex;
min-width: 0;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index b3d3803960..9e6bde84d0 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -164,21 +164,19 @@ function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () =
const [ description, setDescription ] = useState("");
return (
-
+
setDescription((e.target as HTMLInputElement).value)}
- style={{ width: "200px" }}
/>
{
await server.post(`notes/${noteId}/revision`, { description });
setDescription("");
@@ -200,25 +198,19 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
-
+
{item.dateCreated && item.dateCreated.substr(0, 16)}
{item.source && item.source !== "auto" && (
-
+
{t(`revisions.source_${item.source}`)}
)}
-
+
{item.contentLength && utils.formatSize(item.contentLength)}
{item.description && (
-
+
{item.description}
)}
@@ -331,7 +323,7 @@ function RevisionDescription({ revisionItem, editing, draft, onEdit, onDraftChan
}) {
if (editing) {
return (
-
+
@@ -353,8 +344,8 @@ function RevisionDescription({ revisionItem, editing, draft, onEdit, onDraftChan
}
return (
-
-
+
+
{revisionItem.description || t("revisions.description_placeholder")}
Date: Sat, 18 Apr 2026 12:53:54 +0300
Subject: [PATCH 46/79] feat(revisions): improve the list of revisions
---
apps/client/src/widgets/dialogs/revisions.css | 31 +++++++++----------
apps/client/src/widgets/dialogs/revisions.tsx | 22 +++++++------
2 files changed, 27 insertions(+), 26 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 0f1ffe0314..f27935288e 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -82,34 +82,33 @@ body.mobile .revisions-dialog {
}
}
- .revision-item-header {
+ .revision-item-description {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .revision-item-date {
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+
+ .revision-item-meta {
display: flex;
- justify-content: space-between;
align-items: center;
gap: 4px;
+ font-size: 0.85em;
+ opacity: 0.7;
}
.revision-source-badge {
- font-size: 0.75em;
+ font-size: 0.85em;
padding: 0 4px;
border-radius: 3px;
background-color: var(--accented-background-color);
white-space: nowrap;
}
- .revision-item-size {
- font-size: 0.85em;
- opacity: 0.7;
- }
-
- .revision-item-description {
- font-size: 0.85em;
- opacity: 0.7;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
.revision-description-editor {
display: flex;
gap: 5px;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 9e6bde84d0..530807acc8 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -198,22 +198,24 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
-
- {item.dateCreated && item.dateCreated.substr(0, 16)}
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+ {item.dateCreated && item.dateCreated.substr(0, 16)}
+
+
+
+ {item.contentLength && utils.formatSize(item.contentLength)}
+
{item.source && item.source !== "auto" && (
{t(`revisions.source_${item.source}`)}
)}
-
- {item.contentLength && utils.formatSize(item.contentLength)}
-
- {item.description && (
-
- {item.description}
-
- )}
)}
From fc5252e6b8f16427996c1160b6fe2b683444b9d2 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 12:54:17 +0300
Subject: [PATCH 47/79] feat(llm): provide description when saving a revision
---
apps/server/src/services/llm/tools/note_tools.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts
index d1ce83e3e0..8a4ec1c97b 100644
--- a/apps/server/src/services/llm/tools/note_tools.ts
+++ b/apps/server/src/services/llm/tools/note_tools.ts
@@ -101,10 +101,11 @@ export const noteTools = defineTools({
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to update"),
- content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
+ content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)"),
+ changeDescription: z.string().describe("A concise description of what was changed and why (e.g. 'Fix typos in introduction', 'Add section on error handling'). Keep it short, under 100 characters.")
}),
mutates: true,
- execute: ({ noteId, content }) => {
+ execute: ({ noteId, content, changeDescription }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
@@ -116,7 +117,7 @@ export const noteTools = defineTools({
return { error: `Cannot update content for note type: ${note.type}` };
}
- note.saveRevision({ source: "llm" });
+ note.saveRevision({ description: changeDescription, source: "llm" });
setNoteContentFromLlm(note, content);
return {
success: true,
@@ -130,10 +131,11 @@ export const noteTools = defineTools({
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to append to"),
- content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
+ content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)"),
+ changeDescription: z.string().describe("A concise description of what was appended (e.g. 'Add meeting notes for May 15', 'Append troubleshooting section'). Keep it short, under 100 characters.")
}),
mutates: true,
- execute: ({ noteId, content }) => {
+ execute: ({ noteId, content, changeDescription }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
@@ -158,7 +160,7 @@ export const noteTools = defineTools({
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
- note.saveRevision({ source: "llm" });
+ note.saveRevision({ description: changeDescription, source: "llm" });
note.setContent(newContent);
return {
success: true,
From 927c35971669299eea299ede2df5077466eab761 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:22:09 +0300
Subject: [PATCH 48/79] feat(revisions): basic rich diff
---
apps/client/package.json | 1 +
apps/client/src/types-lib.d.ts | 7 ++++
apps/client/src/widgets/dialogs/revisions.css | 27 +++++++++++++
apps/client/src/widgets/dialogs/revisions.tsx | 39 ++++++++++---------
pnpm-lock.yaml | 18 ++++-----
5 files changed, 63 insertions(+), 29 deletions(-)
diff --git a/apps/client/package.json b/apps/client/package.json
index 0978d1b745..716d63fceb 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -52,6 +52,7 @@
"dompurify": "3.4.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
+ "htmldiff-js": "1.0.5",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",
"jquery": "4.0.0",
diff --git a/apps/client/src/types-lib.d.ts b/apps/client/src/types-lib.d.ts
index 8d3c9296d1..c559dc6a55 100644
--- a/apps/client/src/types-lib.d.ts
+++ b/apps/client/src/types-lib.d.ts
@@ -1,3 +1,10 @@
+declare module "htmldiff-js" {
+ const HtmlDiff: {
+ execute(oldHtml: string, newHtml: string): string;
+ };
+ export default HtmlDiff;
+}
+
// TODO: Use real @types/ but that one generates a lot of errors.
declare module "draggabilly" {
type DraggabillyEventData = {};
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index f27935288e..3a49fa5c28 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -137,6 +137,33 @@ body.mobile .revisions-dialog {
}
}
+ /* HTML diff styles (htmldiff-js) */
+ .revision-diff-content {
+ ins {
+ text-decoration: none;
+
+ &.diffins {
+ background-color: color-mix(in srgb, var(--bs-success) 25%, transparent);
+ }
+
+ &.diffmod {
+ background-color: color-mix(in srgb, var(--bs-info) 25%, transparent);
+ }
+ }
+
+ del {
+ text-decoration: line-through;
+
+ &.diffdel {
+ background-color: color-mix(in srgb, var(--bs-danger) 25%, transparent);
+ }
+
+ &.diffmod {
+ background-color: color-mix(in srgb, var(--bs-warning) 25%, transparent);
+ }
+ }
+ }
+
.revision-content.type-file {
display: flex;
min-width: 0;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 530807acc8..12a8316e61 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -3,6 +3,7 @@ import "./revisions.css";
import type { RevisionItem, RevisionPojo } from "@triliumnext/commons";
import clsx from "clsx";
import { diffWords } from "diff";
+import HtmlDiff from "htmldiff-js";
import type { CSSProperties } from "preact/compat";
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
@@ -443,31 +444,31 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
return;
}
- let processedNoteContent = noteContent;
- let processedItemContent = itemContent;
-
if (itemType === "text") {
- processedNoteContent = utils.formatHtml(noteContent);
- processedItemContent = utils.formatHtml(itemContent);
- }
-
- const diff = diffWords(processedNoteContent, processedItemContent);
- const diffHtml = diff.map(part => {
- if (part.added) {
- return `${utils.escapeHtml(part.value)}`;
- } else if (part.removed) {
- return `${utils.escapeHtml(part.value)}`;
+ // Use proper HTML-aware diff for rich text content
+ const diffHtml = HtmlDiff.execute(noteContent, itemContent);
+ if (contentRef.current) {
+ contentRef.current.innerHTML = diffHtml;
}
- return utils.escapeHtml(part.value);
+ } else {
+ // Use word diff for code/mermaid (plain text)
+ const diff = diffWords(noteContent, itemContent);
+ const diffHtml = diff.map(part => {
+ if (part.added) {
+ return `${utils.escapeHtml(part.value)}`;
+ } else if (part.removed) {
+ return `${utils.escapeHtml(part.value)}`;
+ }
+ return utils.escapeHtml(part.value);
+ }).join("");
- }).join("");
-
- if (contentRef.current) {
- contentRef.current.innerHTML = diffHtml;
+ if (contentRef.current) {
+ contentRef.current.innerHTML = diffHtml;
+ }
}
}, [noteContent, itemContent, itemType]);
- return ;
+ return ;
}
function RevisionFooter({ note }: { note?: FNote }) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c1c7168a7..5169aa6521 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -315,6 +315,9 @@ importers:
force-graph:
specifier: 1.51.2
version: 1.51.2
+ htmldiff-js:
+ specifier: 1.0.5
+ version: 1.0.5
i18next:
specifier: 26.0.4
version: 26.0.4(typescript@6.0.2)
@@ -9406,6 +9409,9 @@ packages:
resolution: {integrity: sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==}
hasBin: true
+ htmldiff-js@1.0.5:
+ resolution: {integrity: sha512-rmow9353OK0elkub15Sbze8Nj7BYfduqoJJw4yEvHHjOcHeCazNPk0PoUbjE8SvxKgjymeRIFU/OnS8jtitRtA==}
+
htmlfy@0.8.1:
resolution: {integrity: sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==}
@@ -11656,10 +11662,6 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.8:
- resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
- engines: {node: ^10 || ^12 || >=14}
-
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
@@ -25000,6 +25002,8 @@ snapshots:
dependencies:
concat-stream: 1.6.2
+ htmldiff-js@1.0.5: {}
+
htmlfy@0.8.1: {}
htmlparser2@10.0.0:
@@ -27552,12 +27556,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.8:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
postcss@8.5.9:
dependencies:
nanoid: 3.3.11
From 315a97701beeaddd559984c04c8004e4648f0b2d Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:26:38 +0300
Subject: [PATCH 49/79] feat(revisions): add an icon based on the source
---
apps/client/src/widgets/dialogs/revisions.css | 4 ++++
apps/client/src/widgets/dialogs/revisions.tsx | 10 ++++++++++
2 files changed, 14 insertions(+)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 3a49fa5c28..438d32061a 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -69,6 +69,10 @@ body.mobile .revisions-dialog {
.revision-list {
flex-shrink: 0;
+
+ .dropdown-item + .dropdown-item {
+ border-top: 1px solid var(--main-border-color) !important;
+ }
}
.save-revision-controls {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 12a8316e61..56f9114bc1 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -189,6 +189,15 @@ function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () =
);
}
+const REVISION_SOURCE_ICONS: Record = {
+ auto: "bx bx-time-five",
+ manual: "bx bx-save",
+ etapi: "bx bx-code-alt",
+ llm: "bx bx-bot",
+ restore: "bx bx-history"
+};
+const DEFAULT_REVISION_ICON = "bx bx-file";
+
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
return (
@@ -196,6 +205,7 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
From 6436e56448233f20a6c8cefca4599a0399535641 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:30:09 +0300
Subject: [PATCH 50/79] feat(revisions): display date in relative time
---
apps/client/src/widgets/dialogs/revisions.tsx | 6 +++---
packages/commons/src/lib/dayjs.ts | 3 +++
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 56f9114bc1..eca813447c 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -1,6 +1,6 @@
import "./revisions.css";
-import type { RevisionItem, RevisionPojo } from "@triliumnext/commons";
+import { dayjs, type RevisionItem, type RevisionPojo } from "@triliumnext/commons";
import clsx from "clsx";
import { diffWords } from "diff";
import HtmlDiff from "htmldiff-js";
@@ -214,8 +214,8 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
{item.description}
)}
-
- {item.dateCreated && item.dateCreated.substr(0, 16)}
+
+ {item.dateCreated && dayjs(item.dateCreated).fromNow()}
diff --git a/packages/commons/src/lib/dayjs.ts b/packages/commons/src/lib/dayjs.ts
index 5bf0c1eafc..fe7e58b14c 100644
--- a/packages/commons/src/lib/dayjs.ts
+++ b/packages/commons/src/lib/dayjs.ts
@@ -7,6 +7,7 @@ import "dayjs/plugin/isoWeek";
import "dayjs/plugin/isSameOrAfter";
import "dayjs/plugin/isSameOrBefore";
import "dayjs/plugin/quarterOfYear";
+import "dayjs/plugin/relativeTime";
import "dayjs/plugin/utc";
//#region Plugins
@@ -17,6 +18,7 @@ import isoWeek from "dayjs/plugin/isoWeek.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js";
import quarterOfYear from "dayjs/plugin/quarterOfYear.js";
+import relativeTime from "dayjs/plugin/relativeTime.js";
import utc from "dayjs/plugin/utc.js";
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "./i18n.js";
@@ -27,6 +29,7 @@ dayjs.extend(isoWeek);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
dayjs.extend(quarterOfYear);
+dayjs.extend(relativeTime);
dayjs.extend(utc);
//#endregion
From 270f135632a181546d6ffde0d4fd543e598f58d7 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:33:44 +0300
Subject: [PATCH 51/79] feat(revisions): add a fall-back title for revisions
---
apps/client/src/widgets/dialogs/revisions.css | 4 ++++
apps/client/src/widgets/dialogs/revisions.tsx | 14 +++++++++-----
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 438d32061a..7d3e75f19a 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -90,6 +90,10 @@ body.mobile .revisions-dialog {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+
+ &.fallback {
+ opacity: 0.75;
+ }
}
.revision-item-date {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index eca813447c..8feb053dcb 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -198,6 +198,12 @@ const REVISION_SOURCE_ICONS: Record = {
};
const DEFAULT_REVISION_ICON = "bx bx-file";
+function formatRevisionFallback(item: RevisionItem): string {
+ const source = t(`revisions.source_${item.source ?? "auto"}`);
+ const date = item.dateCreated ? dayjs(item.dateCreated).format("MMM D, HH:mm") : "";
+ return date ? `${source} · ${date}` : source;
+}
+
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
return (
@@ -209,11 +215,9 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
- {item.description && (
-
- {item.description}
-
- )}
+
+ {item.description || formatRevisionFallback(item)}
+
{item.dateCreated && dayjs(item.dateCreated).fromNow()}
From 03d6c816590805a8e2874d1b42f6ea78802dc685 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:35:47 +0300
Subject: [PATCH 52/79] chore(revisions): get rid of source tag
---
apps/client/src/widgets/dialogs/revisions.css | 8 +-------
apps/client/src/widgets/dialogs/revisions.tsx | 5 -----
2 files changed, 1 insertion(+), 12 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 7d3e75f19a..8f8f2bc259 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -109,13 +109,7 @@ body.mobile .revisions-dialog {
opacity: 0.7;
}
- .revision-source-badge {
- font-size: 0.85em;
- padding: 0 4px;
- border-radius: 3px;
- background-color: var(--accented-background-color);
- white-space: nowrap;
- }
+
.revision-description-editor {
display: flex;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 8feb053dcb..c457f49bbe 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -225,11 +225,6 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
{item.contentLength && utils.formatSize(item.contentLength)}
- {item.source && item.source !== "auto" && (
-
- {t(`revisions.source_${item.source}`)}
-
- )}
From d086c8664b9f9efc54e8587b45f979573f120e81 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:51:04 +0300
Subject: [PATCH 53/79] feat(revisions): improve layout of note revision list
---
.../src/translations/en/translation.json | 13 ++++++++---
apps/client/src/widgets/dialogs/revisions.css | 8 -------
apps/client/src/widgets/dialogs/revisions.tsx | 22 +++++++++----------
3 files changed, 21 insertions(+), 22 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 9fbf88bc26..6d03aed254 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -311,11 +311,18 @@
"revision_saved": "Note revision has been saved.",
"edit_description": "Edit description",
"description_updated": "Revision description has been updated.",
- "source_auto": "Auto",
- "source_manual": "Manual",
+ "source_auto": "Auto-save",
+ "source_manual": "Manual save",
"source_etapi": "ETAPI",
"source_llm": "LLM",
- "source_restore": "Restore"
+ "source_restore": "Restore",
+ "source_unknown": "Snapshot",
+ "source_description_auto": "Automatically saved by the system at regular intervals",
+ "source_description_manual": "Manually saved by the user",
+ "source_description_etapi": "Created via the External Trilium API",
+ "source_description_llm": "Created by the AI assistant",
+ "source_description_restore": "Saved before restoring a previous revision",
+ "source_description_unknown": "Source not available"
},
"sort_child_notes": {
"sort_children_by": "Sort children by...",
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 8f8f2bc259..9e41c54cc7 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -96,15 +96,7 @@ body.mobile .revisions-dialog {
}
}
- .revision-item-date {
- font-size: 0.85em;
- opacity: 0.7;
- }
-
.revision-item-meta {
- display: flex;
- align-items: center;
- gap: 4px;
font-size: 0.85em;
opacity: 0.7;
}
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index c457f49bbe..e133b7f7da 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -198,10 +198,12 @@ const REVISION_SOURCE_ICONS: Record = {
};
const DEFAULT_REVISION_ICON = "bx bx-file";
-function formatRevisionFallback(item: RevisionItem): string {
- const source = t(`revisions.source_${item.source ?? "auto"}`);
- const date = item.dateCreated ? dayjs(item.dateCreated).format("MMM D, HH:mm") : "";
- return date ? `${source} · ${date}` : source;
+function getRevisionSourceTitle(source?: string): string {
+ return t(`revisions.source_description_${source ?? "unknown"}`);
+}
+
+function formatRevisionFallback(source?: string): string {
+ return t(`revisions.source_${source ?? "unknown"}`);
}
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
@@ -212,19 +214,17 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
key={item.revisionId}
value={item.revisionId}
icon={REVISION_SOURCE_ICONS[item.source ?? ""] ?? DEFAULT_REVISION_ICON}
+ title={getRevisionSourceTitle(item.source)}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
- {item.description || formatRevisionFallback(item)}
+ {item.description || formatRevisionFallback(item.source)}
-
+
{item.dateCreated && dayjs(item.dateCreated).fromNow()}
-
-
-
- {item.contentLength && utils.formatSize(item.contentLength)}
-
+ {item.dateCreated && item.contentLength && " · "}
+ {item.contentLength && utils.formatSize(item.contentLength)}
From 81ea86100fdfffbc78474e80343c837afed5cdb9 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 13:52:19 +0300
Subject: [PATCH 54/79] feat(revisions): add full date in tooltip
---
apps/client/src/stylesheets/style.css | 1 +
apps/client/src/widgets/dialogs/revisions.tsx | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index c1672f50c4..c5fd77ce5e 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -835,6 +835,7 @@ table.promoted-attributes-in-tooltip th {
text-align: start;
color: var(--main-text-color) !important;
max-width: 500px;
+ white-space: pre-line;
box-shadow: 10px 10px 93px -25px #aaaaaa;
}
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index e133b7f7da..ab0c280b2e 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -214,14 +214,14 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
key={item.revisionId}
value={item.revisionId}
icon={REVISION_SOURCE_ICONS[item.source ?? ""] ?? DEFAULT_REVISION_ICON}
- title={getRevisionSourceTitle(item.source)}
+ title={[getRevisionSourceTitle(item.source), item.dateCreated?.substring(0, 16)].filter(Boolean).join("\n")}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
{item.description || formatRevisionFallback(item.source)}
-
+
{item.dateCreated && dayjs(item.dateCreated).fromNow()}
{item.dateCreated && item.contentLength && " · "}
{item.contentLength && utils.formatSize(item.contentLength)}
From 6390f59fcfff8ed87698d484060b57d211cfa742 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 14:07:38 +0300
Subject: [PATCH 55/79] feat(revisions): improve header layout
---
.../src/translations/en/translation.json | 1 +
apps/client/src/widgets/dialogs/revisions.css | 40 ++--
apps/client/src/widgets/dialogs/revisions.tsx | 171 ++++++++----------
3 files changed, 109 insertions(+), 103 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 6d03aed254..b20878120e 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -287,6 +287,7 @@
"confirm_delete_all": "Do you want to delete all revisions of this note?",
"no_revisions": "No revisions for this note yet...",
"restore_button": "Restore",
+ "highlight_changes": "Highlight changes",
"diff_on": "Show diff",
"diff_off": "Show content",
"diff_on_hint": "Click to show note source diff",
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 9e41c54cc7..a9532ee0b4 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -63,8 +63,35 @@ body.mobile .revisions-dialog {
}
.revisions-dialog {
- .revision-title-buttons {
+ .revision-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
flex-shrink: 0;
+
+ .revision-title {
+ font-size: 1.2em;
+ margin: 3px;
+ flex-grow: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .revision-title-buttons {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ }
+
+ .revision-toolbar-separator {
+ width: 1px;
+ height: 1.2em;
+ background-color: var(--main-border-color);
+ margin: 0 4px;
}
.revision-list {
@@ -75,17 +102,6 @@ body.mobile .revisions-dialog {
}
}
- .save-revision-controls {
- display: flex;
- gap: 5px;
- align-items: center;
- margin-inline-end: 10px;
-
- .save-revision-input {
- width: 200px;
- }
- }
-
.revision-item-description {
white-space: nowrap;
overflow: hidden;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index ab0c280b2e..66b6d67cfb 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -34,7 +34,7 @@ export default function RevisionsDialog() {
const [ revisions, setRevisions ] = useState();
const [ currentRevision, setCurrentRevision ] = useState();
const [ shown, setShown ] = useState(false);
- const [ showDiff, setShowDiff ] = useState(false);
+ const [ showDiff, setShowDiff ] = useState(true);
const [ refreshCounter, setRefreshCounter ] = useState(0);
useTriliumEvent("showRevisions", async ({ noteId }) => {
@@ -78,34 +78,21 @@ export default function RevisionsDialog() {
/>
)}
{!!revisions?.length && (
- <>
- {["text", "code", "mermaid"].includes(currentRevision?.type ?? "") && (
- setShowDiff(newValue)}
- switchOnName={t("revisions.diff_on")}
- switchOffName={t("revisions.diff_off")}
- switchOnTooltip={t("revisions.diff_on_hint")}
- switchOffTooltip={t("revisions.diff_off_hint")}
- />
- )}
-
- {
- const text = t("revisions.confirm_delete_all");
+ {
+ const text = t("revisions.confirm_delete_all");
- if (note && await dialog.confirm(text)) {
- await server.remove(`notes/${note.noteId}/revisions`);
- setRevisions([]);
- setCurrentRevision(undefined);
- toast.showMessage(t("revisions.revisions_deleted"));
- }
- }}
- />
- >
+ if (note && await dialog.confirm(text)) {
+ await server.remove(`notes/${note.noteId}/revisions`);
+ setRevisions([]);
+ setCurrentRevision(undefined);
+ toast.showMessage(t("revisions.revisions_deleted"));
+ }
+ }}
+ />
)}
>
}
@@ -143,6 +130,7 @@ export default function RevisionsDialog() {
noteContent={noteContent}
revisionItem={currentRevision}
showDiff={showDiff}
+ setShowDiff={setShowDiff}
setShown={setShown}
onRevisionDeleted={() => {
setRefreshCounter(c => c + 1);
@@ -162,30 +150,18 @@ export default function RevisionsDialog() {
}
function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () => void }) {
- const [ description, setDescription ] = useState("");
-
return (
-
- setDescription((e.target as HTMLInputElement).value)}
- />
- {
- await server.post(`notes/${noteId}/revision`, { description });
- setDescription("");
- toast.showMessage(t("revisions.revision_saved"));
- onSaved();
- }}
- />
-
+ {
+ await server.post(`notes/${noteId}/revision`);
+ toast.showMessage(t("revisions.revision_saved"));
+ onSaved();
+ }}
+ />
);
}
@@ -232,10 +208,11 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
);
}
-function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
+function RevisionPreview({noteContent, revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
noteContent?: string,
revisionItem?: RevisionItem,
showDiff: boolean,
+ setShowDiff: Dispatch>,
setShown: Dispatch>,
onRevisionDeleted?: () => void,
onDescriptionUpdated?: (revisionId: string, description: string) => void
@@ -253,47 +230,59 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
setEditingDescription(false);
}, [revisionItem]);
+ const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
+ const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
+
return (
<>
-
- {revisionItem?.title ?? t("revisions.no_revisions")}
- {(revisionItem &&
- {(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) &&
- <>
- {
- if (await dialog.confirm(t("revisions.confirm_restore"))) {
- await server.post(`revisions/${revisionItem.revisionId}/restore`);
- setShown(false);
- toast.showMessage(t("revisions.revision_restored"));
- }
- }}/>
-
- {
- if (await dialog.confirm(t("revisions.confirm_delete"))) {
- await server.remove(`revisions/${revisionItem.revisionId}`);
- toast.showMessage(t("revisions.revision_deleted"));
- onRevisionDeleted?.();
- }
- }} />
-
- {
- if (revisionItem.revisionId) {
- open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);}
- }
- }/>
- >
- }
- )}
+
+ {revisionItem?.title ?? t("revisions.no_revisions")}
+ {revisionItem && (
+
+ {canShowDiff && (
+ setShowDiff(newValue)}
+ switchOnName={t("revisions.highlight_changes")}
+ switchOffName={t("revisions.highlight_changes")}
+ />
+ )}
+ {canInteract && canShowDiff && }
+ {canInteract && (
+ <>
+ {
+ if (await dialog.confirm(t("revisions.confirm_delete"))) {
+ await server.remove(`revisions/${revisionItem.revisionId}`);
+ toast.showMessage(t("revisions.revision_deleted"));
+ onRevisionDeleted?.();
+ }
+ }} frame />
+ {
+ if (revisionItem.revisionId) {
+ open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);
+ }
+ }}
+ frame />
+ {
+ if (await dialog.confirm(t("revisions.confirm_restore"))) {
+ await server.post(`revisions/${revisionItem.revisionId}/restore`);
+ setShown(false);
+ toast.showMessage(t("revisions.revision_restored"));
+ }
+ }}/>
+ >
+ )}
+
+ )}
{revisionItem && (
Date: Sat, 18 Apr 2026 14:18:29 +0300
Subject: [PATCH 56/79] feat(revisions): group less used options into overflow
menu
---
.../src/translations/en/translation.json | 5 +
apps/client/src/widgets/dialogs/revisions.css | 7 +
apps/client/src/widgets/dialogs/revisions.tsx | 151 ++++++++++--------
3 files changed, 92 insertions(+), 71 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index b20878120e..ab97764f30 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -301,7 +301,12 @@
"revision_deleted": "Note revision has been deleted.",
"snapshot_interval": "Note Revision Snapshot Interval: {{seconds}}s.",
"maximum_revisions": "Note Revision Snapshot Limit: {{number}}.",
+ "save_revision_now": "Save a revision now",
+ "snapshot_header": "Note revision snapshot",
+ "snapshot_interval_value": "Interval: {{seconds}}s",
+ "snapshot_limit_value": "Limit: {{number}}",
"settings": "Note Revision Settings",
+ "menu_tooltip": "Revision options",
"download_button": "Download",
"mime": "MIME: ",
"file_size": "File size:",
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index a9532ee0b4..6ed412ae01 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -87,6 +87,13 @@ body.mobile .revisions-dialog {
flex-shrink: 0;
}
+ .revision-menu-header {
+ font-weight: bold;
+ font-size: 0.85em;
+ text-transform: uppercase;
+ opacity: 0.6;
+ }
+
.revision-toolbar-separator {
width: 1px;
height: 1.2em;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 66b6d67cfb..3cf3c352e4 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -21,7 +21,8 @@ import toast from "../../services/toast";
import utils from "../../services/utils";
import ActionButton from "../react/ActionButton";
import Button from "../react/Button";
-import FormList, { FormListItem } from "../react/FormList";
+import Dropdown from "../react/Dropdown";
+import FormList, { FormDropdownDivider, FormListItem } from "../react/FormList";
import FormToggle from "../react/FormToggle";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
@@ -66,38 +67,20 @@ export default function RevisionsDialog() {
title={t("revisions.note_revisions")}
helpPageId="vZWERwf8U3nx"
bodyStyle={{ display: "flex", height: "80vh" }}
- header={
- <>
- {note && (
- {
- setRefreshCounter(c => c + 1);
- setCurrentRevision(undefined);
- }}
- />
- )}
- {!!revisions?.length && (
- {
- const text = t("revisions.confirm_delete_all");
-
- if (note && await dialog.confirm(text)) {
- await server.remove(`notes/${note.noteId}/revisions`);
- setRevisions([]);
- setCurrentRevision(undefined);
- toast.showMessage(t("revisions.revisions_deleted"));
- }
- }}
- />
- )}
- >
- }
- footer={ }
- footerStyle={{ paddingTop: 0, paddingBottom: 0 }}
+ header={note && (
+ {
+ setRefreshCounter(c => c + 1);
+ setCurrentRevision(undefined);
+ }}
+ onAllDeleted={() => {
+ setRevisions([]);
+ setCurrentRevision(undefined);
+ }}
+ hasRevisions={!!revisions?.length}
+ />
+ )}
onHidden={() => {
setShown(false);
setShowDiff(false);
@@ -149,19 +132,71 @@ export default function RevisionsDialog() {
);
}
-function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () => void }) {
+function RevisionsMenu({ note, onRevisionSaved, onAllDeleted, hasRevisions }: {
+ note: FNote,
+ onRevisionSaved: () => void,
+ onAllDeleted: () => void,
+ hasRevisions: boolean
+}) {
+ let revisionsNumberLimit: number | string = parseInt(note.getLabelValue("versioningLimit") ?? "", 10);
+ if (!Number.isInteger(revisionsNumberLimit)) {
+ revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
+ }
+ if (revisionsNumberLimit === -1) {
+ revisionsNumberLimit = "∞";
+ }
+
return (
- {
- await server.post(`notes/${noteId}/revision`);
- toast.showMessage(t("revisions.revision_saved"));
- onSaved();
- }}
- />
+ }
+ hideToggleArrow
+ iconAction
+ title={t("revisions.menu_tooltip")}
+ >
+ {
+ await server.post(`notes/${note.noteId}/revision`);
+ toast.showMessage(t("revisions.revision_saved"));
+ onRevisionSaved();
+ }}
+ >
+ {t("revisions.save_revision_now")}
+
+
+
+ {t("revisions.snapshot_header")}
+
+
+ {t("revisions.snapshot_interval_value", { seconds: options.getInt("revisionSnapshotTimeInterval") })}
+
+
+ {t("revisions.snapshot_limit_value", { number: revisionsNumberLimit })}
+
+ appContext.tabManager.openContextWithNote("_optionsOther", { activate: true })}
+ >
+ {t("revisions.settings")}
+
+ {hasRevisions && (
+ <>
+
+ {
+ if (await dialog.confirm(t("revisions.confirm_delete_all"))) {
+ await server.remove(`notes/${note.noteId}/revisions`);
+ onAllDeleted();
+ toast.showMessage(t("revisions.revisions_deleted"));
+ }
+ }}
+ >
+ {t("revisions.delete_all_revisions")}
+
+ >
+ )}
+
);
}
@@ -469,32 +504,6 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
return ;
}
-function RevisionFooter({ note }: { note?: FNote }) {
- if (!note) {
- return <>>;
- }
-
- let revisionsNumberLimit: number | string = parseInt(note?.getLabelValue("versioningLimit") ?? "", 10);
- if (!Number.isInteger(revisionsNumberLimit)) {
- revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
- }
- if (revisionsNumberLimit === -1) {
- revisionsNumberLimit = "∞";
- }
-
- return <>
-
- {t("revisions.snapshot_interval", { seconds: options.getInt("revisionSnapshotTimeInterval") })}
-
-
- {t("revisions.maximum_revisions", { number: revisionsNumberLimit })}
-
- appContext.tabManager.openContextWithNote("_optionsOther", { activate: true })}
- />
- >;
-}
function FilePreview({ revisionItem, fullRevision }: { revisionItem: RevisionItem, fullRevision: RevisionPojo }) {
return (
From b3e877f5dd9733e1e701d8342cb00422ee67e26a Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 14:24:11 +0300
Subject: [PATCH 57/79] feat(revisions): remove paddings for a smoother display
---
apps/client/src/stylesheets/style.css | 11 ++++++++---
apps/client/src/widgets/dialogs/revisions.css | 14 +++++++++++++-
apps/client/src/widgets/dialogs/revisions.tsx | 14 ++++----------
3 files changed, 25 insertions(+), 14 deletions(-)
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index c5fd77ce5e..71f0f7fd68 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -961,15 +961,19 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
-.help-button {
+.help-button,
+.custom-title-bar-button {
float: inline-end;
background: none;
font-weight: 900;
- color: orange;
border: 0;
cursor: pointer;
}
+.help-button {
+ color: orange;
+}
+
.multiplicity {
font-size: 1.3em;
}
@@ -1148,7 +1152,8 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
}
-.modal-header .help-button {
+.modal-header .help-button,
+.modal-header .custom-title-bar-button {
padding: 6px;
margin: 0 12px;
}
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 6ed412ae01..9d3b616237 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -18,7 +18,6 @@ body.mobile .revisions-dialog {
.modal-body {
height: fit-content !important;
flex-direction: column;
- padding: 0;
}
.modal-footer {
@@ -63,6 +62,10 @@ body.mobile .revisions-dialog {
}
.revisions-dialog {
+ .modal-body {
+ padding: 0;
+ }
+
.revision-toolbar {
display: flex;
align-items: center;
@@ -94,6 +97,15 @@ body.mobile .revisions-dialog {
opacity: 0.6;
}
+ .revision-content-wrapper {
+ flex-grow: 1;
+ margin-inline: 20px;
+ display: flex;
+ flex-direction: column;
+ max-width: calc(100% - 150px);
+ min-width: 0;
+ }
+
.revision-toolbar-separator {
width: 1px;
height: 1.2em;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 3cf3c352e4..1ddf3938d3 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -101,14 +101,7 @@ export default function RevisionsDialog() {
currentRevision={currentRevision}
/>
-
+
}
hideToggleArrow
- iconAction
- title={t("revisions.menu_tooltip")}
+ buttonClassName="custom-title-bar-button"
+ noSelectButtonStyle
+ buttonProps={{ title: t("revisions.menu_tooltip") }}
>
Date: Sat, 18 Apr 2026 14:27:07 +0300
Subject: [PATCH 58/79] feat(revisions): highlight inserted/removed images
---
apps/client/src/widgets/dialogs/revisions.css | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 9d3b616237..eebf6b532f 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -191,6 +191,23 @@ body.mobile .revisions-dialog {
background-color: color-mix(in srgb, var(--bs-warning) 25%, transparent);
}
}
+
+ /* Image diff styles */
+ ins img,
+ del img {
+ border: 3px solid;
+ border-radius: 4px;
+ position: relative;
+ }
+
+ del img {
+ border-color: var(--bs-danger);
+ opacity: 0.6;
+ }
+
+ ins img {
+ border-color: var(--bs-success);
+ }
}
.revision-content.type-file {
From 3ece5d6213bf9cfd065c22bf8b997848837fcedd Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 14:39:31 +0300
Subject: [PATCH 59/79] feat(revisions): group revisions and improve display
---
.../src/translations/en/translation.json | 4 +
apps/client/src/widgets/dialogs/revisions.css | 12 ++-
apps/client/src/widgets/dialogs/revisions.tsx | 73 +++++++++++++------
3 files changed, 64 insertions(+), 25 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index ab97764f30..e417e39386 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -323,6 +323,10 @@
"source_llm": "LLM",
"source_restore": "Restore",
"source_unknown": "Snapshot",
+ "date_today": "Today",
+ "date_yesterday": "Yesterday",
+ "date_this_week": "This week",
+ "date_this_month": "This month",
"source_description_auto": "Automatically saved by the system at regular intervals",
"source_description_manual": "Manually saved by the user",
"source_description_etapi": "Created via the External Trilium API",
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index eebf6b532f..189858ccfb 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -125,10 +125,16 @@ body.mobile .revisions-dialog {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
- &.fallback {
- opacity: 0.75;
- }
+ .revision-group-header {
+ font-size: 0.75em;
+ font-weight: bold;
+ text-transform: uppercase;
+ opacity: 0.5;
+ padding: 6px 12px 2px;
}
.revision-item-meta {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 1ddf3938d3..8438d37a07 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -4,6 +4,7 @@ import { dayjs, type RevisionItem, type RevisionPojo } from "@triliumnext/common
import clsx from "clsx";
import { diffWords } from "diff";
import HtmlDiff from "htmldiff-js";
+import { Fragment } from "preact";
import type { CSSProperties } from "preact/compat";
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
@@ -207,33 +208,61 @@ function getRevisionSourceTitle(source?: string): string {
return t(`revisions.source_description_${source ?? "unknown"}`);
}
-function formatRevisionFallback(source?: string): string {
- return t(`revisions.source_${source ?? "unknown"}`);
+function getRelativeDateGroup(dateStr: string): string {
+ const date = dayjs(dateStr);
+ const now = dayjs();
+
+ if (date.isSame(now, "day")) return t("revisions.date_today");
+ if (date.isSame(now.subtract(1, "day"), "day")) return t("revisions.date_yesterday");
+ if (date.isSame(now, "week")) return t("revisions.date_this_week");
+ if (date.isSame(now, "month")) return t("revisions.date_this_month");
+ return date.format("MMMM YYYY");
+}
+
+function buildRevisionTooltip(item: RevisionItem): string {
+ return [
+ getRevisionSourceTitle(item.source),
+ item.dateCreated && dayjs(item.dateCreated).fromNow(),
+ item.contentLength && utils.formatSize(item.contentLength)
+ ].filter(Boolean).join("\n");
}
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
+ let lastGroup = "";
+
return (
- {revisions.map((item) =>
-
-
-
- {item.description || formatRevisionFallback(item.source)}
-
-
- {item.dateCreated && dayjs(item.dateCreated).fromNow()}
- {item.dateCreated && item.contentLength && " · "}
- {item.contentLength && utils.formatSize(item.contentLength)}
-
-
-
- )}
+ {revisions.map((item) => {
+ const group = item.dateCreated ? getRelativeDateGroup(item.dateCreated) : "";
+ const showHeader = group !== lastGroup;
+ lastGroup = group;
+
+ return (
+
+ {showHeader && (
+ {group}
+ )}
+
+
+
+ {item.dateCreated && dayjs(item.dateCreated).format("MMM D · HH:mm")}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+
+ );
+ })}
);
}
From 1ab04cf951cc772183e9f4eaa23f29c663ceab60 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 14:46:56 +0300
Subject: [PATCH 60/79] feat(revisions): improve grouping with smart
auto-titles
---
apps/client/src/widgets/dialogs/revisions.css | 8 ++++
apps/client/src/widgets/dialogs/revisions.tsx | 47 ++++++++++++++-----
2 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 189858ccfb..609973cb86 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -119,6 +119,14 @@ body.mobile .revisions-dialog {
.dropdown-item + .dropdown-item {
border-top: 1px solid var(--main-border-color) !important;
}
+
+ .dropdown-item {
+ min-height: 2.5em;
+
+ >div {
+ padding-left: 0.25em;
+ }
+ }
}
.revision-item-description {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 8438d37a07..f23fcbc5c4 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -208,39 +208,64 @@ function getRevisionSourceTitle(source?: string): string {
return t(`revisions.source_description_${source ?? "unknown"}`);
}
-function getRelativeDateGroup(dateStr: string): string {
+type DateGroup = "today" | "yesterday" | "this_week" | "this_month" | "older";
+
+function getDateGroup(dateStr: string): DateGroup {
const date = dayjs(dateStr);
const now = dayjs();
- if (date.isSame(now, "day")) return t("revisions.date_today");
- if (date.isSame(now.subtract(1, "day"), "day")) return t("revisions.date_yesterday");
- if (date.isSame(now, "week")) return t("revisions.date_this_week");
- if (date.isSame(now, "month")) return t("revisions.date_this_month");
- return date.format("MMMM YYYY");
+ if (date.isSame(now, "day")) return "today";
+ if (date.isSame(now.subtract(1, "day"), "day")) return "yesterday";
+ if (date.isSame(now, "week")) return "this_week";
+ if (date.isSame(now, "month")) return "this_month";
+ return "older";
+}
+
+function getDateGroupLabel(group: DateGroup, dateStr: string): string {
+ if (group === "older") return dayjs(dateStr).format("MMMM YYYY");
+ return t(`revisions.date_${group}`);
+}
+
+function formatRevisionDate(dateStr: string, group: DateGroup): string {
+ const date = dayjs(dateStr);
+ switch (group) {
+ case "today":
+ case "yesterday":
+ return date.format("HH:mm");
+ case "this_week":
+ return date.format("dddd · HH:mm");
+ default:
+ return date.isSame(dayjs(), "year")
+ ? date.format("MMM D · HH:mm")
+ : date.format("MMM D, YYYY · HH:mm");
+ }
}
function buildRevisionTooltip(item: RevisionItem): string {
+ const dateLine = item.dateCreated
+ ? `${dayjs(item.dateCreated).format("YYYY-MM-DD HH:mm")} (${dayjs(item.dateCreated).fromNow()})`
+ : "";
return [
getRevisionSourceTitle(item.source),
- item.dateCreated && dayjs(item.dateCreated).fromNow(),
+ dateLine,
item.contentLength && utils.formatSize(item.contentLength)
].filter(Boolean).join("\n");
}
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
- let lastGroup = "";
+ let lastGroup: DateGroup | "" = "";
return (
{revisions.map((item) => {
- const group = item.dateCreated ? getRelativeDateGroup(item.dateCreated) : "";
+ const group = item.dateCreated ? getDateGroup(item.dateCreated) : "" as DateGroup;
const showHeader = group !== lastGroup;
lastGroup = group;
return (
{showHeader && (
- {group}
+ {item.dateCreated ? getDateGroupLabel(group, item.dateCreated) : ""}
)}
- {item.dateCreated && dayjs(item.dateCreated).format("MMM D · HH:mm")}
+ {item.dateCreated && formatRevisionDate(item.dateCreated, group)}
{item.description && (
From 20fca6c0df772d54f15011c0a1492e03fb505e1b Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 14:51:15 +0300
Subject: [PATCH 61/79] feat(revisions): improve layout and highlighting
---
apps/client/src/widgets/dialogs/revisions.css | 23 +++++++++----------
apps/client/src/widgets/dialogs/revisions.tsx | 1 +
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 609973cb86..ba5cd3f869 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -61,6 +61,12 @@ body.mobile .revisions-dialog {
}
}
+body.desktop .revisions-dialog {
+ .revision-list {
+ width: 300px;
+ }
+}
+
.revisions-dialog {
.modal-body {
padding: 0;
@@ -102,7 +108,6 @@ body.mobile .revisions-dialog {
margin-inline: 20px;
display: flex;
flex-direction: column;
- max-width: calc(100% - 150px);
min-width: 0;
}
@@ -125,6 +130,7 @@ body.mobile .revisions-dialog {
>div {
padding-left: 0.25em;
+ min-width: 0;
}
}
}
@@ -151,7 +157,6 @@ body.mobile .revisions-dialog {
}
-
.revision-description-editor {
display: flex;
gap: 5px;
@@ -185,24 +190,18 @@ body.mobile .revisions-dialog {
ins {
text-decoration: none;
- &.diffins {
- background-color: color-mix(in srgb, var(--bs-success) 25%, transparent);
- }
-
+ &.diffins,
&.diffmod {
- background-color: color-mix(in srgb, var(--bs-info) 25%, transparent);
+ background-color: color-mix(in srgb, var(--bs-success) 25%, transparent);
}
}
del {
text-decoration: line-through;
- &.diffdel {
- background-color: color-mix(in srgb, var(--bs-danger) 25%, transparent);
- }
-
+ &.diffdel,
&.diffmod {
- background-color: color-mix(in srgb, var(--bs-warning) 25%, transparent);
+ background-color: color-mix(in srgb, var(--bs-danger) 25%, transparent);
}
}
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index f23fcbc5c4..4807ee6269 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -246,6 +246,7 @@ function buildRevisionTooltip(item: RevisionItem): string {
? `${dayjs(item.dateCreated).format("YYYY-MM-DD HH:mm")} (${dayjs(item.dateCreated).fromNow()})`
: "";
return [
+ item.description,
getRevisionSourceTitle(item.source),
dateLine,
item.contentLength && utils.formatSize(item.contentLength)
From d1528a2f3a24f01ad8630f2a12af048289329934 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 15:10:58 +0300
Subject: [PATCH 62/79] "Claude PR Assistant workflow"
---
.github/workflows/claude.yml | 50 ++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 .github/workflows/claude.yml
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 0000000000..6b15fac7af
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr *)'
+
From 81dfc59846e6647fe0828d01f295c7ceb98d05a9 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 15:11:00 +0300
Subject: [PATCH 63/79] "Claude Code Review workflow"
---
.github/workflows/claude-code-review.yml | 44 ++++++++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 .github/workflows/claude-code-review.yml
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 0000000000..b5e8cfd4dc
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,44 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
+ plugins: 'code-review@claude-code-plugins'
+ prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+
From 01c57fc8db34ddff1d8e6ecaeacae25e94b276eb Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 16:48:54 +0300
Subject: [PATCH 64/79] feat(revisions): display sidebar in full-height
---
apps/client/src/stylesheets/style.css | 27 +++++++
apps/client/src/widgets/dialogs/revisions.css | 7 +-
apps/client/src/widgets/dialogs/revisions.tsx | 24 +++---
apps/client/src/widgets/react/Modal.tsx | 75 +++++++++++--------
4 files changed, 87 insertions(+), 46 deletions(-)
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index 71f0f7fd68..1babd999fe 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -1158,6 +1158,33 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
margin: 0 12px;
}
+.modal-content-with-sidebar {
+ flex-direction: row !important;
+}
+
+.modal-content-with-sidebar > .modal-sidebar {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--main-border-color);
+ overflow: auto;
+ flex-shrink: 0;
+}
+
+.modal-content-with-sidebar > .modal-main {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.modal-content-with-sidebar > .modal-main > .modal-body {
+ overflow: auto;
+ flex-grow: 1;
+ min-height: 0;
+}
+
.ck-mentions .ck-button {
font-size: var(--detail-font-size) !important;
padding: 5px;
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index ba5cd3f869..2a7eb8137a 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -103,12 +103,17 @@ body.desktop .revisions-dialog {
opacity: 0.6;
}
+ .modal-content {
+ height: 80vh;
+ }
+
.revision-content-wrapper {
flex-grow: 1;
- margin-inline: 20px;
+ padding-inline: 20px;
display: flex;
flex-direction: column;
min-width: 0;
+ overflow: auto;
}
.revision-toolbar-separator {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 4807ee6269..9af385a589 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -67,7 +67,6 @@ export default function RevisionsDialog() {
size="xl"
title={t("revisions.note_revisions")}
helpPageId="vZWERwf8U3nx"
- bodyStyle={{ display: "flex", height: "80vh" }}
header={note && (
)}
+ sidebar={
+ {
+ const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
+ if (correspondingRevision) {
+ setCurrentRevision(correspondingRevision);
+ }
+ }}
+ currentRevision={currentRevision}
+ />
+ }
onHidden={() => {
setShown(false);
setShowDiff(false);
@@ -91,17 +102,6 @@ export default function RevisionsDialog() {
}}
show={shown}
>
- {
- const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
- if (correspondingRevision) {
- setCurrentRevision(correspondingRevision);
- }
- }}
- currentRevision={currentRevision}
- />
-
(externalModalRef);
const modalInstanceRef = useRef();
const elementToFocus = useRef();
@@ -144,41 +150,44 @@ export default function Modal({ children, className, size, title, customTitleBar
return (
{(show || keepInDom) &&
-
-
- {!title || typeof title === "string" ? (
- {title ?? <> >}
+
+ {sidebar && {sidebar}}
+
+
+ {!title || typeof title === "string" ? (
+ {title ?? <> >}
+ ) : (
+ title
+ )}
+ {header}
+ {helpPageId && (
+ ?
+ )}
+
+ {titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
+
+ ))}
+
+
+
+
+
+ {onSubmit ? (
+
) : (
- title
+
+ {children}
+
)}
- {header}
- {helpPageId && (
- ?
- )}
-
- {titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
-
- ))}
-
-
-
-
- {onSubmit ? (
-
- ) : (
-
- {children}
-
- )}
}
From f9310c5cde12afb93b2e792a363d45464f794bea Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:00:19 +0300
Subject: [PATCH 65/79] feat(revisions): improve title display
---
apps/client/src/stylesheets/style.css | 2 +-
apps/client/src/widgets/dialogs/revisions.css | 29 +++++++++++++++----
apps/client/src/widgets/dialogs/revisions.tsx | 27 ++++++++++-------
3 files changed, 41 insertions(+), 17 deletions(-)
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index 1babd999fe..abc59d99c3 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -1166,8 +1166,8 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
display: flex;
flex-direction: column;
border-right: 1px solid var(--main-border-color);
- overflow: auto;
flex-shrink: 0;
+ min-height: 0;
}
.modal-content-with-sidebar > .modal-main {
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 2a7eb8137a..a3ca80de8d 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -72,6 +72,27 @@ body.desktop .revisions-dialog {
padding: 0;
}
+ .modal-sidebar {
+ background-color: var(--card-background-color);
+ }
+
+ .modal-sidebar .dropdown-menu.static {
+ background-color: transparent !important;
+ border-radius: 0 !important;
+ padding-top: 0 !important;
+ }
+
+ .revision-sidebar-header {
+ padding: 0.75rem 1rem;
+ flex-shrink: 0;
+ text-align: center;
+
+ h5 {
+ margin: 0;
+ font-size: 1em;
+ }
+ }
+
.revision-toolbar {
display: flex;
align-items: center;
@@ -124,11 +145,9 @@ body.desktop .revisions-dialog {
}
.revision-list {
- flex-shrink: 0;
-
- .dropdown-item + .dropdown-item {
- border-top: 1px solid var(--main-border-color) !important;
- }
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: auto;
.dropdown-item {
min-height: 2.5em;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 9af385a589..c34b4618c2 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -65,7 +65,7 @@ export default function RevisionsDialog() {
)}
sidebar={
- {
- const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
- if (correspondingRevision) {
- setCurrentRevision(correspondingRevision);
- }
- }}
- currentRevision={currentRevision}
- />
+ <>
+
+ {t("revisions.note_revisions")}
+
+ {
+ const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
+ if (correspondingRevision) {
+ setCurrentRevision(correspondingRevision);
+ }
+ }}
+ currentRevision={currentRevision}
+ />
+ >
}
onHidden={() => {
setShown(false);
From bd30e9efc169b2a8c9ba08f5cc63c5eb7e012475 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:11:22 +0300
Subject: [PATCH 66/79] feat(revisions): declutter the header
---
apps/client/src/widgets/dialogs/revisions.css | 38 ++---
apps/client/src/widgets/dialogs/revisions.tsx | 146 ++++++++++--------
2 files changed, 95 insertions(+), 89 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index a3ca80de8d..e3240635a6 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -70,6 +70,8 @@ body.desktop .revisions-dialog {
.revisions-dialog {
.modal-body {
padding: 0;
+ display: flex;
+ flex-direction: column;
}
.modal-sidebar {
@@ -94,27 +96,20 @@ body.desktop .revisions-dialog {
}
.revision-toolbar {
- display: flex;
- align-items: center;
- gap: 4px;
flex-shrink: 0;
-
- .revision-title {
- font-size: 1.2em;
- margin: 3px;
- flex-grow: 1;
- min-width: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
+ border-bottom: 1px solid var(--main-border-color);
+ padding: 8px 20px;
}
- .revision-title-buttons {
+ .revision-title {
+ font-size: 1.2em;
+ margin: 8px 0;
+ }
+
+ .revision-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
- flex-shrink: 0;
}
.revision-menu-header {
@@ -134,15 +129,16 @@ body.desktop .revisions-dialog {
display: flex;
flex-direction: column;
min-width: 0;
+ min-height: 0;
+ }
+
+ .revision-content {
+ flex-grow: 1;
+ min-height: 0;
overflow: auto;
}
- .revision-toolbar-separator {
- width: 1px;
- height: 1.2em;
- background-color: var(--main-border-color);
- margin: 0 4px;
- }
+
.revision-list {
flex: 1 1 0;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index c34b4618c2..2e073f349f 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -107,17 +107,21 @@ export default function RevisionsDialog() {
}}
show={shown}
>
+ {
+ setRefreshCounter(c => c + 1);
+ setCurrentRevision(undefined);
+ }}
+ />
{
- setRefreshCounter(c => c + 1);
- setCurrentRevision(undefined);
- }}
onDescriptionUpdated={(revisionId, description) => {
setRevisions(prev => prev?.map(r =>
r.revisionId === revisionId ? { ...r, description } : r
@@ -297,13 +301,72 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
);
}
-function RevisionPreview({noteContent, revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
- noteContent?: string,
+function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted }: {
revisionItem?: RevisionItem,
showDiff: boolean,
setShowDiff: Dispatch>,
setShown: Dispatch>,
onRevisionDeleted?: () => void,
+}) {
+ const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
+ const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
+
+ return (
+
+ {revisionItem && (
+
+ {canShowDiff && (
+ setShowDiff(newValue)}
+ switchOnName={t("revisions.highlight_changes")}
+ switchOffName={t("revisions.highlight_changes")}
+ />
+ )}
+
+ {canInteract && (
+ <>
+ {
+ if (await dialog.confirm(t("revisions.confirm_delete"))) {
+ await server.remove(`revisions/${revisionItem.revisionId}`);
+ toast.showMessage(t("revisions.revision_deleted"));
+ onRevisionDeleted?.();
+ }
+ }} frame />
+ {
+ if (revisionItem.revisionId) {
+ open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);
+ }
+ }}
+ frame />
+ {
+ if (await dialog.confirm(t("revisions.confirm_restore"))) {
+ await server.post(`revisions/${revisionItem.revisionId}/restore`);
+ setShown(false);
+ toast.showMessage(t("revisions.revision_restored"));
+ }
+ }}/>
+ >
+ )}
+
+ )}
+
+ );
+}
+
+function RevisionPreview({noteContent, revisionItem, showDiff, onDescriptionUpdated }: {
+ noteContent?: string,
+ revisionItem?: RevisionItem,
+ showDiff: boolean,
onDescriptionUpdated?: (revisionId: string, description: string) => void
}) {
const [ fullRevision, setFullRevision ] = useState();
@@ -319,60 +382,12 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShowDiff, setS
setEditingDescription(false);
}, [revisionItem]);
- const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
- const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
-
return (
- <>
-
- {revisionItem?.title ?? t("revisions.no_revisions")}
- {revisionItem && (
-
- {canShowDiff && (
- setShowDiff(newValue)}
- switchOnName={t("revisions.highlight_changes")}
- switchOffName={t("revisions.highlight_changes")}
- />
- )}
- {canInteract && canShowDiff && }
- {canInteract && (
- <>
- {
- if (await dialog.confirm(t("revisions.confirm_delete"))) {
- await server.remove(`revisions/${revisionItem.revisionId}`);
- toast.showMessage(t("revisions.revision_deleted"));
- onRevisionDeleted?.();
- }
- }} frame />
- {
- if (revisionItem.revisionId) {
- open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);
- }
- }}
- frame />
- {
- if (await dialog.confirm(t("revisions.confirm_restore"))) {
- await server.post(`revisions/${revisionItem.revisionId}/restore`);
- setShown(false);
- toast.showMessage(t("revisions.revision_restored"));
- }
- }}/>
- >
- )}
-
- )}
-
+
+ {revisionItem?.title ?? t("revisions.no_revisions")}
{revisionItem && (
setEditingDescription(false)}
/>
)}
-
-
-
- >
+
+
);
}
From fb5520a3c5f4f7abcc1fb8f0ac7c84448752fc4a Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:17:02 +0300
Subject: [PATCH 67/79] feat(revisions): relocate the description
---
apps/client/src/widgets/dialogs/revisions.css | 5 ++
apps/client/src/widgets/dialogs/revisions.tsx | 82 ++++++++++---------
2 files changed, 49 insertions(+), 38 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index e3240635a6..b61cced865 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -177,6 +177,11 @@ body.desktop .revisions-dialog {
}
+ .revision-description-icon {
+ opacity: 0.5;
+ flex-shrink: 0;
+ }
+
.revision-description-editor {
display: flex;
gap: 5px;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 2e073f349f..3578599151 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -116,20 +116,21 @@ export default function RevisionsDialog() {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}}
+ onDescriptionUpdated={(revisionId, description) => {
+ setRevisions(prev => prev?.map(r =>
+ r.revisionId === revisionId ? { ...r, description } : r
+ ));
+ if (currentRevision?.revisionId === revisionId) {
+ setCurrentRevision({ ...currentRevision, description });
+ }
+ }}
/>
{
- setRevisions(prev => prev?.map(r =>
- r.revisionId === revisionId ? { ...r, description } : r
- ));
- if (currentRevision?.revisionId === revisionId) {
- setCurrentRevision({ ...currentRevision, description });
- }
- }} />
+ />
);
@@ -301,15 +302,22 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
);
}
-function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted }: {
+function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
revisionItem?: RevisionItem,
showDiff: boolean,
setShowDiff: Dispatch>,
setShown: Dispatch>,
onRevisionDeleted?: () => void,
+ onDescriptionUpdated?: (revisionId: string, description: string) => void,
}) {
const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
+ const [ editingDescription, setEditingDescription ] = useState(false);
+ const [ descriptionDraft, setDescriptionDraft ] = useState("");
+
+ useEffect(() => {
+ setEditingDescription(false);
+ }, [revisionItem]);
return (
@@ -359,35 +367,6 @@ function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevi
)}
)}
-
- );
-}
-
-function RevisionPreview({noteContent, revisionItem, showDiff, onDescriptionUpdated }: {
- noteContent?: string,
- revisionItem?: RevisionItem,
- showDiff: boolean,
- onDescriptionUpdated?: (revisionId: string, description: string) => void
-}) {
- const [ fullRevision, setFullRevision ] = useState();
- const [ editingDescription, setEditingDescription ] = useState(false);
- const [ descriptionDraft, setDescriptionDraft ] = useState("");
-
- useEffect(() => {
- if (revisionItem) {
- server.get(`revisions/${revisionItem.revisionId}`).then(setFullRevision);
- } else {
- setFullRevision(undefined);
- }
- setEditingDescription(false);
- }, [revisionItem]);
-
- return (
-
- {revisionItem?.title ?? t("revisions.no_revisions")}
{revisionItem && (
setEditingDescription(false)}
/>
)}
+
+ );
+}
+
+function RevisionPreview({noteContent, revisionItem, showDiff }: {
+ noteContent?: string,
+ revisionItem?: RevisionItem,
+ showDiff: boolean,
+}) {
+ const [ fullRevision, setFullRevision ] = useState();
+
+ useEffect(() => {
+ if (revisionItem) {
+ server.get(`revisions/${revisionItem.revisionId}`).then(setFullRevision);
+ } else {
+ setFullRevision(undefined);
+ }
+ }, [revisionItem]);
+
+ return (
+
+ {revisionItem?.title ?? t("revisions.no_revisions")}
);
@@ -424,6 +428,7 @@ function RevisionDescription({ revisionItem, editing, draft, onEdit, onDraftChan
if (editing) {
return (
+
+
{revisionItem.description || t("revisions.description_placeholder")}
From f4cf8829f7a10a829f744c51420876c43eed2783 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:23:07 +0300
Subject: [PATCH 68/79] feat(revisions): improve the layout even further &
rebrand description to name
---
apps/client/src/translations/en/translation.json | 6 +++---
apps/client/src/widgets/dialogs/revisions.css | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index e417e39386..0367588547 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -313,10 +313,10 @@
"preview_not_available": "Preview isn't available for this note type.",
"save_revision": "Save revision",
"save_revision_tooltip": "Manually save a snapshot of the current note",
- "description_placeholder": "Add a description (optional)",
+ "description_placeholder": "Name this revision",
"revision_saved": "Note revision has been saved.",
- "edit_description": "Edit description",
- "description_updated": "Revision description has been updated.",
+ "edit_description": "Edit name",
+ "description_updated": "Revision name has been updated.",
"source_auto": "Auto-save",
"source_manual": "Manual save",
"source_etapi": "ETAPI",
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index b61cced865..378d24c391 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -81,13 +81,13 @@ body.desktop .revisions-dialog {
.modal-sidebar .dropdown-menu.static {
background-color: transparent !important;
border-radius: 0 !important;
- padding-top: 0 !important;
}
.revision-sidebar-header {
padding: 0.75rem 1rem;
flex-shrink: 0;
text-align: center;
+ border-bottom: 1px solid var(--main-border-color);
h5 {
margin: 0;
@@ -125,7 +125,6 @@ body.desktop .revisions-dialog {
.revision-content-wrapper {
flex-grow: 1;
- padding-inline: 20px;
display: flex;
flex-direction: column;
min-width: 0;
@@ -136,6 +135,7 @@ body.desktop .revisions-dialog {
flex-grow: 1;
min-height: 0;
overflow: auto;
+ padding: 20px;
}
From a2cd75c2c485985f9952c0e2089bfa569f147e64 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:36:31 +0300
Subject: [PATCH 69/79] feat(revisions): preserve monospace for code notes diff
---
apps/client/src/widgets/dialogs/revisions.css | 9 +++++++++
apps/client/src/widgets/dialogs/revisions.tsx | 4 ++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 378d24c391..6ed398c149 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -210,6 +210,15 @@ body.desktop .revisions-dialog {
}
}
+ .revision-diff-code {
+ font-family: var(--font-family-monospace, monospace);
+ font-size: 0.9rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-width: 100%;
+ padding: 0;
+ }
+
/* HTML diff styles (htmldiff-js) */
.revision-diff-content {
ins {
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 3578599151..0b72b2e1e3 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -488,7 +488,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
case "text":
return ;
case "code":
- return {content};
+ return {content};
case "image":
switch (revisionItem.mime) {
case "image/svg+xml": {
@@ -571,7 +571,7 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
}
}, [noteContent, itemContent, itemType]);
- return ;
+ return ;
}
From ffe60580dbba8badbc7afb8a962df3ce1c005631 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 17:55:03 +0300
Subject: [PATCH 70/79] fix(revisions): layout on mobile
---
apps/client/src/stylesheets/style.css | 51 +++++++++++++++
apps/client/src/widgets/dialogs/revisions.css | 63 ++-----------------
apps/client/src/widgets/dialogs/revisions.tsx | 27 ++++----
apps/client/src/widgets/react/Modal.tsx | 7 ++-
4 files changed, 73 insertions(+), 75 deletions(-)
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index abc59d99c3..1fdfe9aeea 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -1170,6 +1170,31 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
min-height: 0;
}
+.modal-content-with-sidebar .modal-sidebar-header {
+ padding: 0.75rem 1rem;
+ flex-shrink: 0;
+ text-align: center;
+ border-bottom: 1px solid var(--main-border-color);
+}
+
+.modal-content-with-sidebar .modal-sidebar-header h5 {
+ margin: 0;
+ font-size: 1em;
+}
+
+.modal-content-with-sidebar > .modal-main > .modal-header > .modal-title {
+ visibility: hidden;
+ flex-grow: 1;
+ width: 0;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+}
+
+.modal-content-with-sidebar > .modal-main > .modal-header {
+ flex-wrap: nowrap;
+}
+
.modal-content-with-sidebar > .modal-main {
display: flex;
flex-direction: column;
@@ -1185,6 +1210,32 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
min-height: 0;
}
+body.mobile .modal-content-with-sidebar {
+ flex-direction: column !important;
+}
+
+body.mobile .modal-content-with-sidebar > .modal-sidebar {
+ border-right: none;
+ border-bottom: 1px solid var(--main-border-color);
+ height: 30vh;
+ flex-shrink: 0;
+ overflow: hidden;
+ order: 1;
+}
+
+body.mobile .modal-content-with-sidebar > .modal-main {
+ order: 0;
+}
+
+body.mobile .modal-content-with-sidebar .modal-sidebar-header {
+ display: none;
+}
+
+body.mobile .modal-content-with-sidebar > .modal-main > .modal-header > .modal-title {
+ visibility: visible;
+ width: auto;
+}
+
.ck-mentions .ck-button {
font-size: var(--detail-font-size) !important;
padding: 5px;
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index 6ed398c149..ca938047c2 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -3,61 +3,20 @@ body.mobile .revisions-dialog {
height: 95vh;
}
- .modal-header {
- display: flex;
- flex-wrap: wrap;
- gap: 0.25em;
- font-size: 0.9em;
- }
-
- .modal-title {
- flex-grow: 1;
- width: 100%;
- }
-
- .modal-body {
- height: fit-content !important;
- flex-direction: column;
- }
-
.modal-footer {
font-size: 0.9em;
}
- .revision-list {
- height: fit-content !important;
- max-height: 20vh;
- border-bottom: 1px solid var(--main-border-color) !important;
- padding: 0 1em;
- flex-shrink: 0;
- }
-
- .modal-body > .revision-content-wrapper {
- flex-grow: 1;
- max-width: unset !important;
- height: 100%;
- margin: 0;
- display: block !important;
- }
-
- .modal-body > .revision-content-wrapper > div:first-of-type {
- flex-direction: column;
- }
-
.revision-title {
font-size: 1rem;
}
- .revision-title-buttons {
- text-align: center;
- display: flex;
- gap: 0.25em;
+ .revision-toolbar-actions {
flex-wrap: wrap;
}
.revision-content {
padding: 0.5em;
- height: fit-content;
}
}
@@ -65,6 +24,10 @@ body.desktop .revisions-dialog {
.revision-list {
width: 300px;
}
+
+ .modal-content {
+ height: 80vh;
+ }
}
.revisions-dialog {
@@ -83,18 +46,6 @@ body.desktop .revisions-dialog {
border-radius: 0 !important;
}
- .revision-sidebar-header {
- padding: 0.75rem 1rem;
- flex-shrink: 0;
- text-align: center;
- border-bottom: 1px solid var(--main-border-color);
-
- h5 {
- margin: 0;
- font-size: 1em;
- }
- }
-
.revision-toolbar {
flex-shrink: 0;
border-bottom: 1px solid var(--main-border-color);
@@ -119,10 +70,6 @@ body.desktop .revisions-dialog {
opacity: 0.6;
}
- .modal-content {
- height: 80vh;
- }
-
.revision-content-wrapper {
flex-grow: 1;
display: flex;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 0b72b2e1e3..3fdd9d0952 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -65,7 +65,7 @@ export default function RevisionsDialog() {
)}
sidebar={
- <>
-
- {t("revisions.note_revisions")}
-
- {
- const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
- if (correspondingRevision) {
- setCurrentRevision(correspondingRevision);
- }
- }}
- currentRevision={currentRevision}
- />
- >
+ {
+ const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
+ if (correspondingRevision) {
+ setCurrentRevision(correspondingRevision);
+ }
+ }}
+ currentRevision={currentRevision}
+ />
}
onHidden={() => {
setShown(false);
diff --git a/apps/client/src/widgets/react/Modal.tsx b/apps/client/src/widgets/react/Modal.tsx
index eaef4c2fa9..674729b7bc 100644
--- a/apps/client/src/widgets/react/Modal.tsx
+++ b/apps/client/src/widgets/react/Modal.tsx
@@ -151,7 +151,12 @@ export default function Modal({ children, className, size, title, customTitleBar
{(show || keepInDom) &&
- {sidebar && {sidebar}}
+ {sidebar &&
+ {title &&
+ {typeof title === "string" ? title : title}
+ }
+ {sidebar}
+ }
{!title || typeof title === "string" ? (
From dabbbbb874229863f91ed435b510762d8db31e54 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 18:01:34 +0300
Subject: [PATCH 71/79] fix(revisions): context menu on mobile + regression in
tab switcher
---
apps/client/src/widgets/dialogs/revisions.tsx | 2 ++
apps/client/src/widgets/react/Modal.tsx | 11 +++++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 3fdd9d0952..2fddb65ac3 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -152,6 +152,8 @@ function RevisionsMenu({ note, onRevisionSaved, onAllDeleted, hasRevisions }: {
buttonClassName="custom-title-bar-button"
noSelectButtonStyle
buttonProps={{ title: t("revisions.menu_tooltip") }}
+ dropdownContainerClassName="mobile-bottom-menu"
+ dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
>
}
{sidebar}
}
-
+
{!title || typeof title === "string" ? (
{title ?? <> >}
@@ -192,13 +192,20 @@ export default function Modal({ children, className, size, title, customTitleBar
{children}
)}
-
+
}
);
}
+function ModalMain({ sidebar, children }: { sidebar: boolean; children: ComponentChildren }) {
+ if (sidebar) {
+ return {children};
+ }
+ return <>{children}>;
+}
+
function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick) {
// Memoize footer style
const footerStyle = useMemo(() => {
From da96d2bc88e3507cd151358c5bb64672aa2935bd Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 18:08:11 +0300
Subject: [PATCH 72/79] feat(revisions): dedicated no revisions modal
---
apps/client/src/widgets/dialogs/revisions.css | 6 ++-
apps/client/src/widgets/dialogs/revisions.tsx | 52 +++++++++++++++----
2 files changed, 47 insertions(+), 11 deletions(-)
diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css
index ca938047c2..0985839c04 100644
--- a/apps/client/src/widgets/dialogs/revisions.css
+++ b/apps/client/src/widgets/dialogs/revisions.css
@@ -25,7 +25,7 @@ body.desktop .revisions-dialog {
width: 300px;
}
- .modal-content {
+ .modal-content-with-sidebar {
height: 80vh;
}
}
@@ -85,7 +85,9 @@ body.desktop .revisions-dialog {
padding: 20px;
}
-
+ .no-items {
+ padding-block: 3em;
+ }
.revision-list {
flex: 1 1 0;
diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx
index 2fddb65ac3..e9f67d88f5 100644
--- a/apps/client/src/widgets/dialogs/revisions.tsx
+++ b/apps/client/src/widgets/dialogs/revisions.tsx
@@ -27,6 +27,7 @@ import FormList, { FormDropdownDivider, FormListItem } from "../react/FormList";
import FormToggle from "../react/FormToggle";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
+import NoItems from "../react/NoItems";
import { RawHtmlBlock } from "../react/RawHtml";
import PdfViewer from "../type_widgets/file/PdfViewer";
@@ -57,10 +58,49 @@ export default function RevisionsDialog() {
}
}, [ note, refreshCounter ]);
+ const hasRevisions = !!revisions?.length;
+
if (revisions?.length && !currentRevision) {
setCurrentRevision(revisions[0]);
}
+ const onHidden = () => {
+ setShown(false);
+ setShowDiff(true);
+ setNote(undefined);
+ setCurrentRevision(undefined);
+ setRevisions(undefined);
+ };
+
+ if (!hasRevisions) {
+ return (
+ {
+ setRefreshCounter(c => c + 1);
+ setCurrentRevision(undefined);
+ }}
+ onAllDeleted={() => {
+ setRevisions([]);
+ setCurrentRevision(undefined);
+ }}
+ hasRevisions={false}
+ />
+ )}
+ onHidden={onHidden}
+ show={shown}
+ >
+
+
+ );
+ }
+
return (
)}
sidebar={
@@ -93,13 +133,7 @@ export default function RevisionsDialog() {
currentRevision={currentRevision}
/>
}
- onHidden={() => {
- setShown(false);
- setShowDiff(false);
- setNote(undefined);
- setCurrentRevision(undefined);
- setRevisions(undefined);
- }}
+ onHidden={onHidden}
show={shown}
>
- {revisionItem?.title ?? t("revisions.no_revisions")}
+ {revisionItem?.title}
);
From 9d953ea7005e8d67be5e83295f7ec2c59e2c1404 Mon Sep 17 00:00:00 2001
From: Elian Doran
Date: Sat, 18 Apr 2026 18:47:21 +0300
Subject: [PATCH 73/79] docs(user): update note revisions to new layout
---
.../Notes/1_Note Revisions_image.png | Bin 0 -> 927 bytes
.../Notes/2_Note Revisions_image.png | Bin 0 -> 364139 bytes
.../Notes/Note Revisions.html | 119 ++++++++++++++++--
.../Notes/Note Revisions_image.png | Bin 0 -> 548 bytes
.../Notes/Note Revisions_note-revisi.png | Bin 143455 -> 0 bytes
.../User Guide/Note Types/Markdown.html | 52 ++++----
.../Developer Guide/Documentation.md | 2 +-
docs/User Guide/!!!meta.json | 57 ++++++++-
.../Notes/1_Note Revisions_image.png | Bin 0 -> 927 bytes
.../Notes/2_Note Revisions_image.png | Bin 0 -> 364139 bytes
.../Notes/Note Revisions.md | 64 ++++++++--
.../Notes/Note Revisions_image.png | Bin 0 -> 548 bytes
.../Notes/Note Revisions_note-revisi.png | Bin 143455 -> 0 bytes
13 files changed, 247 insertions(+), 47 deletions(-)
create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/1_Note Revisions_image.png
create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/2_Note Revisions_image.png
create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions_image.png
delete mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions_note-revisi.png
create mode 100644 docs/User Guide/User Guide/Basic Concepts and Features/Notes/1_Note Revisions_image.png
create mode 100644 docs/User Guide/User Guide/Basic Concepts and Features/Notes/2_Note Revisions_image.png
create mode 100644 docs/User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions_image.png
delete mode 100644 docs/User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions_note-revisi.png
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/1_Note Revisions_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/1_Note Revisions_image.png
new file mode 100644
index 0000000000000000000000000000000000000000..10b64a944c414798ef2d479bcd142bf63962da9a
GIT binary patch
literal 927
zcmV;Q17Q4#P)X1^@s6z#LUx00004b3#c}2nYxW
zdm8m2fRu+`
zurMT_-b`YLhll9(dgUVeMngkGwj%n;udgp`Yinb{q@*N#cz9rlNvx!#gu%VNy^e+S
zz^0}q=IH3?P>ZdstT2c~txTjlQtRyOR6@kDK-v{WCnO{UFE1}8&akuk`g#d)xm>uv
zzpo^ci@wvpxw+XAGdeoTxW2wVqZq|IDk@3>a&mGkNt}wky}cnN3Go#c7D_(17)ZOg
zxB#(T*%w*$?fw6UpPwJ(<>g6&?d@&(WHE6NnK>~rk^6QXk*OfgBDe1DZUbE7xw^W7
z*Vk9@9T*sZjg1XBJ3C|G@bEA!EG$5Fb~e=2)miF^QU=sC)Az|I${5ex-`|6;$U10m
zZwD%}nk_CaLS$qlbaizpg(@p6mHyP!RD=Ke`kKe#;o;)z37eXl;>;1VG8^aT=WUb3
z$z)nun&gd%iIH$l{g##%78E;m!exs&Jw0WCd~Q(xjCt5*r^MXIfJs
z)@GicpD{l_A4^M1QDoK6_fU?-;lk}7PhvwWI58)
zG&D3oe}6xCJRT#p@>Rc`^kM1p)Cx3x1G|=zl7cfcGnSN=Vbrg9e0;>Qu`v{t1LNc4
z`QL=9Ites3Hsaje+^@-G8AerT^**s{ps@bM{sFG7Z2CN{wvYe-002ovPDHLkV1o1W
B#0vlb
literal 0
HcmV?d00001
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/2_Note Revisions_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Notes/2_Note Revisions_image.png
new file mode 100644
index 0000000000000000000000000000000000000000..cdaf1cef601e8fc6928c1701b1ffc2677fc7a4c1
GIT binary patch
literal 364139
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%0D_Q24WmE)7X)Y2Tz~;8-1iOFm$V2
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