Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements

This commit is contained in:
Adorian Doran 2025-11-05 23:52:53 +02:00
commit 728f574eac
29 changed files with 1212 additions and 225 deletions

View File

@ -1,3 +1,5 @@
@import "boxicons/css/boxicons.min.css";
:root {
--print-font-size: 11pt;
--ck-content-color-image-caption-background: transparent !important;

View File

@ -2040,6 +2040,9 @@
"start-presentation": "Start presentation",
"slide-overview": "Toggle an overview of the slides"
},
"calendar_view": {
"delete_note": "Delete note..."
},
"command_palette": {
"tree-action-name": "Tree: {{name}}",
"export_note_title": "Export Note",

View File

@ -0,0 +1,28 @@
import FNote from "../../../entities/fnote";
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
import link_context_menu from "../../../menus/link_context_menu";
import branches from "../../../services/branches";
import { t } from "../../../services/i18n";
export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, parentNote: FNote) {
e.preventDefault();
e.stopPropagation();
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: [
...link_context_menu.getItems(),
{ kind: "separator" },
{
title: t("calendar_view.delete_note"),
uiIcon: "bx bx-trash",
handler: async () => {
const branchId = parentNote.childToBranch[noteId];
await branches.deleteNotes([ branchId ], false, false);
}
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
})
}

View File

@ -20,6 +20,7 @@ import Button, { ButtonGroup } from "../../react/Button";
import ActionButton from "../../react/ActionButton";
import { RefObject } from "preact";
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
import { openCalendarContextMenu } from "./context_menu";
interface CalendarViewData {
@ -106,7 +107,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
const plugins = usePlugins(isEditable, isCalendarRoot);
const locale = useLocale();
const { eventDidMount } = useEventDisplayCustomization();
const { eventDidMount } = useEventDisplayCustomization(note);
const editingProps = useEditing(note, isEditable, isCalendarRoot);
// React to changes.
@ -253,7 +254,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
};
}
function useEventDisplayCustomization() {
function useEventDisplayCustomization(parentNote: FNote) {
const eventDidMount = useCallback((e: EventMountArg) => {
const { iconClass, promotedAttributes } = e.event.extendedProps;
@ -302,6 +303,11 @@ function useEventDisplayCustomization() {
}
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
}
e.el.addEventListener("contextmenu", (contextMenuEvent) => {
const noteId = e.event.extendedProps.noteId;
openCalendarContextMenu(contextMenuEvent, noteId, parentNote);
});
}, []);
return { eventDidMount };
}

View File

@ -0,0 +1,502 @@
import { test, expect } from "@playwright/test";
import App from "./support/app";
const BASE_URL = "http://127.0.0.1:8082";
/**
* E2E tests for exact search functionality using the leading "=" operator.
*
* These tests validate the GitHub issue:
* - Searching for "pagio" returns many false positives (e.g., "page", "pages")
* - Searching for "=pagio" should return ONLY exact matches for "pagio"
*/
test.describe("Exact Search with Leading = Operator", () => {
let csrfToken: string;
let createdNoteIds: string[] = [];
test.beforeEach(async ({ page, context }) => {
const app = new App(page, context);
await app.goto();
// Get CSRF token
csrfToken = await page.evaluate(() => {
return (window as any).glob.csrfToken;
});
expect(csrfToken).toBeTruthy();
// Create test notes with specific content patterns
// Note 1: Contains exactly "pagio" in title
const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Test Note with pagio",
content: "This note contains the word pagio in the content.",
type: "text"
}
});
expect(note1.ok()).toBeTruthy();
const note1Data = await note1.json();
createdNoteIds.push(note1Data.note.noteId);
// Note 2: Contains "page" (not exact match)
const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Test Note with page",
content: "This note contains the word page in the content.",
type: "text"
}
});
expect(note2.ok()).toBeTruthy();
const note2Data = await note2.json();
createdNoteIds.push(note2Data.note.noteId);
// Note 3: Contains "pages" (plural, not exact match)
const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Test Note with pages",
content: "This note contains the word pages in the content.",
type: "text"
}
});
expect(note3.ok()).toBeTruthy();
const note3Data = await note3.json();
createdNoteIds.push(note3Data.note.noteId);
// Note 4: Contains "homepage" (contains "page", not exact match)
const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Homepage Note",
content: "This note is about homepage content.",
type: "text"
}
});
expect(note4.ok()).toBeTruthy();
const note4Data = await note4.json();
createdNoteIds.push(note4Data.note.noteId);
// Note 5: Another note with exact "pagio" in content
const note5 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Another pagio Note",
content: "This is another note with pagio content for testing exact matches.",
type: "text"
}
});
expect(note5.ok()).toBeTruthy();
const note5Data = await note5.json();
createdNoteIds.push(note5Data.note.noteId);
// Note 6: Contains "pagio" in title only
const note6 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "pagio",
content: "This note has pagio as the title.",
type: "text"
}
});
expect(note6.ok()).toBeTruthy();
const note6Data = await note6.json();
createdNoteIds.push(note6Data.note.noteId);
// Wait a bit for indexing
await page.waitForTimeout(500);
});
test.afterEach(async ({ page }) => {
// Clean up created notes
for (const noteId of createdNoteIds) {
try {
const taskId = `cleanup-${Math.random().toString(36).substr(2, 9)}`;
await page.request.delete(`${BASE_URL}/api/notes/${noteId}?taskId=${taskId}&last=true`, {
headers: { "x-csrf-token": csrfToken }
});
} catch (e) {
console.error(`Failed to delete note ${noteId}:`, e);
}
}
createdNoteIds = [];
});
test("Quick search without = operator returns all partial matches", async ({ page }) => {
// Test the /quick-search endpoint without the = operator
const response = await page.request.get(`${BASE_URL}/api/quick-search/pag`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
// Should return multiple notes including "page", "pages", "homepage"
expect(data.searchResultNoteIds).toBeDefined();
expect(data.searchResults).toBeDefined();
// Filter to only our test notes
const testResults = data.searchResults.filter((result: any) =>
result.noteTitle.includes("page") ||
result.noteTitle.includes("pagio") ||
result.noteTitle.includes("Homepage")
);
// Should find at least "page", "pages", "homepage", and "pagio" notes
expect(testResults.length).toBeGreaterThanOrEqual(4);
console.log("Quick search 'pag' found:", testResults.length, "matching notes");
console.log("Note titles:", testResults.map((r: any) => r.noteTitle));
});
test("Quick search with = operator returns only exact matches", async ({ page }) => {
// Test the /quick-search endpoint WITH the = operator
const response = await page.request.get(`${BASE_URL}/api/quick-search/=pagio`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
// Should return only notes with exact "pagio" match
expect(data.searchResultNoteIds).toBeDefined();
expect(data.searchResults).toBeDefined();
// Filter to only our test notes
const testResults = data.searchResults.filter((result: any) =>
createdNoteIds.includes(result.notePath.split("/").pop() || "")
);
console.log("Quick search '=pagio' found:", testResults.length, "matching notes");
console.log("Note titles:", testResults.map((r: any) => r.noteTitle));
// Should find exactly 3 notes: "Test Note with pagio", "Another pagio Note", "pagio"
expect(testResults.length).toBe(3);
// Verify that none of the results contain "page" or "pages" (only "pagio")
for (const result of testResults) {
const title = result.noteTitle.toLowerCase();
const hasPageNotPagio = (title.includes("page") && !title.includes("pagio"));
expect(hasPageNotPagio).toBe(false);
}
});
test("Full search API without = operator returns partial matches", async ({ page }) => {
// Test the /search endpoint without the = operator
const response = await page.request.get(`${BASE_URL}/api/search/pag`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
// Should return an array of note IDs
expect(Array.isArray(data)).toBe(true);
// Filter to only our test notes
const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id));
console.log("Full search 'pag' found:", testNoteIds.length, "matching notes from our test set");
// Should find at least 4 notes
expect(testNoteIds.length).toBeGreaterThanOrEqual(4);
});
test("Full search API with = operator returns only exact matches", async ({ page }) => {
// Test the /search endpoint WITH the = operator
const response = await page.request.get(`${BASE_URL}/api/search/=pagio`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
// Should return an array of note IDs
expect(Array.isArray(data)).toBe(true);
// Filter to only our test notes
const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id));
console.log("Full search '=pagio' found:", testNoteIds.length, "matching notes from our test set");
// Should find exactly 3 notes with exact "pagio" match
expect(testNoteIds.length).toBe(3);
});
test("Exact search operator works with content search", async ({ page }) => {
// Create a note with "test" in title but different content
const noteWithTest = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Testing Content",
content: "This note contains the exact word test in content.",
type: "text"
}
});
expect(noteWithTest.ok()).toBeTruthy();
const noteWithTestData = await noteWithTest.json();
const testNoteId = noteWithTestData.note.noteId;
createdNoteIds.push(testNoteId);
// Create a note with "testing" (not exact match)
const noteWithTesting = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Testing More",
content: "This note has testing in the content.",
type: "text"
}
});
expect(noteWithTesting.ok()).toBeTruthy();
const noteWithTestingData = await noteWithTesting.json();
createdNoteIds.push(noteWithTestingData.note.noteId);
await page.waitForTimeout(500);
// Search with exact operator
const response = await page.request.get(`${BASE_URL}/api/quick-search/=test`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
const ourTestNotes = data.searchResults.filter((result: any) => {
const noteId = result.notePath.split("/").pop();
return noteId === testNoteId || noteId === noteWithTestingData.note.noteId;
});
console.log("Exact search '=test' found our test notes:", ourTestNotes.length);
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
// Should find the note with exact "test" match, but not "testing"
// Note: This test may fail if the implementation doesn't properly handle exact matching in content
expect(ourTestNotes.length).toBeGreaterThan(0);
});
test("Exact search is case-insensitive", async ({ page }) => {
// Create notes with different case variations
const noteUpper = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "EXACT MATCH",
content: "This note has EXACT in uppercase.",
type: "text"
}
});
expect(noteUpper.ok()).toBeTruthy();
const noteUpperData = await noteUpper.json();
createdNoteIds.push(noteUpperData.note.noteId);
const noteLower = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "exact match",
content: "This note has exact in lowercase.",
type: "text"
}
});
expect(noteLower.ok()).toBeTruthy();
const noteLowerData = await noteLower.json();
createdNoteIds.push(noteLowerData.note.noteId);
await page.waitForTimeout(500);
// Search with exact operator in lowercase
const response = await page.request.get(`${BASE_URL}/api/quick-search/=exact`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
const ourTestNotes = data.searchResults.filter((result: any) => {
const noteId = result.notePath.split("/").pop();
return noteId === noteUpperData.note.noteId || noteId === noteLowerData.note.noteId;
});
console.log("Case-insensitive exact search found:", ourTestNotes.length, "notes");
// Should find both uppercase and lowercase versions
expect(ourTestNotes.length).toBe(2);
});
test("Exact phrase matching with multi-word searches", async ({ page }) => {
// Create notes with various phrase patterns
const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "exact phrase",
content: "This note contains the exact phrase.",
type: "text"
}
});
expect(note1.ok()).toBeTruthy();
const note1Data = await note1.json();
createdNoteIds.push(note1Data.note.noteId);
const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "exact phrase match",
content: "This note has exact phrase followed by more words.",
type: "text"
}
});
expect(note2.ok()).toBeTruthy();
const note2Data = await note2.json();
createdNoteIds.push(note2Data.note.noteId);
const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "phrase exact",
content: "This note has the words in reverse order.",
type: "text"
}
});
expect(note3.ok()).toBeTruthy();
const note3Data = await note3.json();
createdNoteIds.push(note3Data.note.noteId);
const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "this exact and that phrase",
content: "Words are separated but both present.",
type: "text"
}
});
expect(note4.ok()).toBeTruthy();
const note4Data = await note4.json();
createdNoteIds.push(note4Data.note.noteId);
await page.waitForTimeout(500);
// Search for exact phrase "exact phrase"
const response = await page.request.get(`${BASE_URL}/api/quick-search/='exact phrase'`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
const ourTestNotes = data.searchResults.filter((result: any) => {
const noteId = result.notePath.split("/").pop();
return [note1Data.note.noteId, note2Data.note.noteId, note3Data.note.noteId, note4Data.note.noteId].includes(noteId || "");
});
console.log("Exact phrase search '=\"exact phrase\"' found:", ourTestNotes.length, "notes");
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
// Should find only notes 1 and 2 (consecutive "exact phrase")
// Should NOT find note 3 (reversed order) or note 4 (words separated)
expect(ourTestNotes.length).toBe(2);
const foundTitles = ourTestNotes.map((r: any) => r.noteTitle);
expect(foundTitles).toContain("exact phrase");
expect(foundTitles).toContain("exact phrase match");
expect(foundTitles).not.toContain("phrase exact");
expect(foundTitles).not.toContain("this exact and that phrase");
});
test("Exact phrase matching respects word order", async ({ page }) => {
// Create notes to test word order sensitivity
const noteForward = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Testing Order",
content: "This is a test sentence for verification.",
type: "text"
}
});
expect(noteForward.ok()).toBeTruthy();
const noteForwardData = await noteForward.json();
createdNoteIds.push(noteForwardData.note.noteId);
const noteReverse = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Order Testing",
content: "A sentence test is this for verification.",
type: "text"
}
});
expect(noteReverse.ok()).toBeTruthy();
const noteReverseData = await noteReverse.json();
createdNoteIds.push(noteReverseData.note.noteId);
await page.waitForTimeout(500);
// Search for exact phrase "test sentence"
const response = await page.request.get(`${BASE_URL}/api/quick-search/='test sentence'`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
const ourTestNotes = data.searchResults.filter((result: any) => {
const noteId = result.notePath.split("/").pop();
return noteId === noteForwardData.note.noteId || noteId === noteReverseData.note.noteId;
});
console.log("Exact phrase search '=\"test sentence\"' found:", ourTestNotes.length, "notes");
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
// Should find only the forward order note
expect(ourTestNotes.length).toBe(1);
expect(ourTestNotes[0].noteTitle).toBe("Testing Order");
});
test("Multi-word exact search without quotes", async ({ page }) => {
// Test that multi-word search with = but without quotes also does exact phrase matching
const notePhrase = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Quick Test Note",
content: "A simple note for multi word testing.",
type: "text"
}
});
expect(notePhrase.ok()).toBeTruthy();
const notePhraseData = await notePhrase.json();
createdNoteIds.push(notePhraseData.note.noteId);
const noteScattered = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
headers: { "x-csrf-token": csrfToken },
data: {
title: "Word Multi Testing",
content: "Words are multi scattered in this testing example.",
type: "text"
}
});
expect(noteScattered.ok()).toBeTruthy();
const noteScatteredData = await noteScattered.json();
createdNoteIds.push(noteScatteredData.note.noteId);
await page.waitForTimeout(500);
// Search for "=multi word" without quotes (parser tokenizes as two words)
const response = await page.request.get(`${BASE_URL}/api/quick-search/=multi word`, {
headers: { "x-csrf-token": csrfToken }
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
const ourTestNotes = data.searchResults.filter((result: any) => {
const noteId = result.notePath.split("/").pop();
return noteId === notePhraseData.note.noteId || noteId === noteScatteredData.note.noteId;
});
console.log("Multi-word exact search '=multi word' found:", ourTestNotes.length, "notes");
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
// Should find only the note with consecutive "multi word" phrase
expect(ourTestNotes.length).toBe(1);
expect(ourTestNotes[0].noteTitle).toBe("Quick Test Note");
});
});

