Embeddings are important as it allows us to have an compact
AI “summary” (it's not human readable text) of each of your Notes, that
we can then perform mathematical functions on (such as cosine similarity)
@@ -79,59 +80,59 @@ class="image image_resized" style="width:74.04%;">
These are the tools that currently exist, and will certainly be updated
to be more effectively (and even more to be added!):
-
search_notes
+
search_notes
-
Semantic search
+
Semantic search
-
keyword_search
+
keyword_search
-
Keyword-based search
+
Keyword-based search
-
attribute_search
+
attribute_search
-
Attribute-specific search
+
Attribute-specific search
-
search_suggestion
+
search_suggestion
-
Search syntax helper
+
Search syntax helper
-
read_note
+
read_note
-
Read note content (helps the LLM read Notes)
+
Read note content (helps the LLM read Notes)
-
create_note
+
create_note
-
Create a Note
+
Create a Note
-
update_note
+
update_note
-
Update a Note
+
Update a Note
-
manage_attributes
+
manage_attributes
-
Manage attributes on a Note
+
Manage attributes on a Note
-
manage_relationships
+
manage_relationships
-
Manage the various relationships between Notes
+
Manage the various relationships between Notes
-
extract_content
+
extract_content
-
Used to smartly extract content from a Note
+
Used to smartly extract content from a Note
-
calendar_integration
+
calendar_integration
-
Used to find date notes, create date notes, get the daily note, etc.
+
Used to find date notes, create date notes, get the daily note, etc.
If you don't see the button in the Launch Bar,
you might need to move it from the Available Launchers section to
the Visible Launchers section:
This functionality is still in preview, expect possible issues or even
the feature disappearing completely.
- Feel free to report any
- issues you might have.
+ Feel free to report any issues you might
+ have.
The read-only database is an alternative to Sharing notes.
- Although the share functionality works pretty well to publish pages to
- the Internet in a wiki, blog-like format it does not offer the full functionality
- behind Trilium (such as the advanced Search or
- the interactivity behind Collections or
- the various Note Types).
+ href="#root/_help_R9pX4DGra2Vt">Sharing notes. Although the share functionality
+ works pretty well to publish pages to the Internet in a wiki, blog-like
+ format it does not offer the full functionality behind Trilium (such as
+ the advanced Search or
+ the interactivity behind Collections or
+ the various Note Types).
When the database is in read-only mode, the Trilium application can be
used as normal, but editing is disabled and changes are made in-memory
only.
What it does
-
All notes are read-only, without the possibility of editing them.
-
Features that would normally alter the database such as the list of recent
+
All notes are read-only, without the possibility of editing them.
+
Features that would normally alter the database such as the list of recent
notes are disabled.
Limitations
-
Some features might “slip through” and still end up creating a note, for
+
Some features might “slip through” and still end up creating a note, for
example.
-
However, the database is still read-only, so all modifications will be
+
However, the database is still read-only, so all modifications will be
reset if the server is restarted.
-
Whenever this occurs, ERROR: read-only DB ignored will be shown
+
Whenever this occurs, ERROR: read-only DB ignored will be shown
in the logs.
Setting a database as read-only
First, make sure the database is initialized (e.g. the first set up is
- complete). Then modify the config.ini by
+ complete). Then modify the config.ini by
looking for the [General] section and adding a new readOnly field:
[General]
readOnly=true
If your server is already running, restart it to apply the changes.
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .html
similarity index 88%
rename from apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.html
rename to apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .html
index 2e8d0601f..f36ee6be7 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .html
@@ -41,6 +41,15 @@
The export requires a functional web server as the pages will not render
properly if accessed locally via a web browser due to the use of module
scripts.
+
The directory structure is also slightly different:
+
+
A normal HTML export results in an index file and a single directory.
+
Instead, for static exporting the top-root level becomes the index file
+ and the child directories are on the root instead.
+
This makes it possible to easily publish to a website, without forcing
+ everything but the root note to be in a sub-directory.
+
+
Testing locally
As mentioned previously, the exported static pages require a website to
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 ad81d00c1..9f2de8853 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
@@ -1,12 +1,12 @@
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.html
index 84a15c29e..5ed3518e6 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.html
@@ -8,7 +8,7 @@
get parentWidget() { return "left-pane"; }
doRender() {
- this.$widget = $("");
+ this.$widget = $("<div id='my-widget'>");
return this.$widget;
}
}
@@ -22,13 +22,13 @@ module.exports = new MyWidget();
the note.
Restart Trilium or reload the window.
-
To verify that the widget is working, open the developer tools (Cmd + Shift + I)
+
To verify that the widget is working, open the developer tools (Ctrl + Shift + I)
and run document.querySelector("#my-widget"). If the element
is found, the widget is functioning correctly. If undefined is
returned, double-check that the note has
the #widgetattribute.
Step 2: Adding an UI Element
-
Next, let's improve the widget by adding a button to it.
const template = ``;
+
Next, let's improve the widget by adding a button to it.
const template = `<div id="my-widget"><button>Click Me!</button></div>`;
class MyWidget extends api.BasicWidget {
get position() {return 1;}
@@ -47,7 +47,7 @@ module.exports = new MyWidget();
To make the button more visually appealing and position it correctly,
we'll apply some custom styling. Trilium includes Box Icons,
which we'll use to replace the button text with an icon. For example the bx bxs-magic-wand icon.
Next, we'll adjust the button's position using CSS:
class MyWidget extends api.BasicWidget {
get position() { return 1; }
get parentWidget() { return "left-pane"; }
diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts
index 29d75c6dc..cbd584529 100644
--- a/apps/server/src/routes/api/search.ts
+++ b/apps/server/src/routes/api/search.ts
@@ -10,6 +10,8 @@ import cls from "../../services/cls.js";
import attributeFormatter from "../../services/attribute_formatter.js";
import ValidationError from "../../errors/validation_error.js";
import type SearchResult from "../../services/search/search_result.js";
+import hoistedNoteService from "../../services/hoisted_note.js";
+import beccaService from "../../becca/becca_service.js";
function searchFromNote(req: Request): SearchNoteResult {
const note = becca.getNoteOrThrow(req.params.noteId);
@@ -49,13 +51,41 @@ function quickSearch(req: Request) {
const searchContext = new SearchContext({
fastSearch: false,
includeArchivedNotes: false,
- fuzzyAttributeSearch: false
+ includeHiddenNotes: true,
+ fuzzyAttributeSearch: true,
+ ignoreInternalAttributes: true,
+ ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId()
+ });
+
+ // Execute search with our context
+ const allSearchResults = searchService.findResultsWithQuery(searchString, searchContext);
+ const trimmed = allSearchResults.slice(0, 200);
+
+ // Extract snippets using highlightedTokens from our context
+ for (const result of trimmed) {
+ result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens);
+ result.attributeSnippet = searchService.extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
+ }
+
+ // Highlight the results
+ searchService.highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
+
+ // Map to API format
+ const searchResults = trimmed.map((result) => {
+ const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId);
+ return {
+ notePath: result.notePath,
+ noteTitle: title,
+ notePathTitle: result.notePathTitle,
+ highlightedNotePathTitle: result.highlightedNotePathTitle,
+ contentSnippet: result.contentSnippet,
+ highlightedContentSnippet: result.highlightedContentSnippet,
+ attributeSnippet: result.attributeSnippet,
+ highlightedAttributeSnippet: result.highlightedAttributeSnippet,
+ icon: icon
+ };
});
- // Use the same highlighting logic as autocomplete for consistency
- const searchResults = searchService.searchNotesForAutocomplete(searchString, false);
-
- // Extract note IDs for backward compatibility
const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[];
return {
diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts
index f1e1bf95f..c36dddd74 100644
--- a/apps/server/src/services/search/expressions/note_content_fulltext.ts
+++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts
@@ -75,20 +75,101 @@ class NoteContentFulltextExp extends Expression {
return inputNoteSet;
}
+ // Add tokens to highlightedTokens so snippet extraction knows what to look for
+ for (const token of this.tokens) {
+ if (!searchContext.highlightedTokens.includes(token)) {
+ searchContext.highlightedTokens.push(token);
+ }
+ }
+
const resultNoteSet = new NoteSet();
+ // Search through notes with content
for (const row of sql.iterateRows(`
SELECT noteId, type, mime, content, isProtected
FROM notes JOIN blobs USING (blobId)
- WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
- AND isDeleted = 0
+ WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
+ AND isDeleted = 0
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
this.findInText(row, inputNoteSet, resultNoteSet);
}
+ // For exact match with flatText, also search notes WITHOUT content (they may have matching attributes)
+ if (this.flatText && (this.operator === "=" || this.operator === "!=")) {
+ for (const note of inputNoteSet.notes) {
+ // Skip if already found or doesn't exist
+ if (resultNoteSet.hasNoteId(note.noteId) || !(note.noteId in becca.notes)) {
+ continue;
+ }
+
+ const noteFromBecca = becca.notes[note.noteId];
+ const flatText = noteFromBecca.getFlatText();
+
+ // For flatText, only check attribute values (format: #name=value or ~name=value)
+ // Don't match against noteId, type, mime, or title which are also in flatText
+ let matches = false;
+ const phrase = this.tokens.join(" ");
+ const normalizedPhrase = normalizeSearchText(phrase);
+ const normalizedFlatText = normalizeSearchText(flatText);
+
+ // Check if =phrase appears in flatText (indicates attribute value match)
+ matches = normalizedFlatText.includes(`=${normalizedPhrase}`);
+
+ if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) {
+ resultNoteSet.add(noteFromBecca);
+ }
+ }
+ }
+
return resultNoteSet;
}
+ /**
+ * Checks if content contains the exact word (with word boundaries) or exact phrase
+ * This is case-insensitive since content and token are already normalized
+ */
+ private containsExactWord(token: string, content: string): boolean {
+ // Normalize both for case-insensitive comparison
+ const normalizedToken = normalizeSearchText(token);
+ const normalizedContent = normalizeSearchText(content);
+
+ // If token contains spaces, it's a multi-word phrase from quotes
+ // Check for substring match (consecutive phrase)
+ if (normalizedToken.includes(' ')) {
+ return normalizedContent.includes(normalizedToken);
+ }
+
+ // For single words, split content into words and check for exact match
+ const words = normalizedContent.split(/\s+/);
+ return words.some(word => word === normalizedToken);
+ }
+
+ /**
+ * Checks if content contains the exact phrase (consecutive words in order)
+ * This is case-insensitive since content and tokens are already normalized
+ */
+ private containsExactPhrase(tokens: string[], content: string, checkFlatTextAttributes: boolean = false): boolean {
+ const normalizedTokens = tokens.map(t => normalizeSearchText(t));
+ const normalizedContent = normalizeSearchText(content);
+
+ // Join tokens with single space to form the phrase
+ const phrase = normalizedTokens.join(" ");
+
+ // Check if the phrase appears as a substring (consecutive words)
+ if (normalizedContent.includes(phrase)) {
+ return true;
+ }
+
+ // For flatText, also check if the phrase appears in attribute values
+ // Attributes in flatText appear as "#name=value" or "~name=value"
+ // So we need to check for "=phrase" to match attribute values
+ if (checkFlatTextAttributes && normalizedContent.includes(`=${phrase}`)) {
+ return true;
+ }
+
+ return false;
+ }
+
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
return;
@@ -112,7 +193,7 @@ class NoteContentFulltextExp extends Expression {
}
content = this.preprocessContent(content, type, mime);
-
+
// Apply content size validation and preprocessing
const processedContent = validateAndPreprocessContent(content, noteId);
if (!processedContent) {
@@ -123,9 +204,25 @@ class NoteContentFulltextExp extends Expression {
if (this.tokens.length === 1) {
const [token] = this.tokens;
+ let matches = false;
+ if (this.operator === "=") {
+ matches = this.containsExactWord(token, content);
+ // Also check flatText if enabled (includes attributes)
+ if (!matches && this.flatText) {
+ const flatText = becca.notes[noteId].getFlatText();
+ matches = this.containsExactPhrase([token], flatText, true);
+ }
+ } else if (this.operator === "!=") {
+ matches = !this.containsExactWord(token, content);
+ // For negation, check flatText too
+ if (matches && this.flatText) {
+ const flatText = becca.notes[noteId].getFlatText();
+ matches = !this.containsExactPhrase([token], flatText, true);
+ }
+ }
+
if (
- (this.operator === "=" && token === content) ||
- (this.operator === "!=" && token !== content) ||
+ matches ||
(this.operator === "*=" && content.endsWith(token)) ||
(this.operator === "=*" && content.startsWith(token)) ||
(this.operator === "*=*" && content.includes(token)) ||
@@ -138,10 +235,26 @@ class NoteContentFulltextExp extends Expression {
} else {
// Multi-token matching with fuzzy support and phrase proximity
if (this.operator === "~=" || this.operator === "~*") {
+ // Fuzzy phrase matching
if (this.matchesWithFuzzy(content, noteId)) {
resultNoteSet.add(becca.notes[noteId]);
}
+ } else if (this.operator === "=" || this.operator === "!=") {
+ // Exact phrase matching for = and !=
+ let matches = this.containsExactPhrase(this.tokens, content, false);
+
+ // Also check flatText if enabled (includes attributes)
+ if (!matches && this.flatText) {
+ const flatText = becca.notes[noteId].getFlatText();
+ matches = this.containsExactPhrase(this.tokens, flatText, true);
+ }
+
+ if ((this.operator === "=" && matches) ||
+ (this.operator === "!=" && !matches)) {
+ resultNoteSet.add(becca.notes[noteId]);
+ }
} else {
+ // Other operators: check all tokens present (any order)
const nonMatchingToken = this.tokens.find(
(token) =>
!this.tokenMatchesContent(token, content, noteId)
diff --git a/apps/server/src/services/search/services/build_comparator.ts b/apps/server/src/services/search/services/build_comparator.ts
index 3aebe1adb..c090b458f 100644
--- a/apps/server/src/services/search/services/build_comparator.ts
+++ b/apps/server/src/services/search/services/build_comparator.ts
@@ -13,8 +13,41 @@ function getRegex(str: string) {
type Comparator = (comparedValue: T) => (val: string) => boolean;
const stringComparators: Record> = {
- "=": (comparedValue) => (val) => val === comparedValue,
- "!=": (comparedValue) => (val) => val !== comparedValue,
+ "=": (comparedValue) => (val) => {
+ // For the = operator, check if the value contains the exact word or phrase
+ // This is case-insensitive
+ if (!val) return false;
+
+ const normalizedVal = normalizeSearchText(val);
+ const normalizedCompared = normalizeSearchText(comparedValue);
+
+ // If comparedValue has spaces, it's a multi-word phrase
+ // Check for substring match (consecutive phrase)
+ if (normalizedCompared.includes(" ")) {
+ return normalizedVal.includes(normalizedCompared);
+ }
+
+ // For single word, split into words and check for exact word match
+ const words = normalizedVal.split(/\s+/);
+ return words.some(word => word === normalizedCompared);
+ },
+ "!=": (comparedValue) => (val) => {
+ // Negation of exact word/phrase match
+ if (!val) return true;
+
+ const normalizedVal = normalizeSearchText(val);
+ const normalizedCompared = normalizeSearchText(comparedValue);
+
+ // If comparedValue has spaces, it's a multi-word phrase
+ // Check for substring match (consecutive phrase) and negate
+ if (normalizedCompared.includes(" ")) {
+ return !normalizedVal.includes(normalizedCompared);
+ }
+
+ // For single word, split into words and check for exact word match, then negate
+ const words = normalizedVal.split(/\s+/);
+ return !words.some(word => word === normalizedCompared);
+ },
">": (comparedValue) => (val) => val > comparedValue,
">=": (comparedValue) => (val) => val >= comparedValue,
"<": (comparedValue) => (val) => val < comparedValue,
diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts
index b537ee562..03986b9ac 100644
--- a/apps/server/src/services/search/services/parse.ts
+++ b/apps/server/src/services/search/services/parse.ts
@@ -38,11 +38,14 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leading
if (!searchContext.fastSearch) {
// For exact match with "=", we need different behavior
- if (leadingOperator === "=" && tokens.length === 1) {
- // Exact match on title OR exact match on content
+ if (leadingOperator === "=" && tokens.length >= 1) {
+ // Exact match on title OR exact match on content OR exact match in flat text (includes attributes)
+ // For multi-word, join tokens with space to form exact phrase
+ const titleSearchValue = tokens.join(" ");
return new OrExp([
- new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
- new NoteContentFulltextExp("=", { tokens, flatText: false })
+ new PropertyComparisonExp(searchContext, "title", "=", titleSearchValue),
+ new NoteContentFulltextExp("=", { tokens, flatText: false }),
+ new NoteContentFulltextExp("=", { tokens, flatText: true })
]);
}
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]);
diff --git a/apps/server/src/services/search/services/search.spec.ts b/apps/server/src/services/search/services/search.spec.ts
index d448a04b0..fc36d7d7c 100644
--- a/apps/server/src/services/search/services/search.spec.ts
+++ b/apps/server/src/services/search/services/search.spec.ts
@@ -242,18 +242,149 @@ describe("Search", () => {
const searchContext = new SearchContext();
- // Using leading = for exact title match
- let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext);
- expect(searchResults.length).toEqual(1);
+ // Using leading = for exact word match - should find notes containing the exact word "example"
+ let searchResults = searchService.findResultsWithQuery("=example", searchContext);
+ expect(searchResults.length).toEqual(2); // "Example Note" and "Sample" (has label "example")
expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "Sample")).toBeTruthy();
- // Without =, it should find all notes containing "example"
+ // Without =, it should find all notes containing "example" (substring match)
searchResults = searchService.findResultsWithQuery("example", searchContext);
- expect(searchResults.length).toEqual(3);
+ expect(searchResults.length).toEqual(3); // All notes
// = operator should not match partial words
- searchResults = searchService.findResultsWithQuery("=Example", searchContext);
- expect(searchResults.length).toEqual(0);
+ searchResults = searchService.findResultsWithQuery("=examples", searchContext);
+ expect(searchResults.length).toEqual(1); // Only "Examples of Usage"
+ expect(findNoteByTitle(searchResults, "Examples of Usage")).toBeTruthy();
+ });
+
+ it("leading = operator for exact match - comprehensive title tests", () => {
+ // Create notes with varying titles to test exact vs contains matching
+ rootNote
+ .child(note("testing"))
+ .child(note("testing123"))
+ .child(note("My testing notes"))
+ .child(note("123testing"))
+ .child(note("test"));
+
+ const searchContext = new SearchContext();
+
+ // Test 1: Exact word match with leading = should find notes containing the exact word "testing"
+ let searchResults = searchService.findResultsWithQuery("=testing", searchContext);
+ expect(searchResults.length).toEqual(2); // "testing" and "My testing notes" (word boundary)
+ expect(findNoteByTitle(searchResults, "testing")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "My testing notes")).toBeTruthy();
+
+ // Test 2: Without =, it should find all notes containing "testing" (substring contains behavior)
+ searchResults = searchService.findResultsWithQuery("testing", searchContext);
+ expect(searchResults.length).toEqual(4); // All notes with "testing" substring
+
+ // Test 3: Exact match should only find the exact composite word
+ searchResults = searchService.findResultsWithQuery("=testing123", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "testing123")).toBeTruthy();
+
+ // Test 4: Exact match should only find the exact composite word
+ searchResults = searchService.findResultsWithQuery("=123testing", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "123testing")).toBeTruthy();
+
+ // Test 5: Verify that "test" doesn't match "testing" with exact search
+ searchResults = searchService.findResultsWithQuery("=test", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "test")).toBeTruthy();
+ });
+
+ it("leading = operator with quoted phrases", () => {
+ rootNote
+ .child(note("exact phrase"))
+ .child(note("exact phrase match"))
+ .child(note("this exact phrase here"))
+ .child(note("phrase exact"));
+
+ const searchContext = new SearchContext();
+
+ // Test 1: With = and quotes, treat as exact phrase match (consecutive words in order)
+ let searchResults = searchService.findResultsWithQuery("='exact phrase'", searchContext);
+ // Should match only notes containing the exact phrase "exact phrase"
+ expect(searchResults.length).toEqual(3); // Only notes with consecutive "exact phrase"
+ expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy();
+
+ // Test 2: Without =, quoted phrase should find substring/contains matches
+ searchResults = searchService.findResultsWithQuery("'exact phrase'", searchContext);
+ expect(searchResults.length).toEqual(3); // All notes containing the phrase substring
+ expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy();
+ expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy();
+
+ // Test 3: Verify word order matters with exact phrase matching
+ searchResults = searchService.findResultsWithQuery("='phrase exact'", searchContext);
+ expect(searchResults.length).toEqual(1); // Only "phrase exact" matches
+ expect(findNoteByTitle(searchResults, "phrase exact")).toBeTruthy();
+ });
+
+ it("leading = operator case sensitivity", () => {
+ rootNote
+ .child(note("TESTING"))
+ .child(note("testing"))
+ .child(note("Testing"))
+ .child(note("TeStiNg"));
+
+ const searchContext = new SearchContext();
+
+ // Exact match should be case-insensitive (based on lex.ts line 4: str.toLowerCase())
+ let searchResults = searchService.findResultsWithQuery("=testing", searchContext);
+ expect(searchResults.length).toEqual(4); // All variants of "testing"
+
+ searchResults = searchService.findResultsWithQuery("=TESTING", searchContext);
+ expect(searchResults.length).toEqual(4); // All variants
+
+ searchResults = searchService.findResultsWithQuery("=Testing", searchContext);
+ expect(searchResults.length).toEqual(4); // All variants
+
+ searchResults = searchService.findResultsWithQuery("=TeStiNg", searchContext);
+ expect(searchResults.length).toEqual(4); // All variants
+ });
+
+ it("leading = operator with special characters", () => {
+ rootNote
+ .child(note("test-note"))
+ .child(note("test_note"))
+ .child(note("test.note"))
+ .child(note("test note"))
+ .child(note("testnote"));
+
+ const searchContext = new SearchContext();
+
+ // Each exact match should only find its specific variant (compound words are treated as single words)
+ let searchResults = searchService.findResultsWithQuery("=test-note", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "test-note")).toBeTruthy();
+
+ searchResults = searchService.findResultsWithQuery("=test_note", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "test_note")).toBeTruthy();
+
+ searchResults = searchService.findResultsWithQuery("=test.note", searchContext);
+ expect(searchResults.length).toEqual(1);
+ expect(findNoteByTitle(searchResults, "test.note")).toBeTruthy();
+
+ // For phrases with spaces, use quotes to keep them together
+ // With exact phrase matching, this finds notes with the consecutive phrase
+ searchResults = searchService.findResultsWithQuery("='test note'", searchContext);
+ expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase
+ expect(findNoteByTitle(searchResults, "test note")).toBeTruthy();
+
+ // Without quotes, "test note" is tokenized as two separate tokens
+ // and will be treated as an exact phrase search with = operator
+ searchResults = searchService.findResultsWithQuery("=test note", searchContext);
+ expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase
+
+ // Without =, should find all matches containing "test" substring
+ searchResults = searchService.findResultsWithQuery("test", searchContext);
+ expect(searchResults.length).toEqual(5);
});
it("fuzzy attribute search", () => {
diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts
index 22dbe6d9f..5ca4bda4a 100644
--- a/apps/server/src/services/search/services/search.ts
+++ b/apps/server/src/services/search/services/search.ts
@@ -500,19 +500,38 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
// Extract snippet
let snippet = content.substring(snippetStart, snippetStart + maxLength);
-
+
// If snippet contains linebreaks, limit to max 4 lines and override character limit
const lines = snippet.split('\n');
if (lines.length > 4) {
- snippet = lines.slice(0, 4).join('\n');
+ // Find which lines contain the search tokens to ensure they're included
+ const normalizedLines = lines.map(line => normalizeString(line.toLowerCase()));
+ const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase()));
+
+ // Find the first line that contains a search token
+ let firstMatchLine = -1;
+ for (let i = 0; i < normalizedLines.length; i++) {
+ if (normalizedTokens.some(token => normalizedLines[i].includes(token))) {
+ firstMatchLine = i;
+ break;
+ }
+ }
+
+ if (firstMatchLine !== -1) {
+ // Center the 4-line window around the first match
+ // Try to show 1 line before and 2 lines after the match
+ const startLine = Math.max(0, firstMatchLine - 1);
+ const endLine = Math.min(lines.length, startLine + 4);
+ snippet = lines.slice(startLine, endLine).join('\n');
+ } else {
+ // No match found in lines (shouldn't happen), just take first 4
+ snippet = lines.slice(0, 4).join('\n');
+ }
// Add ellipsis if we truncated lines
snippet = snippet + "...";
} else if (lines.length > 1) {
- // For multi-line snippets, just limit to 4 lines (keep existing snippet)
- snippet = lines.slice(0, 4).join('\n');
- if (lines.length > 4) {
- snippet = snippet + "...";
- }
+ // For multi-line snippets that are 4 or fewer lines, keep them as-is
+ // No need to truncate
} else {
// Single line content - apply original word boundary logic
// Try to start/end at word boundaries
@@ -770,5 +789,8 @@ export default {
searchNotesForAutocomplete,
findResultsWithQuery,
findFirstNoteWithQuery,
- searchNotes
+ searchNotes,
+ extractContentSnippet,
+ extractAttributeSnippet,
+ highlightSearchResults
};
diff --git a/apps/website/src/translations/pl/translation.json b/apps/website/src/translations/pl/translation.json
index b4ddd8e13..d8ee85f20 100644
--- a/apps/website/src/translations/pl/translation.json
+++ b/apps/website/src/translations/pl/translation.json
@@ -26,7 +26,7 @@
"productivity_benefits": {
"title": "Produktywność i bezpieczeństwo",
"revisions_title": "Historia zmian",
- "revisions_content": "Notatki są regularnie zapisywane w tle, co pozwala to przeglądać i cofać wprowadzone zmiany. Zapisy można także wykonywać \"na życzenie\".",
+ "revisions_content": "Notatki są regularnie zapisywane w tle, pozwala to przeglądać i cofać wprowadzone zmiany. Zapisy można także wykonywać \"na życzenie\".",
"sync_title": "Synchronizacja",
"sync_content": "Używaj własnych lub chmurowych instancji do łatwiejszej synchronizacji notatek między wieloma urządzeniami, w tym twoim telefonem używając PWA.",
"protected_notes_title": "Notatki chronione",
@@ -51,7 +51,8 @@
"mermaid_description": "Twórz diagramy, takie jak schematy blokowe, diagramy klas i sekwencyjne, wykresy Gantta i wiele innych, korzystając z składni Mermaid.",
"mindmap_title": "Mapy myśli",
"mindmap_description": "Organizuj wizualnie swoje myśli albo przeprowadź sesję burzy mózgów.",
- "others_list": "I wiele innych: <0>mapa notatek0>, <1>mapa powiązań1>, <2>zapisane wyszukiwania2>, <3>renderowane notatki3>, and <4>podgląd stron www4>."
+ "others_list": "I wiele innych: <0>mapa notatek0>, <1>mapa powiązań1>, <2>zapisane wyszukiwania2>, <3>renderowane notatki3>, and <4>podgląd stron www4>.",
+ "title": "Wiele sposobów przedstawienia Twoich informacji"
},
"extensibility_benefits": {
"title": "Udostępnianie i rozszerzenia",
@@ -69,10 +70,13 @@
"calendar_description": "Organizuj swoje prywatne i służbowe wydarzenia używając kalendarza. Miej plany pod kontrolą z tygodniowym, miesięcznym i rocznym podglądem. Twórz i edytuj wydarzenia w prosty i intuicyjny sposób.",
"table_title": "Tabele",
"table_description": "Wyświetlaj i edytuj informacje o notatkach w tabelach na wiele sposobów dzięki wielu typom kolumn: Tekstowym, numerycznym, z polami wyboru, z datami i godzinami, zawierającym linki, z kolorowymi wypełnieniami i powiązaniami notatek. Możesz nawet wyświetlić całe drzewo hierarchii w tabeli.",
- "board_title": "Tablice",
+ "board_title": "Tablice Kanban",
"board_description": "Organizuj swoje zadania i postępy projektów w tablicach Kanban z prostym tworzeniem nowych elementów i kolumn, a możliwość graficznego ich przenoszenia ułatwi zmianę statusu i pozwoli zachować porządek.",
"geomap_title": "Mapy",
- "geomap_description": "Zaplanuj wakacje albo interesujące miejsca bezpośrednio na mapie, używaj personalizowanych pinezek. Dzięki możliwości importu plików GPX możesz wyświetlać przebyte trasy."
+ "geomap_description": "Zaplanuj wakacje albo interesujące miejsca bezpośrednio na mapie, używaj personalizowanych pinezek. Dzięki możliwości importu plików GPX możesz wyświetlać przebyte trasy.",
+ "title": "Kolekcje",
+ "presentation_title": "Prezentacje",
+ "presentation_description": "Zawrzyj informacje w slajdach i zaprezentuj je w pełnoekranowych prezentacjach, które możesz łatwo wyeksportować do plików PDF."
},
"faq": {
"title": "Częste pytania",
@@ -187,5 +191,10 @@
"description": "Trilium Notes hostowane na PikaPods, płatnym serwisie dla łatwego dostępu i zarządzania. Bezpośrednio nie związanie z Trilium team.",
"download_pikapod": "Konfiguruj na PikaPods",
"download_triliumcc": "Alternatywnie patrz na trilium.cc"
+ },
+ "header": {
+ "get-started": "Start",
+ "documentation": "Dokumentacja",
+ "support-us": "Wesprzyj nas"
}
}
diff --git a/docs/Developer Guide/!!!meta.json b/docs/Developer Guide/!!!meta.json
index 63e4d0cc4..0c39870f6 100644
--- a/docs/Developer Guide/!!!meta.json
+++ b/docs/Developer Guide/!!!meta.json
@@ -245,6 +245,13 @@
"value": "database",
"isInheritable": false,
"position": 20
+ },
+ {
+ "type": "label",
+ "name": "iconClass",
+ "value": "bx bx-data",
+ "isInheritable": false,
+ "position": 30
}
],
"format": "markdown",
@@ -765,6 +772,71 @@
"format": "markdown",
"dataFileName": "revisions.md",
"attachments": []
+ },
+ {
+ "isClone": false,
+ "noteId": "6DG1au6rgOTl",
+ "notePath": [
+ "jdjRLhLV3TtI",
+ "MhwWMgxwDTZL",
+ "pRZhrVIGCbMu",
+ "vNMojjUN76jc",
+ "6DG1au6rgOTl"
+ ],
+ "title": "sessions",
+ "notePosition": 66,
+ "prefix": null,
+ "isExpanded": false,
+ "type": "text",
+ "mime": "text/html",
+ "attributes": [
+ {
+ "type": "label",
+ "name": "iconClass",
+ "value": "bx bx-table",
+ "isInheritable": false,
+ "position": 20
+ },
+ {
+ "type": "label",
+ "name": "shareAlias",
+ "value": "sessions",
+ "isInheritable": false,
+ "position": 30
+ }
+ ],
+ "format": "markdown",
+ "dataFileName": "sessions.md",
+ "attachments": []
+ },
+ {
+ "isClone": false,
+ "noteId": "zWY2LKmas9os",
+ "notePath": [
+ "jdjRLhLV3TtI",
+ "MhwWMgxwDTZL",
+ "pRZhrVIGCbMu",
+ "vNMojjUN76jc",
+ "zWY2LKmas9os"
+ ],
+ "title": "user_data",
+ "notePosition": 76,
+ "prefix": null,
+ "isExpanded": false,
+ "type": "text",
+ "mime": "text/html",
+ "attributes": [
+ {
+ "type": "label",
+ "name": "iconClass",
+ "value": "bx bx-table",
+ "isInheritable": false,
+ "position": 20
+ }
+ ],
+ "format": "markdown",
+ "dataFileName": "user_data.md",
+ "attachments": []
}
]
}
diff --git a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/notes.md b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/notes.md
index 19cb4a322..108ea8b26 100644
--- a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/notes.md
+++ b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/notes.md
@@ -6,10 +6,10 @@
| `isProtected` | Integer | Non-null | 0 | `1` if the entity is [protected](../../../Concepts/Protected%20entities.md), `0` otherwise. |
| `type` | Text | Non-null | `"text"` | The type of note (i.e. `text`, `file`, `code`, `relationMap`, `mermaid`, `canvas`). |
| `mime` | Text | Non-null | `"text/html"` | The MIME type of the note (e.g. `text/html`).. Note that it can be an empty string in some circumstances, but not null. |
+| `blobId` | Text | Nullable | `null` | The corresponding ID from blobs. Although it can theoretically be `NULL`, haven't found any such note yet. |
| `isDeleted` | Integer | Nullable | 0 | `1` if the entity is [deleted](../../../Concepts/Deleted%20notes.md), `0` otherwise. |
| `deleteId` | Text | Non-null | `null` | |
| `dateCreated` | Text | Non-null | | Localized creation date (e.g. `2023-11-08 18:43:44.204+0200`) |
| `dateModified` | Text | Non-null | | Localized modification date (e.g. `2023-11-08 18:43:44.204+0200`) |
| `utcDateCreated` | Text | Non-null | | Creation date in UTC format (e.g. `2023-11-08 16:43:44.204Z`) |
-| `utcDateModified` | Text | Non-null | | Modification date in UTC format (e.g. `2023-11-08 16:43:44.204Z`) |
-| `blobId` | Text | Nullable | `null` | The corresponding ID from blobs. Although it can theoretically be `NULL`, haven't found any such note yet. |
\ No newline at end of file
+| `utcDateModified` | Text | Non-null | | Modification date in UTC format (e.g. `2023-11-08 16:43:44.204Z`) |
\ No newline at end of file
diff --git a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/revisions.md b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/revisions.md
index fef9ed1ed..faae5421c 100644
--- a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/revisions.md
+++ b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/revisions.md
@@ -1,7 +1,7 @@
# revisions
| Column Name | Data Type | Nullity | Default value | Description |
| --- | --- | --- | --- | --- |
-| `revisionId` | TextText | Non-null | | Unique ID of the revision (e.g. `0GjgUqnEudI8`). |
+| `revisionId` | Text | Non-null | | Unique ID of the revision (e.g. `0GjgUqnEudI8`). |
| `noteId` | Text | Non-null | | ID of the [note](notes.md) this revision belongs to. |
| `type` | Text | Non-null | `""` | The type of note (i.e. `text`, `file`, `code`, `relationMap`, `mermaid`, `canvas`). |
| `mime` | Text | Non-null | `""` | The MIME type of the note (e.g. `text/html`). |
diff --git a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/sessions.md b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/sessions.md
new file mode 100644
index 000000000..456236152
--- /dev/null
+++ b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/sessions.md
@@ -0,0 +1,8 @@
+# sessions
+Contains user sessions for authentication purposes. The table is almost a direct mapping of the information that `express-session` requires.
+
+| Column Name | Data Type | Nullity | Default value | Description |
+| --- | --- | --- | --- | --- |
+| `id` | Text | Non-null | | Unique, non-sequential ID of the session, directly as indicated by `express-session` |
+| `data` | Text | Non-null | | The session information, in stringified JSON format. |
+| `expires` | Integer | Non-null | | The expiration date of the session, extracted from the session information. Used to rapidly clean up expired sessions. |
\ No newline at end of file
diff --git a/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/user_data.md b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/user_data.md
new file mode 100644
index 000000000..3b27ee30f
--- /dev/null
+++ b/docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/user_data.md
@@ -0,0 +1,17 @@
+# user_data
+Contains the user information for two-factor authentication. This table is **not** used for multi-user.
+
+Relevant files:
+
+* `apps/server/src/services/encryption/open_id_encryption.ts`
+
+| Column Name | Data Type | Nullity | Default value | Description |
+| --- | --- | --- | --- | --- |
+| `tmpID` | Integer | | | A sequential ID of the user. Since only one user is supported by Trilium, this value is always zero. |
+| `username` | Text | | | The user name as returned from the OAuth operation. |
+| `email` | Text | | | The email as returned from the OAuth operation. |
+| `userIDEncryptedDataKey` | Text | | | An encrypted hash of the user subject identifier from the OAuth operation. |
+| `userIDVerificationHash` | Text | | | A salted hash of the subject identifier from the OAuth operation. |
+| `salt` | Text | | | The verification salt. |
+| `derivedKey` | Text | | | A random secure token. |
+| `isSetup` | Text | | `"false"` | Indicates that the user has been saved (`"true"`). |
\ No newline at end of file
diff --git a/docs/Developer Guide/Developer Guide/Concepts/Note Revisions.md b/docs/Developer Guide/Developer Guide/Concepts/Note Revisions.md
index e69de29bb..7624b1d78 100644
--- a/docs/Developer Guide/Developer Guide/Concepts/Note Revisions.md
+++ b/docs/Developer Guide/Developer Guide/Concepts/Note Revisions.md
@@ -0,0 +1,2 @@
+# Note Revisions
+The revision API on the server side is managed by `apps/server/src/routes/api/revisions.ts`
\ 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 c11d3164c..f5956d744 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 c046fac03..057df9c87 100644
--- a/docs/User Guide/!!!meta.json
+++ b/docs/User Guide/!!!meta.json
@@ -12093,7 +12093,7 @@
"R9pX4DGra2Vt",
"ycBFjKrrwE9p"
],
- "title": "Exporting HTML for web publishing",
+ "title": "Exporting static HTML for web publishing",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
@@ -12130,7 +12130,7 @@
}
],
"format": "markdown",
- "dataFileName": "Exporting HTML for web publish.md",
+ "dataFileName": "Exporting static HTML for web .md",
"attachments": []
},
{
@@ -14166,6 +14166,48 @@
"type": "text",
"mime": "text/html",
"attributes": [
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "Gzjqa934BdH4",
+ "isInheritable": false,
+ "position": 10
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "wy8So3yZZlH9",
+ "isInheritable": false,
+ "position": 20
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "R9pX4DGra2Vt",
+ "isInheritable": false,
+ "position": 30
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "eIg8jdvaoNNd",
+ "isInheritable": false,
+ "position": 40
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "GTwFsgaA0lCt",
+ "isInheritable": false,
+ "position": 50
+ },
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "KSZ04uQ2D1St",
+ "isInheritable": false,
+ "position": 60
+ },
{
"type": "label",
"name": "iconClass",
@@ -14179,48 +14221,6 @@
"value": "read-only-db",
"isInheritable": false,
"position": 40
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "wy8So3yZZlH9",
- "isInheritable": false,
- "position": 50
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "R9pX4DGra2Vt",
- "isInheritable": false,
- "position": 60
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "Gzjqa934BdH4",
- "isInheritable": false,
- "position": 70
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "eIg8jdvaoNNd",
- "isInheritable": false,
- "position": 80
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "GTwFsgaA0lCt",
- "isInheritable": false,
- "position": 90
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "KSZ04uQ2D1St",
- "isInheritable": false,
- "position": 100
}
],
"format": "markdown",
@@ -14250,6 +14250,13 @@
"isInheritable": false,
"position": 10
},
+ {
+ "type": "relation",
+ "name": "internalLink",
+ "value": "xYmIYSP6wE3F",
+ "isInheritable": false,
+ "position": 20
+ },
{
"type": "label",
"name": "shareAlias",
@@ -14263,13 +14270,6 @@
"value": "bx bx-bot",
"isInheritable": false,
"position": 30
- },
- {
- "type": "relation",
- "name": "internalLink",
- "value": "xYmIYSP6wE3F",
- "isInheritable": false,
- "position": 40
}
],
"format": "markdown",
diff --git a/docs/User Guide/User Guide/Advanced Usage/Sharing.md b/docs/User Guide/User Guide/Advanced Usage/Sharing.md
index 8738124c8..5f87ce125 100644
--- a/docs/User Guide/User Guide/Advanced Usage/Sharing.md
+++ b/docs/User Guide/User Guide/Advanced Usage/Sharing.md
@@ -50,7 +50,7 @@ You can view a list of all shared notes by clicking on "Show Shared Notes Subtre
* Shared notes are published on the open internet and can be accessed by anyone with the URL unless the notes are password-protected.
* The URL's randomness does not provide security, so it is crucial not to share sensitive information through this feature.
-* Trilium takes precautions to protect your publicly shared instance from leaking information for non-shared notes, including opening a separate read-only connection to the Database. Depending on your threat model, it might make more sense to use Exporting HTML for web publishing and use battle-tested web servers such as Nginx or Apache to serve static content.
+* Trilium takes precautions to protect your publicly shared instance from leaking information for non-shared notes, including opening a separate read-only connection to the Database. Depending on your threat model, it might make more sense to use Exporting HTML for web publishing and use battle-tested web servers such as Nginx or Apache to serve static content.
### Password protection
diff --git a/docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.md b/docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .md
similarity index 87%
rename from docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.md
rename to docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .md
index 405c79d5a..cc2e30825 100644
--- a/docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting HTML for web publish.md
+++ b/docs/User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web .md
@@ -1,4 +1,4 @@
-# Exporting HTML for web publishing
+# Exporting static HTML for web publishing
As described in Sharing, Trilium can act as a public server in which the shared notes are displayed in read-only mode. While this can work in most cases, it's generally not meant for high-traffic websites and since it's running on a Node.js server it can be potentially exploited.
Another alternative is to generate static HTML files (just like other static site generators such as [MkDocs](https://www.mkdocs.org/)). Since the normal HTML ZIP export does not contain any styling or additional functionality, Trilium provides a way to export the same layout and style as the Sharing function into static HTML files.
@@ -23,6 +23,10 @@ Apart from normal Sharing, e
* The name of the files/URLs will prefer `shareAlias` to allow for clean URLs.
* The export requires a functional web server as the pages will not render properly if accessed locally via a web browser due to the use of module scripts.
+* The directory structure is also slightly different:
+ * A normal HTML export results in an index file and a single directory.
+ * Instead, for static exporting the top-root level becomes the index file and the child directories are on the root instead.
+ * This makes it possible to easily publish to a website, without forcing everything but the root note to be in a sub-directory.
## Testing locally
diff --git a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.md b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.md
index 474cba983..39867172f 100644
--- a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.md
+++ b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics.md
@@ -11,7 +11,7 @@ class MyWidget extends api.BasicWidget {
get parentWidget() { return "left-pane"; }
doRender() {
- this.$widget = $("");
+ this.$widget = $("
");
return this.$widget;
}
}
@@ -25,14 +25,14 @@ To implement this widget:
2. Assign the `#widget` [attribute](../../../Advanced%20Usage/Attributes.md) to the [note](../../../Basic%20Concepts%20and%20Features/Notes.md).
3. Restart Trilium or reload the window.
-To verify that the widget is working, open the developer tools (`Cmd` + `Shift` + `I`) and run `document.querySelector("#my-widget")`. If the element is found, the widget is functioning correctly. If `undefined` is returned, double-check that the [note](../../../Basic%20Concepts%20and%20Features/Notes.md) has the `#widget` [attribute](../../../Advanced%20Usage/Attributes.md).
+To verify that the widget is working, open the developer tools (Ctrl + Shift + I) and run `document.querySelector("#my-widget")`. If the element is found, the widget is functioning correctly. If `undefined` is returned, double-check that the [note](../../../Basic%20Concepts%20and%20Features/Notes.md) has the `#widget` [attribute](../../../Advanced%20Usage/Attributes.md).
### Step 2: Adding an UI Element
Next, let's improve the widget by adding a button to it.
```
-const template = ``;
+const template = ``;
class MyWidget extends api.BasicWidget {
get position() {return 1;}
@@ -56,7 +56,7 @@ To make the button more visually appealing and position it correctly, we'll appl
Here's the updated template:
```
-const template = ``;
+const template = ``;
```
Next, we'll adjust the button's position using CSS: