mirror of
https://github.com/zadam/trilium.git
synced 2025-11-09 07:58:59 +01:00
Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/ui-improvements
This commit is contained in:
commit
728f574eac
@ -1,3 +1,5 @@
|
||||
@import "boxicons/css/boxicons.min.css";
|
||||
|
||||
:root {
|
||||
--print-font-size: 11pt;
|
||||
--ck-content-color-image-caption-background: transparent !important;
|
||||
|
||||
@ -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",
|
||||
|
||||
28
apps/client/src/widgets/collections/calendar/context_menu.ts
Normal file
28
apps/client/src/widgets/collections/calendar/context_menu.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
502
apps/server-e2e/src/exact_search.spec.ts
Normal file
502
apps/server-e2e/src/exact_search.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
51
apps/server/src/assets/doc_notes/en/User Guide/User Guide/AI.html
generated
vendored
51
apps/server/src/assets/doc_notes/en/User Guide/User Guide/AI.html
generated
vendored
@ -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
|
||||
<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 <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>,
|
||||
<p>If you don't see the button in the <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"
|
||||
|
||||
@ -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 <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_R9pX4DGra2Vt">Sharing</a> 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 <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_eIg8jdvaoNNd">Search</a> or
|
||||
the interactivity behind <a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a> or
|
||||
the various <a class="reference-link" href="#root/pOsGYCXsbNQG/_help_KSZ04uQ2D1St">Note Types</a>).</p>
|
||||
href="#root/_help_R9pX4DGra2Vt">Sharing</a> 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 <a class="reference-link" href="#root/_help_eIg8jdvaoNNd">Search</a> or
|
||||
the interactivity behind <a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a> or
|
||||
the various <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>
|
||||
|
||||
@ -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
|
||||
@ -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 <a class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a> (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 <a class="reference-link" href="#root/_help_ODY7qQn5m2FT">Backup</a> 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 <a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a> 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 <a class="reference-link" href="#root/_help_x59R8J8KV5Bp">Anonymized Database</a> 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 <a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a> 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 <a class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a> (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 <a class="reference-link" href="#root/_help_ODY7qQn5m2FT">Backup</a> 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 <a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a> 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 <a class="reference-link" href="#root/_help_x59R8J8KV5Bp">Anonymized Database</a> 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 <a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a> file.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -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();</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 = `<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();</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 = `<div id="my-widget"><button class="tree-floating-button bx bxs-magic-wand tree-settings-button"></button></div>`;</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"; }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })]);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
72
docs/Developer Guide/!!!meta.json
vendored
72
docs/Developer Guide/!!!meta.json
vendored
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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`) |
|
||||
@ -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`). |
|
||||
|
||||
8
docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/sessions.md
vendored
Normal file
8
docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/sessions.md
vendored
Normal 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. |
|
||||
17
docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/user_data.md
vendored
Normal file
17
docs/Developer Guide/Developer Guide/Architecture/Database/Database structure/user_data.md
vendored
Normal 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"`). |
|
||||
@ -0,0 +1,2 @@
|
||||
# Note Revisions
|
||||
The revision API on the server side is managed by `apps/server/src/routes/api/revisions.ts`
|
||||
@ -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.
|
||||
|
||||
102
docs/User Guide/!!!meta.json
vendored
102
docs/User Guide/!!!meta.json
vendored
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user