File diff suppressed because one or more lines are too long

View File

@ -19,7 +19,8 @@ class="image image_resized" style="width:74.04%;">
<img style="aspect-ratio:1911/997;" src="1_AI_image.png"
width="1911" height="997">
</figure>
<h2>Embeddings</h2>
<h2>Embeddings</h2>
<p><strong>Embeddings</strong> 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%;">
<p>These are the tools that currently exist, and will certainly be updated
to be more effectively (and even more to be added!):</p>
<ul>
<li data-list-item-id="e56ad2313afb3f2f5162bcb3d9bf8e963"><code>search_notes</code>
<li><code>search_notes</code>
<ul>
<li data-list-item-id="ee3b5cc2bd60f496e7919c63def713108">Semantic search</li>
<li>Semantic search</li>
</ul>
</li>
<li data-list-item-id="e9abe7bae428aff060dfc70ef669bd079"><code>keyword_search</code>
<li><code>keyword_search</code>
<ul>
<li data-list-item-id="ea8e2cece0d711e80eacfdb42e5d0edc3">Keyword-based search</li>
<li>Keyword-based search</li>
</ul>
</li>
<li data-list-item-id="e775735113778afe2073695881679540b"><code>attribute_search</code>
<li><code>attribute_search</code>
<ul>
<li data-list-item-id="e64a93997e8ae8cc348e222f926866921">Attribute-specific search</li>
<li>Attribute-specific search</li>
</ul>
</li>
<li data-list-item-id="e58d04b2e226a143a8c8941337b04113a"><code>search_suggestion</code>
<li><code>search_suggestion</code>
<ul>
<li data-list-item-id="e7c1ed076f0e0e3c242ca659276576860">Search syntax helper</li>
<li>Search syntax helper</li>
</ul>
</li>
<li data-list-item-id="e30741593cef85e71179301c280063bc2"><code>read_note</code>
<li><code>read_note</code>
<ul>
<li data-list-item-id="e9c50f6fe0b4f013f16f9b32dbf6dbc92">Read note content (helps the LLM read Notes)</li>
<li>Read note content (helps the LLM read Notes)</li>
</ul>
</li>
<li data-list-item-id="ec71fd71ce58b9d8338bc66e7c99ae1cb"><code>create_note</code>
<li><code>create_note</code>
<ul>
<li data-list-item-id="e0b0bae68b45bb0c27bc092145452ecd1">Create a Note</li>
<li>Create a Note</li>
</ul>
</li>
<li data-list-item-id="e7e816d84f52cf76096d2b5f1e4665989"><code>update_note</code>
<li><code>update_note</code>
<ul>
<li data-list-item-id="ed28fc8ca5a4753c1b680ee52d140940a">Update a Note</li>
<li>Update a Note</li>
</ul>
</li>
<li data-list-item-id="e4580152b092501aa4fa87d1562b0f304"><code>manage_attributes</code>
<li><code>manage_attributes</code>
<ul>
<li data-list-item-id="ea0601e29b574cf0e4c40ae0e7d60bfa4">Manage attributes on a Note</li>
<li>Manage attributes on a Note</li>
</ul>
</li>
<li data-list-item-id="ecea045d4312b04ea06ac12802efb0544"><code>manage_relationships</code>
<li><code>manage_relationships</code>
<ul>
<li data-list-item-id="e3293a7c119de1a0eb2ddf8779a0b0d65">Manage the various relationships between Notes</li>
<li>Manage the various relationships between Notes</li>
</ul>
</li>
<li data-list-item-id="e483a2ef5f76d4da426c6059ddd3827a0"><code>extract_content</code>
<li><code>extract_content</code>
<ul>
<li data-list-item-id="e1735854ab39d6d4e06a242bc2f80b839">Used to smartly extract content from a Note</li>
<li>Used to smartly extract content from a Note</li>
</ul>
</li>
<li data-list-item-id="e11df70f4a6846bf881cb110c2950065f"><code>calendar_integration</code>
<li><code>calendar_integration</code>
<ul>
<li data-list-item-id="e0f1dfa6c5520bf02e125c1932b4f6e5e">Used to find date notes, create date notes, get the daily note, etc.</li>
<li>Used to find date notes, create date notes, get the daily note, etc.</li>
</ul>
</li>
</ul>
@ -146,12 +147,12 @@ class="image image_resized" style="width:74.04%;">
<h2>Overview</h2>
<p>To start, simply press the <em>Chat with Notes</em> button in the&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>.</p>
class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>.</p>
<figure class="image image_resized" style="width:60.77%;">
<img style="aspect-ratio:1378/539;" src="2_AI_image.png"
width="1378" height="539">
</figure>
<p>If you don't see the button in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>,
<p>If you don't see the button in the&nbsp;<a class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>,
you might need to move it from the <em>Available Launchers</em> section to
the <em>Visible Launchers</em> section:</p>
<figure class="image image_resized"

