diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 97756a3830..f1908c9c93 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -1,2 +1,3 @@ import { note_service } from "@triliumnext/core"; +export { findBookmarks } from "@triliumnext/core"; export default note_service; diff --git a/apps/server/src/services/search/services/search_profiling.spec.ts b/apps/server/src/services/search/services/search_profiling.spec.ts index 6ed5d9fbb7..d043281acb 100644 --- a/apps/server/src/services/search/services/search_profiling.spec.ts +++ b/apps/server/src/services/search/services/search_profiling.spec.ts @@ -12,15 +12,12 @@ * - These tests focus on the in-memory/CPU-bound parts of the pipeline */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import searchService from "./search.js"; -import BNote from "../../../becca/entities/bnote.js"; -import BBranch from "../../../becca/entities/bbranch.js"; -import SearchContext from "../search_context.js"; -import becca from "../../../becca/becca.js"; -import beccaService from "../../../becca/becca_service.js"; -import { NoteBuilder, note, id } from "../../../test/becca_mocking.js"; -import SearchResult from "../search_result.js"; -import { normalizeSearchText } from "../utils/text_utils.js"; +import { becca, becca_service as beccaService, BNote, BBranch, search as searchService, SearchContext, becca_mocking } from "@triliumnext/core"; +import SearchResult from "@triliumnext/core/src/services/search/search_result.js"; +import { normalizeSearchText } from "@triliumnext/core/src/services/search/utils/text_utils.js"; + +const { NoteBuilder, note } = becca_mocking; +type NoteBuilder = InstanceType; // ── helpers ────────────────────────────────────────────────────────── diff --git a/apps/server/src/test/becca_mocking.ts b/apps/server/src/test/becca_mocking.ts index 4e70eb4ff9..c3b3dc8050 100644 --- a/apps/server/src/test/becca_mocking.ts +++ b/apps/server/src/test/becca_mocking.ts @@ -1,2 +1 @@ -import { becca_mocking } from "@triliumnext/core"; -export const NoteBuilder = becca_mocking.NoteBuilder; +export { NoteBuilder, note, id, findNoteByTitle } from "@triliumnext/core/src/test/becca_mocking.js"; diff --git a/packages/trilium-core/src/becca/entities/battribute.ts b/packages/trilium-core/src/becca/entities/battribute.ts index 9156017793..ac8e3e2553 100644 --- a/packages/trilium-core/src/becca/entities/battribute.ts +++ b/packages/trilium-core/src/becca/entities/battribute.ts @@ -5,7 +5,7 @@ import AbstractBeccaEntity from "./abstract_becca_entity.js"; import dateUtils from "../../services/utils/date"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; import type { AttributeRow, AttributeType } from "@triliumnext/commons"; -import { sanitizeAttributeName } from "../../services/utils/index.js"; +import { normalize, sanitizeAttributeName } from "../../services/utils/index.js"; interface SavingOpts { skipValidation?: boolean; @@ -34,6 +34,11 @@ class BAttribute extends AbstractBeccaEntity { value!: string; isInheritable!: boolean; + /** Pre-normalized (lowercase, diacritics removed) name for search. */ + normalizedName!: string; + /** Pre-normalized (lowercase, diacritics removed) value for search. */ + normalizedValue!: string; + constructor(row?: AttributeRow) { super(); @@ -59,6 +64,10 @@ class BAttribute extends AbstractBeccaEntity { this.isInheritable = !!isInheritable; this.utcDateModified = utcDateModified; + // Pre-compute normalized forms for search (avoids repeated normalize() calls in hot loops) + this.normalizedName = normalize(this.name); + this.normalizedValue = normalize(this.value); + return this; } @@ -194,6 +203,11 @@ class BAttribute extends AbstractBeccaEntity { super.beforeSaving(); + // Recompute normalized fields in case name/value were modified directly + // (e.g., attr.value = "..." followed by attr.save()) + this.normalizedName = normalize(this.name); + this.normalizedValue = normalize(this.value); + this.becca.attributes[this.attributeId] = this; } diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index e33f0484ec..30755675a1 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -90,7 +90,7 @@ export { default as SearchContext } from "./services/search/search_context"; export { default as search, } from "./services/search/services/search"; export { type default as SearchResult } from "./services/search/search_result"; export { type SearchParams } from "./services/search/services/types"; -export { default as note_service } from "./services/notes"; +export { default as note_service, findBookmarks } from "./services/notes"; export type { NoteParams } from "./services/notes"; export * as sanitize from "./services/sanitizer"; export * as routes from "./routes"; diff --git a/packages/trilium-core/src/services/import/enex.spec.ts b/packages/trilium-core/src/services/import/enex.spec.ts index a95baafec9..d66494778c 100644 --- a/packages/trilium-core/src/services/import/enex.spec.ts +++ b/packages/trilium-core/src/services/import/enex.spec.ts @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; -import cls from "../cls.js"; +import { getContext } from "../context.js"; import sql_init from "../sql_init.js"; import TaskContext from "../task_context.js"; import enex from "./enex.js"; @@ -17,7 +17,7 @@ async function testImport(fileName: string) { const taskContext = TaskContext.getInstance("import-enex", "importNotes", {}); return new Promise<{ importedNote: BNote; rootNote: BNote }>((resolve, reject) => { - cls.init(async () => { + getContext().init(async () => { const rootNote = becca.getNote("root"); if (!rootNote) { expect(rootNote).toBeTruthy(); diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index 1830af3a5e..03215c5b91 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -512,6 +512,54 @@ function findIncludeNoteLinks(content: string, foundLinks: FoundLink[]) { return content; } +/** + * Extracts bookmark IDs from CKEditor bookmark anchors (`` without href). + * Bookmarks are stored as labels on the note so they can be looked up without parsing content. + */ +export function findBookmarks(content: string): string[] { + const re = /]*>(<\/a>)?/g; + const bookmarks: string[] = []; + let match; + + while ((match = re.exec(content))) { + // Skip anchors that also have an href (those are regular links, not bookmarks) + if (match[0].includes("href=")) { + continue; + } + + const id = match[1]; + if (!bookmarks.includes(id)) { + bookmarks.push(id); + } + } + + return bookmarks; +} + +function saveBookmarks(note: BNote, content: string) { + const foundBookmarks = findBookmarks(content); + const existingBookmarks = note.getOwnedLabels("internalBookmark"); + + for (const bookmarkId of foundBookmarks) { + const existing = existingBookmarks.find((l) => l.value === bookmarkId); + + if (!existing) { + new BAttribute({ + noteId: note.noteId, + type: "label", + name: "internalBookmark", + value: bookmarkId + }).save(); + } + } + + // Remove bookmarks that are no longer in the content + const unusedBookmarks = existingBookmarks.filter((l) => !foundBookmarks.includes(l.value)); + for (const unused of unusedBookmarks) { + unused.markAsDeleted(); + } +} + function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { try { const obj = JSON.parse(content); @@ -727,6 +775,7 @@ function saveLinks(note: BNote, content: string | Uint8Array) { content = findImageLinks(content, foundLinks); content = findInternalLinks(content, foundLinks); content = findIncludeNoteLinks(content, foundLinks); + saveBookmarks(note, content); ({ forceFrontendReload, content } = checkImageAttachments(note, content)); } else if (note.type === "relationMap" && typeof content === "string") { diff --git a/packages/trilium-core/src/services/sanitizer.spec.ts b/packages/trilium-core/src/services/sanitizer.spec.ts index 734f5f69a1..0737f163bd 100644 --- a/packages/trilium-core/src/services/sanitizer.spec.ts +++ b/packages/trilium-core/src/services/sanitizer.spec.ts @@ -54,22 +54,22 @@ describe("sanitize", () => { describe("bookmark anchors", () => { it("preserves id attribute on empty tags (CKEditor bookmarks)", () => { const dirty = ``; - expect(html_sanitizer.sanitize(dirty)).toBe(dirty); + expect(sanitizeHtml(dirty)).toBe(dirty); }); it("preserves id attribute on tags with bookmark class", () => { const dirty = ``; - expect(html_sanitizer.sanitize(dirty)).toBe(dirty); + expect(sanitizeHtml(dirty)).toBe(dirty); }); it("strips id attribute from non-anchor tags to prevent DOM clobbering", () => { const dirty = `
content
`; - expect(html_sanitizer.sanitize(dirty)).toBe(`
content
`); + expect(sanitizeHtml(dirty)).toBe(`
content
`); }); it("strips id attribute from tags to prevent DOM clobbering", () => { const dirty = ``; - expect(html_sanitizer.sanitize(dirty)).toBe(``); + expect(sanitizeHtml(dirty)).toBe(``); }); }); }); diff --git a/packages/trilium-core/src/services/search/search_context.ts b/packages/trilium-core/src/services/search/search_context.ts index 314c7e7ce6..64e35a420c 100644 --- a/packages/trilium-core/src/services/search/search_context.ts +++ b/packages/trilium-core/src/services/search/search_context.ts @@ -1,6 +1,7 @@ "use strict"; import hoistedNoteService from "../hoisted_note.js"; +import optionService from "../options.js"; import type { SearchParams } from "./services/types.js"; class SearchContext { @@ -19,6 +20,8 @@ class SearchContext { debugInfo: {} | null; fuzzyAttributeSearch: boolean; enableFuzzyMatching: boolean; // Controls whether fuzzy matching is enabled for this search phase + /** When true, skip the two-phase fuzzy fallback and use the single-token fast path. */ + autocomplete: boolean; highlightedTokens: string[]; originalQuery: string; fulltextQuery: string; @@ -46,7 +49,12 @@ class SearchContext { this.debug = params.debug; this.debugInfo = null; this.fuzzyAttributeSearch = !!params.fuzzyAttributeSearch; - this.enableFuzzyMatching = true; // Default to true for backward compatibility + this.autocomplete = !!params.autocomplete; + try { + this.enableFuzzyMatching = optionService.getOptionBool("searchEnableFuzzyMatching"); + } catch { + this.enableFuzzyMatching = true; // Default to true if option not yet initialized + } this.highlightedTokens = []; this.originalQuery = ""; this.fulltextQuery = ""; // complete fulltext part