View File

@ -1,41 +1,40 @@
<aside class="admonition warning">
<p>This functionality is still in preview, expect possible issues or even
the feature disappearing completely.
<br>Feel free to <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report</a> any
issues you might have.</p>
<br>Feel free to <a href="#root/_help_wy8So3yZZlH9">report</a> any issues you might
have.</p>
</aside>
<p>The read-only database is an alternative to&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_R9pX4DGra2Vt">Sharing</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_eIg8jdvaoNNd">Search</a>&nbsp;or
the interactivity behind&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a>&nbsp;or
the various&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/_help_KSZ04uQ2D1St">Note Types</a>).</p>
href="#root/_help_R9pX4DGra2Vt">Sharing</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_eIg8jdvaoNNd">Search</a>&nbsp;or
the interactivity behind&nbsp;<a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>&nbsp;or
the various&nbsp;<a class="reference-link" href="#root/_help_KSZ04uQ2D1St">Note Types</a>).</p>
<p>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.</p>
<h2>What it does</h2>
<ul>
<li data-list-item-id="e97d71c2d01fa683d840e3f8de9e5bea9">All notes are read-only, without the possibility of editing them.</li>
<li
data-list-item-id="e6e82b25093f659cca922614ea8701ca4">Features that would normally alter the database such as the list of recent
<li>All notes are read-only, without the possibility of editing them.</li>
<li>Features that would normally alter the database such as the list of recent
notes are disabled.</li>
</ul>
<h2>Limitations</h2>
<ul>
<li data-list-item-id="ec1a648467ed42eee3d6b96f829b6daad">Some features might “slip through” and still end up creating a note, for
<li>Some features might “slip through” and still end up creating a note, for
example.
<ul>
<li data-list-item-id="ed98de88bf24f574cfda28cf278be7f86">However, the database is still read-only, so all modifications will be
<li>However, the database is still read-only, so all modifications will be
reset if the server is restarted.</li>
<li data-list-item-id="e3f0450830304fa98038a8a5fd9026b06">Whenever this occurs, <code>ERROR: read-only DB ignored</code> will be shown
<li>Whenever this occurs, <code>ERROR: read-only DB ignored</code> will be shown
in the logs.</li>
</ul>
</li>
</ul>
<h2>Setting a database as read-only</h2>
<p>First, make sure the database is initialized (e.g. the first set up is
complete). Then modify the <a href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_Gzjqa934BdH4">config.ini</a> by
complete). Then modify the <a href="#root/_help_Gzjqa934BdH4">config.ini</a> by
looking for the <code>[General]</code> section and adding a new <code>readOnly</code> field:</p><pre><code class="language-text-x-trilium-auto">[General]
readOnly=true</code></pre>
<p>If your server is already running, restart it to apply the changes.</p>

View File

@ -41,6 +41,15 @@
<li>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.</li>
<li>The directory structure is also slightly different:
<ul>
<li>A normal HTML export results in an index file and a single directory.</li>
<li>Instead, for static exporting the top-root level becomes the index file
and the child directories are on the root instead.</li>
<li>This makes it possible to easily publish to a website, without forcing
everything but the root note to be in a sub-directory.</li>
</ul>
</li>
</ul>
<h2>Testing locally</h2>
<p>As mentioned previously, the exported static pages require a website to

View File

@ -1,12 +1,12 @@
<p>Data directory contains:</p>
<ul>
<li data-list-item-id="e02c8e6918e645272972d94bf51bd34e1"><code>document.db</code> - <a href="#root/_help_wX4HbRucYSDD">database</a>
<li><code>document.db</code> - <a href="#root/_help_wX4HbRucYSDD">database</a>
</li>
<li data-list-item-id="ee3b386f752a4d2de88a1362ff4bc447a"><code>config.ini</code> - instance level settings like port on which the
<li><code>config.ini</code> - instance level settings like port on which the
Trilium application runs</li>
<li data-list-item-id="e9acb98fe27a493914c7f61574b277d59"><code>backup</code> - contains automatically <a href="#root/_help_ODY7qQn5m2FT">backup</a> of
<li><code>backup</code> - contains automatically <a href="#root/_help_ODY7qQn5m2FT">backup</a> of
documents</li>
<li data-list-item-id="e9f5de6d3c7f8d79710eb8c8e2ccac979"><code>log</code> - contains application log files</li>
<li><code>log</code> - contains application log files</li>
</ul>
<h2>Location of the data directory</h2>
<p>Easy way how to find out which data directory Trilium uses is to look
@ -18,14 +18,11 @@
<p>Data directory is normally named <code>trilium-data</code> and it is stored
in:</p>
<ul>
<li data-list-item-id="e52706d339ce2332e73c5cfdc51edf81d"><code>/home/[user]/.local/share</code> for Linux</li>
<li data-list-item-id="e5709c767479c028d2ef60d17124ea924"><code>C:\Users\[user]\AppData\Roaming</code> for Windows Vista and up</li>
<li
data-list-item-id="eae590672aea8b24e625f372fb22d1f1b"><code>/Users/[user]/Library/Application Support</code> for Mac OS</li>
<li
data-list-item-id="e150ab7fc3c436d7cbac192acf3fe433f">user's home is a fallback if some of the paths above don't exist</li>
<li
data-list-item-id="e5f476ac39d8fa453e19b8399073a79d7">user's home is also a default setup for [[docker|Docker server installation]]</li>
<li><code>/home/[user]/.local/share</code> for Linux</li>
<li><code>C:\Users\[user]\AppData\Roaming</code> for Windows Vista and up</li>
<li><code>/Users/[user]/Library/Application Support</code> for Mac OS</li>
<li>user's home is a fallback if some of the paths above don't exist</li>
<li>user's home is also a default setup for [[docker|Docker server installation]]</li>
</ul>
<p>If you want to back up your Trilium data, just backup this single directory
- it contains everything you need.</p>
@ -35,17 +32,15 @@
variable to some other location:</p>
<h3>Windows</h3>
<ol>
<li data-list-item-id="e8597645795a4e5b079b8e2e4a772a7d7">Press the Windows key on your keyboard.</li>
<li data-list-item-id="ee32856df1db7c2d206baa480a672eeac">Search and select “Edit the system variables”.</li>
<li data-list-item-id="e08c7cdc0659b414465a07c78dfdaeb34">Press the “Environment Variables…” button in the bottom-right of the newly
<li>Press the Windows key on your keyboard.</li>
<li>Search and select “Edit the system variables”.</li>
<li>Press the “Environment Variables…” button in the bottom-right of the newly
opened screen.</li>
<li data-list-item-id="e6c7bae2590161a2661b6229c0853021c">On the top section ("User variables for [user]"), press the “New…” button.</li>
<li
data-list-item-id="effac7dc3976f17d309958dd3374cfa8d">In the <em>Variable name</em> field insert <code>TRILIUM_DATA_DIR</code>.</li>
<li
data-list-item-id="e3b74fdd03536563bb461e11111fae5ff">Press the <em>Browse Directory…</em> button and select the new directory
where to store the database.</li>
<li data-list-item-id="ed6df567f0fa772a14610b3904a0cb635">Close all the windows by pressing the <em>OK</em> button for each of them.</li>
<li>On the top section ("User variables for [user]"), press the “New…” button.</li>
<li>In the <em>Variable name</em> field insert <code>TRILIUM_DATA_DIR</code>.</li>
<li>Press the <em>Browse Directory…</em> button and select the new directory
where to store the database.</li>
<li>Close all the windows by pressing the <em>OK</em> button for each of them.</li>
</ol>
<h4>Linux</h4><pre><code class="language-text-x-trilium-auto">export TRILIUM_DATA_DIR=/home/myuser/data/my-trilium-data</code></pre>
<h4>Mac OS X</h4>
@ -74,63 +69,61 @@
<h2>Fine-grained directory/path location</h2>
<p>Apart from the data directory, some of the subdirectories of it can be
moved elsewhere by changing an environment variable:</p>
<figure class="table">
<table>
<thead>
<tr>
<th>Environment variable</th>
<th>Default value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>TRILIUM_DOCUMENT_PATH</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/document.db</code>
</td>
<td>Path to the&nbsp;<a class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a>&nbsp;(storing
all notes and metadata).</td>
</tr>
<tr>
<td><code>TRILIUM_BACKUP_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/backup</code>
</td>
<td>Directory where automated&nbsp;<a class="reference-link" href="#root/_help_ODY7qQn5m2FT">Backup</a>&nbsp;databases
are stored.</td>
</tr>
<tr>
<td><code>TRILIUM_LOG_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/log</code>
</td>
<td>Directory where daily&nbsp;<a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a>&nbsp;are
stored.</td>
</tr>
<tr>
<td><code>TRILIUM_TMP_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/tmp</code>
</td>
<td>Directory where temporary files are stored (for example when opening in
an external app).</td>
</tr>
<tr>
<td><code>TRILIUM_ANONYMIZED_DB_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/anonymized-db</code>
</td>
<td>Directory where a&nbsp;<a class="reference-link" href="#root/_help_x59R8J8KV5Bp">Anonymized Database</a>&nbsp;is
stored.</td>
</tr>
<tr>
<td><code>TRILIUM_CONFIG_INI_PATH</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/config.ini</code>
</td>
<td>Path to&nbsp;<a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a>&nbsp;file.</td>
</tr>
</tbody>
</table>
</figure>
<table>
<thead>
<tr>
<th>Environment variable</th>
<th>Default value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>TRILIUM_DOCUMENT_PATH</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/document.db</code>
</td>
<td>Path to the&nbsp;<a class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a>&nbsp;(storing
all notes and metadata).</td>
</tr>
<tr>
<td><code>TRILIUM_BACKUP_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/backup</code>
</td>
<td>Directory where automated&nbsp;<a class="reference-link" href="#root/_help_ODY7qQn5m2FT">Backup</a>&nbsp;databases
are stored.</td>
</tr>
<tr>
<td><code>TRILIUM_LOG_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/log</code>
</td>
<td>Directory where daily&nbsp;<a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a>&nbsp;are
stored.</td>
</tr>
<tr>
<td><code>TRILIUM_TMP_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/tmp</code>
</td>
<td>Directory where temporary files are stored (for example when opening in
an external app).</td>
</tr>
<tr>
<td><code>TRILIUM_ANONYMIZED_DB_DIR</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/anonymized-db</code>
</td>
<td>Directory where a&nbsp;<a class="reference-link" href="#root/_help_x59R8J8KV5Bp">Anonymized Database</a>&nbsp;is
stored.</td>
</tr>
<tr>
<td><code>TRILIUM_CONFIG_INI_PATH</code>
</td>
<td><code>${TRILIUM_DATA_DIR}/config.ini</code>
</td>
<td>Path to&nbsp;<a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a>&nbsp;file.</td>
</tr>
</tbody>
</table>

View File

@ -8,7 +8,7 @@
get parentWidget() { return "left-pane"; }
doRender() {
this.$widget = $("");
this.$widget = $("&lt;div id='my-widget'&gt;");
return this.$widget;
}
}
@ -22,13 +22,13 @@ module.exports = new MyWidget();</code></pre>
the <a href="#root/_help_BFs8mudNFgCS">note</a>.</li>
<li>Restart Trilium or reload the window.</li>
</ol>
<p>To verify that the widget is working, open the developer tools (<code>Cmd</code> + <code>Shift</code> + <code>I</code>)
<p>To verify that the widget is working, open the developer tools (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd>)
and run <code>document.querySelector("#my-widget")</code>. If the element
is found, the widget is functioning correctly. If <code>undefined</code> is
returned, double-check that the <a href="#root/_help_BFs8mudNFgCS">note</a> has
the <code>#widget</code> <a href="#root/_help_zEY4DaJG4YT5">attribute</a>.</p>
<h3>Step 2: Adding an UI Element</h3>
<p>Next, let's improve the widget by adding a button to it.</p><pre><code class="language-text-x-trilium-auto">const template = ``;
<p>Next, let's improve the widget by adding a button to it.</p><pre><code class="language-text-x-trilium-auto">const template = `&lt;div id="my-widget"&gt;&lt;button&gt;Click Me!&lt;/button&gt;&lt;/div&gt;`;
class MyWidget extends api.BasicWidget {
get position() {return 1;}
@ -47,7 +47,7 @@ module.exports = new MyWidget();</code></pre>
<p>To make the button more visually appealing and position it correctly,
we'll apply some custom styling. Trilium includes <a href="https://boxicons.com">Box Icons</a>,
which we'll use to replace the button text with an icon. For example the <code>bx bxs-magic-wand</code> icon.</p>
<p>Here's the updated template:</p><pre><code class="language-text-x-trilium-auto">const template = ``;</code></pre>
<p>Here's the updated template:</p><pre><code class="language-text-x-trilium-auto">const template = `&lt;div id="my-widget"&gt;&lt;button class="tree-floating-button bx bxs-magic-wand tree-settings-button"&gt;&lt;/button&gt;&lt;/div&gt;`;</code></pre>
<p>Next, we'll adjust the button's position using CSS:</p><pre><code class="language-text-x-trilium-auto">class MyWidget extends api.BasicWidget {
get position() { return 1; }
get parentWidget() { return "left-pane"; }

View File

@ -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 {

View File

@ -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<SearchRow>(`
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)

View File

@ -13,8 +13,41 @@ function getRegex(str: string) {
type Comparator<T> = (comparedValue: T) => (val: string) => boolean;
const stringComparators: Record<string, Comparator<string>> = {
"=": (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,

View File

@ -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 })]);

View File

@ -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", () => {

View File

@ -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
};

View File

@ -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 notatek</0>, <1>mapa powiązań</1>, <2>zapisane wyszukiwania</2>, <3>renderowane notatki</3>, and <4>podgląd stron www</4>."
"others_list": "I wiele innych: <0>mapa notatek</0>, <1>mapa powiązań</1>, <2>zapisane wyszukiwania</2>, <3>renderowane notatki</3>, and <4>podgląd stron www</4>.",
"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"
}
}

View File

@ -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": []
}
]
}

View File

@ -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 <a class="reference-link" href="blobs.md">blobs</a>. 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 <a class="reference-link" href="blobs.md">blobs</a>. Although it can theoretically be `NULL`, haven't found any such note yet. |
| `utcDateModified` | Text | Non-null | | Modification date in UTC format (e.g. `2023-11-08 16:43:44.204Z`) |

View File

@ -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`). |

View File

@ -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. |

View File

@ -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"`). |

View File

@ -0,0 +1,2 @@
# Note Revisions
The revision API on the server side is managed by `apps/server/src/routes/api/revisions.ts`

View File

@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/6iUHD6bZIVmd/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/kaFXA5t813qK/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@ -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",

View File

@ -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 <a class="reference-link" href="Database.md">Database</a>. Depending on your threat model, it might make more sense to use <a class="reference-link" href="Sharing/Exporting%20HTML%20for%20web%20publish.md">Exporting HTML for web publishing</a> 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 <a class="reference-link" href="Database.md">Database</a>. Depending on your threat model, it might make more sense to use <a class="reference-link" href="Sharing/Exporting%20static%20HTML%20for%20web%20.md">Exporting HTML for web publishing</a> and use battle-tested web servers such as Nginx or Apache to serve static content.
### Password protection

View File

@ -1,4 +1,4 @@
# Exporting HTML for web publishing
# Exporting static HTML for web publishing
As described in <a class="reference-link" href="../Sharing.md">Sharing</a>, 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 <a class="reference-link" href="../Sharing.md">Sharing</a> function into static HTML files.
@ -23,6 +23,10 @@ Apart from normal <a class="reference-link" href="../Sharing.md">Sharing</a>, 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

View File

@ -11,7 +11,7 @@ class MyWidget extends api.BasicWidget {
get parentWidget() { return "left-pane"; }
doRender() {
this.$widget = $("");
this.$widget = $("<div id='my-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 (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd>) 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 = `<div id="my-widget"><button>Click Me!</button></div>`;
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 = `<div id="my-widget"><button class="tree-floating-button bx bxs-magic-wand tree-settings-button"></button></div>`;
```
Next, we'll adjust the button's position using CSS: