diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..6289e73104 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -30,6 +30,8 @@ export interface Suggestion { notePathTitle?: string; notePath?: string; highlightedNotePathTitle?: string; + highlightedNoteId?: string; + highlightedContentSnippet?: string; action?: string | "create-note" | "search-notes" | "external-link" | "command"; parentNoteId?: string; icon?: string; @@ -344,6 +346,12 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { html += ``; html += `${suggestion.highlightedNotePathTitle}`; + // Highlighted noteId (if present) + if (suggestion.highlightedNoteId) { + html += `
${suggestion.highlightedNoteId}
`; + } + html += ``; // close .note-header + // Add attribute snippet inline if available if (suggestion.highlightedAttributeSnippet) { html += `${suggestion.highlightedAttributeSnippet}`; diff --git a/apps/client/src/stylesheets/theme-next/dialogs.css b/apps/client/src/stylesheets/theme-next/dialogs.css index 357a541ff0..674b0dab03 100644 --- a/apps/client/src/stylesheets/theme-next/dialogs.css +++ b/apps/client/src/stylesheets/theme-next/dialogs.css @@ -117,6 +117,20 @@ div.tn-tool-dialog { backdrop-filter: var(--dropdown-backdrop-filter); } +.jump-to-note-dialog .search-result-id { + display: block; + margin-top: 2px; + font-size: 0.8em; + color: var(--muted-text-color); + font-family: var(--monospace-font, monospace); + opacity: 0.8; +} + +.jump-to-note-dialog .search-result-id b { + color: var(--accent-color); + font-weight: 600; +} + .jump-to-note-dialog .modal-content { --bs-modal-header-padding-x: 0; @@ -420,4 +434,118 @@ div.tn-tool-dialog { .note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.bx { margin-right: .25em; -} \ No newline at end of file +} + +/* ============================================================ +Quick Search Dropdown Styles +============================================================ */ + +.quick-search { + padding: 10px 10px 10px 0px; + height: 50px; +} + +.quick-search button, .quick-search input { + border: 0; + font-size: 100% !important; +} + +.quick-search .dropdown-menu { + --quick-search-item-delimiter-color: var(--dropdown-border-color); + + max-height: 80vh; + min-width: 400px; + max-width: 720px; + overflow-y: auto; + overflow-x: hidden; + text-overflow: ellipsis; + box-shadow: -30px 50px 93px -50px black; +} + +.quick-search .dropdown-item { + white-space: normal; + padding: 12px 16px; + line-height: 1.4; + position: relative; +} + +.quick-search .dropdown-item + .dropdown-item::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 1px; + border-bottom: 1px solid var(--quick-search-item-delimiter-color); +} + +.quick-search .dropdown-item:last-child::after { + display: none; +} + +.quick-search .dropdown-item.disabled::after { + display: none; +} + +.quick-search .dropdown-item.show-in-full-search::after { + display: none; +} + +.quick-search-item.dropdown-item:hover { + background-color: #f8f9fa; +} + +.quick-search .quick-search-item { + width: 100%; +} + +.quick-search .quick-search-item-header { + padding: 0 8px; +} + +.quick-search .quick-search-item-icon { + margin-inline-end: 2px; +} + +.quick-search .search-result-title { + font-weight: 500; +} + +.quick-search .search-result-attributes { + opacity: .5; + padding: 0 8px; + font-size: .75em; +} + +.quick-search .search-result-id { + font-size: 0.75em; + color: var(--muted-text-color); + margin-top: 2px; + padding-left: 22px; /* aligns nicely with the icon */ +} + +.quick-search .search-result-content { + margin-top: 8px; + padding: 8px; + background-color: var(--accented-background-color); + color: var(--main-text-color); + font-size: .85em; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Search result highlighting */ +.quick-search .search-result-title b, +.quick-search .search-result-content b, +.quick-search .search-result-attributes b { + color: var(--admonition-warning-accent-color); + text-decoration: underline; +} + +.quick-search .dropdown-divider { + margin: 0; +} + +.quick-search .bx-loader { + margin-inline-end: 4px; +} diff --git a/apps/server/src/becca/entities/battribute.ts b/apps/server/src/becca/entities/battribute.ts index 6ff1246fcf..dc50a6186e 100644 --- a/apps/server/src/becca/entities/battribute.ts +++ b/apps/server/src/becca/entities/battribute.ts @@ -34,6 +34,9 @@ class BAttribute extends AbstractBeccaEntity { value!: string; isInheritable!: boolean; + _normalizedKey?: string; + _normalizedValue?: string; + constructor(row?: AttributeRow) { super(); @@ -59,6 +62,9 @@ class BAttribute extends AbstractBeccaEntity { this.isInheritable = !!isInheritable; this.utcDateModified = utcDateModified; + this._normalizedKey = undefined; + this._normalizedValue = undefined; + return this; } @@ -172,6 +178,8 @@ class BAttribute extends AbstractBeccaEntity { } this.name = sanitizeAttributeName(this.name); + this._normalizedKey = undefined; + this._normalizedValue = undefined; if (!this.value) { // null value isn't allowed diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 1a724b1b06..c03b71528b 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -89,6 +89,10 @@ class BNote extends AbstractBeccaEntity { return ["noteId", "title", "isProtected", "type", "mime", "blobId"]; } + _normalizedTitle?: string; + /** Cached trigrams for the note title */ + _titleTrigrams?: Set; + noteId!: string; title!: string; type!: NoteType; @@ -149,6 +153,9 @@ class BNote extends AbstractBeccaEntity { this.utcDateModified = utcDateModified; this.isBeingDeleted = false; + this._normalizedTitle = undefined; + this._titleTrigrams = undefined; + // ------ Derived attributes ------ this.isDecrypted = !this.noteId || !this.isProtected; @@ -816,6 +823,9 @@ class BNote extends AbstractBeccaEntity { this.__attributeCache = null; this.__inheritableAttributeCache = null; this.__ancestorCache = null; + + this._normalizedTitle = undefined; + this._titleTrigrams = undefined; } invalidateSubTree(path: string[] = []) { diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts index 29d75c6dca..9f4f3debc0 100644 --- a/apps/server/src/routes/api/search.ts +++ b/apps/server/src/routes/api/search.ts @@ -54,7 +54,7 @@ function quickSearch(req: Request) { // 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[]; diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index bf8a33524b..0e6d942a1e 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -2,169 +2,253 @@ import beccaService from "../../becca/becca_service.js"; import becca from "../../becca/becca.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - FUZZY_SEARCH_CONFIG -} from "./utils/text_utils.js"; +import fuzzysort from "fuzzysort"; -// Scoring constants for better maintainability const SCORE_WEIGHTS = { NOTE_ID_EXACT_MATCH: 1000, - TITLE_EXACT_MATCH: 2000, - TITLE_PREFIX_MATCH: 500, - TITLE_WORD_MATCH: 300, - TOKEN_EXACT_MATCH: 4, - TOKEN_PREFIX_MATCH: 2, - TOKEN_CONTAINS_MATCH: 1, - TOKEN_FUZZY_MATCH: 0.5, - TITLE_FACTOR: 2.0, - PATH_FACTOR: 0.3, + + TITLE_EXACT_MATCH: 900, + LABEL_VALUE_EXACT_MATCH: 880, + TITLE_PREFIX_MATCH: 850, + LABEL_VALUE_PREFIX_MATCH: 840, + TITLE_WORD_MATCH: 800, + LABEL_VALUE_WORD_MATCH: 790, + + LABEL_KEY_EXACT_MATCH: 600, + LABEL_KEY_PREFIX_MATCH: 580, + LABEL_KEY_WORD_MATCH: 560, + + TITLE_FUZZY_MATCH: 750, + LABEL_VALUE_FUZZY_MATCH: 560, + LABEL_KEY_FUZZY_MATCH: 540, + + TOKEN_EXACT_MATCH: 120, + TOKEN_PREFIX_MATCH: 110, + TOKEN_CONTAINS_MATCH: 105, + TOKEN_FUZZY_MATCH: 100, + HIDDEN_NOTE_PENALTY: 3, - // Score caps to prevent fuzzy matches from outranking exact matches - MAX_FUZZY_SCORE_PER_TOKEN: 3, // Cap fuzzy token contributions to stay below exact matches - MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER: 3, // Limit token length impact for fuzzy matches - MAX_TOTAL_FUZZY_SCORE: 200 // Total cap on fuzzy scoring per search + MAX_TOTAL_FUZZY_SCORE: 100, + MAX_FUZZY_SCORE_PER_TOKEN: 3, + MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER: 3 } as const; +function normalizeSearchText(str?: string | null): string { + if (!str) return ""; // handle undefined, null, empty + return str + .normalize("NFKD") // split accents + .replace(/[\u0300-\u036f]/g, "") // remove diacritics + .replace(/[^a-z0-9\s_-]+/gi, " ") // remove weird chars + .replace(/\s+/g, " ") // collapse spaces + .trim() + .toLowerCase(); +} + +function getTrigrams(str: string): Set { + const set = new Set(); + for (let i = 0; i < str.length - 2; i++) set.add(str.slice(i, i + 3)); + return set; +} + +function trigramJaccard(a: Set, b: Set): number { + if (!a.size || !b.size) return 0; + let intersection = 0; + for (const tri of a) if (b.has(tri)) intersection++; + return intersection / (a.size + b.size - intersection); +} class SearchResult { notePathArray: string[]; - score: number; + score = 0; + fuzzyScore = 0; + + // 🧩 extra properties Trilium expects notePathTitle: string; highlightedNotePathTitle?: string; - contentSnippet?: string; + contentSnippet: string = ""; highlightedContentSnippet?: string; - attributeSnippet?: string; + attributeSnippet: string = ""; highlightedAttributeSnippet?: string; - private fuzzyScore: number; // Track fuzzy score separately + highlightedNoteId?: string; constructor(notePathArray: string[]) { this.notePathArray = notePathArray; - this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray); - this.score = 0; - this.fuzzyScore = 0; - } - - get notePath() { - return this.notePathArray.join("/"); + this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray) || ""; + this.contentSnippet = ""; + this.attributeSnippet = ""; } get noteId() { return this.notePathArray[this.notePathArray.length - 1]; } - computeScore(fulltextQuery: string, tokens: string[], enableFuzzyMatching: boolean = true) { - this.score = 0; - this.fuzzyScore = 0; // Reset fuzzy score tracking + get notePath(): string { + return this.notePathArray.join("/"); + } + computeScore( + fulltextQuery: string, + tokens: string[], + enableFuzzyMatching = true, + preNormalized = false, + precomputed?: { normalizedQuery?: string; queryTrigrams?: Set } + ) { const note = becca.notes[this.noteId]; - const normalizedQuery = normalizeSearchText(fulltextQuery.toLowerCase()); - const normalizedTitle = normalizeSearchText(note.title.toLowerCase()); + const normalizedQuery = precomputed?.normalizedQuery ?? normalizeSearchText(fulltextQuery); + const queryTrigrams = precomputed?.queryTrigrams ?? getTrigrams(normalizedQuery); + const title = note._normalizedTitle ??= normalizeSearchText(note.title || ""); + const labels = note.getLabels?.() || []; - // Note ID exact match, much higher score - if (note.noteId.toLowerCase() === fulltextQuery) { - this.score += SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH; - } + this.score = 0; + this.fuzzyScore = 0; - // Title matching scores with fuzzy matching support - if (normalizedTitle === normalizedQuery) { - this.score += SCORE_WEIGHTS.TITLE_EXACT_MATCH; - } else if (normalizedTitle.startsWith(normalizedQuery)) { - this.score += SCORE_WEIGHTS.TITLE_PREFIX_MATCH; - } else if (this.isWordMatch(normalizedTitle, normalizedQuery)) { - this.score += SCORE_WEIGHTS.TITLE_WORD_MATCH; - } else if (enableFuzzyMatching) { - // Try fuzzy matching for typos only if enabled - const fuzzyScore = this.calculateFuzzyTitleScore(normalizedTitle, normalizedQuery); - this.score += fuzzyScore; - this.fuzzyScore += fuzzyScore; // Track fuzzy score contributions + // 1️⃣ NOTE ID exact match + if (note.noteId.toLowerCase() === normalizedQuery) { + this.score = SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH; + return this.score; } - // Add scores for token matches - this.addScoreForStrings(tokens, note.title, SCORE_WEIGHTS.TITLE_FACTOR, enableFuzzyMatching); - this.addScoreForStrings(tokens, this.notePathTitle, SCORE_WEIGHTS.PATH_FACTOR, enableFuzzyMatching); + // 2️⃣ TITLE deterministic checks + if (title === normalizedQuery) this.score = SCORE_WEIGHTS.TITLE_EXACT_MATCH; + else if (title.startsWith(normalizedQuery)) this.score = SCORE_WEIGHTS.TITLE_PREFIX_MATCH; + else if (title.includes(normalizedQuery)) this.score = SCORE_WEIGHTS.TITLE_WORD_MATCH; + + // 3️⃣ LABEL deterministic checks + let bestLabelValueScore = 0; + let bestLabelKeyScore = 0; + + for (const label of labels) { + const key = label._normalizedKey ??= normalizeSearchText(label.name || ""); + const val = label._normalizedValue ??= normalizeSearchText(label.value || ""); + + if (val === normalizedQuery) + bestLabelValueScore = Math.max(bestLabelValueScore, SCORE_WEIGHTS.LABEL_VALUE_EXACT_MATCH); + else if (val.startsWith(normalizedQuery)) + bestLabelValueScore = Math.max(bestLabelValueScore, SCORE_WEIGHTS.LABEL_VALUE_PREFIX_MATCH); + else if (val.includes(normalizedQuery)) + bestLabelValueScore = Math.max(bestLabelValueScore, SCORE_WEIGHTS.LABEL_VALUE_WORD_MATCH); + + if (key === normalizedQuery) + bestLabelKeyScore = Math.max(bestLabelKeyScore, SCORE_WEIGHTS.LABEL_KEY_EXACT_MATCH); + else if (key.startsWith(normalizedQuery)) + bestLabelKeyScore = Math.max(bestLabelKeyScore, SCORE_WEIGHTS.LABEL_KEY_PREFIX_MATCH); + else if (key.includes(normalizedQuery)) + bestLabelKeyScore = Math.max(bestLabelKeyScore, SCORE_WEIGHTS.LABEL_KEY_WORD_MATCH); + } - if (note.isInHiddenSubtree()) { - this.score = this.score / SCORE_WEIGHTS.HIDDEN_NOTE_PENALTY; + // 4️⃣ TOKEN deterministic checks + let bestTokenScore = 0; + for (const token of tokens) { + const t = preNormalized ? token : normalizeSearchText(token); + if (title === t) bestTokenScore = Math.max(bestTokenScore, SCORE_WEIGHTS.TOKEN_EXACT_MATCH); + else if (title.startsWith(t)) bestTokenScore = Math.max(bestTokenScore, SCORE_WEIGHTS.TOKEN_PREFIX_MATCH); + else if (title.includes(t)) bestTokenScore = Math.max(bestTokenScore, SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH); } - } - addScoreForStrings(tokens: string[], str: string, factor: number, enableFuzzyMatching: boolean = true) { - const normalizedStr = normalizeSearchText(str.toLowerCase()); - const chunks = normalizedStr.split(" "); + this.score = Math.max(this.score, bestLabelValueScore, bestLabelKeyScore, bestTokenScore); + + // 5️⃣ FUZZY phase + if (enableFuzzyMatching) { + const titleTrigrams = note._titleTrigrams ??= getTrigrams(title); + const titlePass = trigramJaccard(queryTrigrams, titleTrigrams) >= 0.25; + + if (titlePass && this.score < SCORE_WEIGHTS.TITLE_PREFIX_MATCH) { + const fuzzyTitleScore = this.fuzzyScoreFor(title, normalizedQuery, SCORE_WEIGHTS.TITLE_FUZZY_MATCH); + this.score = Math.max(this.score, fuzzyTitleScore); + } + + for (const label of labels) { + const key = label._normalizedKey ??= normalizeSearchText(label.name || ""); + const val = label._normalizedValue ??= normalizeSearchText(label.value || ""); + + const keyPass = trigramJaccard(queryTrigrams, getTrigrams(key)) >= 0.25; + const valPass = trigramJaccard(queryTrigrams, getTrigrams(val)) >= 0.25; + + if (valPass) + bestLabelValueScore = Math.max( + bestLabelValueScore, + this.fuzzyScoreFor(val, normalizedQuery, SCORE_WEIGHTS.LABEL_VALUE_FUZZY_MATCH) + ); + if (keyPass) + bestLabelKeyScore = Math.max( + bestLabelKeyScore, + this.fuzzyScoreFor(key, normalizedQuery, SCORE_WEIGHTS.LABEL_KEY_FUZZY_MATCH) + ); + } - let tokenScore = 0; - for (const chunk of chunks) { + // Token fuzzy for (const token of tokens) { - const normalizedToken = normalizeSearchText(token.toLowerCase()); - - if (chunk === normalizedToken) { - tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor; - } else if (chunk.startsWith(normalizedToken)) { - tokenScore += SCORE_WEIGHTS.TOKEN_PREFIX_MATCH * token.length * factor; - } else if (chunk.includes(normalizedToken)) { - tokenScore += SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH * token.length * factor; - } else { - // Try fuzzy matching for individual tokens with caps applied - const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && - normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - - const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - // Apply caps: limit token length multiplier and per-token contribution - const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); - const fuzzyTokenScore = Math.min( - fuzzyWeight * cappedTokenLength * factor, - SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN - ); - - tokenScore += fuzzyTokenScore; - this.fuzzyScore += fuzzyTokenScore; - } - } + const t = preNormalized ? token : normalizeSearchText(token); + const tokenPass = trigramJaccard(queryTrigrams, getTrigrams(t)) >= 0.25; + if (tokenPass) + bestTokenScore = Math.max( + bestTokenScore, + this.fuzzyScoreFor(title, t, SCORE_WEIGHTS.TOKEN_FUZZY_MATCH) + ); } } - this.score += tokenScore; - } + this.score = Math.max(this.score, bestLabelValueScore, bestLabelKeyScore, bestTokenScore); - /** - * Checks if the query matches as a complete word in the text - */ - private isWordMatch(text: string, query: string): boolean { - return text.includes(` ${query} `) || - text.startsWith(`${query} `) || - text.endsWith(` ${query}`); + if (note.isInHiddenSubtree()) this.score /= SCORE_WEIGHTS.HIDDEN_NOTE_PENALTY; + return this.score; } - /** - * Calculates fuzzy matching score for title matches with caps applied - */ - private calculateFuzzyTitleScore(title: string, query: string): number { - // Check if we've already hit the fuzzy scoring cap - if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - return 0; - } - - const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - const maxLen = Math.max(title.length, query.length); - - // Only apply fuzzy matching if the query is reasonably long and edit distance is small - if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && - editDistance / maxLen <= 0.3) { - const similarity = 1 - (editDistance / maxLen); - const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches - - // Apply cap to ensure fuzzy title matches don't exceed reasonable bounds - return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); - } - - return 0; + // ⚙️ Fuzzysort-based scoring + private fuzzyScoreFor(target: string, query: string, baseWeight: number): number { + if (!target || !query || this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) return 0; + const res = fuzzysort.single(query, target); + if (!res) return 0; + + const quality = Math.max(0, 1 - Math.min(Math.abs(res.score) / 1000, 1)); + const rawScore = baseWeight * quality; + const cappedScore = Math.min( + rawScore, + SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN * SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER + ); + + this.fuzzyScore += cappedScore; + return cappedScore; } +} + +export function rankSearchResults ( + notePaths: string[][], + fulltextQuery: string, + tokens: string[], + enableFuzzyMatching = true +): SearchResult[] { + const normalizedQuery = normalizeSearchText(fulltextQuery); + const queryTrigrams = getTrigrams(normalizedQuery); + const normalizedTokens = tokens.map(t => normalizeSearchText(t)); + const results: SearchResult[] = []; + + for (const notePathArray of notePaths) { + const res = new SearchResult(notePathArray); + const note = becca.notes[res.noteId]; + if (!note) continue; + + const title = note._normalizedTitle ??= normalizeSearchText(note.title || ""); + const titleTrigrams = note._titleTrigrams ??= getTrigrams(title); + + // 🔹 quick trigram prefilter to skip clearly irrelevant notes + if (enableFuzzyMatching && trigramJaccard(queryTrigrams, titleTrigrams) < 0.1) continue; + + res.computeScore(fulltextQuery, normalizedTokens, enableFuzzyMatching, true, { + normalizedQuery, + queryTrigrams + }); + results.push(res); + } + + results.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + if (a.notePathArray.length !== b.notePathArray.length) + return a.notePathArray.length - b.notePathArray.length; + return a.notePathTitle.localeCompare(b.notePathTitle); + }); + return results; } export default SearchResult; diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 22dbe6d9fc..500a0e00cb 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -4,7 +4,7 @@ import normalizeString from "normalize-strings"; import lex from "./lex.js"; import handleParens from "./handle_parens.js"; import parse from "./parse.js"; -import SearchResult from "../search_result.js"; +import SearchResult, { rankSearchResults } from "../search_result.js"; import SearchContext from "../search_context.js"; import becca from "../../../becca/becca.js"; import beccaService from "../../../becca/becca_service.js"; @@ -20,6 +20,33 @@ import scriptService from "../../script.js"; import striptags from "striptags"; import protectedSessionService from "../../protected_session.js"; +import NoteSet from "../note_set.js"; + +type CacheValue = { query: string; results: SearchResult[]; searchContext: SearchContext }; + +class SearchCache { + private map = new Map(); + constructor(private maxSize = 100) {} + + set(key: string, value: CacheValue) { + this.map.set(key, value); + if (this.map.size > this.maxSize) { + const oldest = this.map.keys().next().value; + if (oldest) this.map.delete(oldest); + } + } + + get(key: string): CacheValue | undefined { + return this.map.get(key); + } + + clear() { + this.map.clear(); + } +} + +const searchCache = new SearchCache(100); + export interface SearchNoteResult { searchResultNoteIds: string[]; highlightedTokens: string[]; @@ -56,7 +83,7 @@ function searchFromNote(note: BNote): SearchNoteResult { fuzzyAttributeSearch: false }); - searchResultNoteIds = findResultsWithQuery(searchString, searchContext).map((sr) => sr.noteId); + searchResultNoteIds = findResultsWithQueryIncremental(searchString, searchContext).map((sr) => sr.noteId); highlightedTokens = searchContext.highlightedTokens; error = searchContext.getError(); @@ -252,71 +279,118 @@ function findResultsWithExpression(expression: Expression, searchContext: Search // Phase 1: Try exact matches first (without fuzzy matching) const exactResults = performSearch(expression, searchContext, false); - + // Check if we have sufficient high-quality results const minResultThreshold = 5; const minScoreForQuality = 10; // Minimum score to consider a result "high quality" - + const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality); - + // If we have enough high-quality exact matches, return them if (highQualityResults.length >= minResultThreshold) { return exactResults; } - + // Phase 2: Add fuzzy matching as fallback when exact matches are insufficient const fuzzyResults = performSearch(expression, searchContext, true); - + // Merge results, ensuring exact matches always rank higher than fuzzy matches return mergeExactAndFuzzyResults(exactResults, fuzzyResults); } -function performSearch(expression: Expression, searchContext: SearchContext, enableFuzzyMatching: boolean): SearchResult[] { +function performSearch( + expression: Expression, + searchContext: SearchContext, + enableFuzzyMatching: boolean +): SearchResult[] { const allNoteSet = becca.getAllNoteSet(); const noteIdToNotePath: Record = {}; - const executionContext = { - noteIdToNotePath - }; + const executionContext = { noteIdToNotePath }; - // Store original fuzzy setting and temporarily override it + // Preserve and temporarily override fuzzy setting const originalFuzzyMatching = searchContext.enableFuzzyMatching; searchContext.enableFuzzyMatching = enableFuzzyMatching; - const noteSet = expression.execute(allNoteSet, executionContext, searchContext); - - const searchResults = noteSet.notes.map((note) => { - const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath(); + // If ancestorNoteId is provided, limit search to that subtree + let noteSet = expression.execute(allNoteSet, executionContext, searchContext); + if (searchContext.ancestorNoteId && becca.notes[searchContext.ancestorNoteId]) { + const ancestor = becca.notes[searchContext.ancestorNoteId]; + const allowedNoteIds = new Set(ancestor.getSubtreeNoteIds({ + includeArchived: searchContext.includeArchivedNotes ?? true, + includeHidden: searchContext.includeHiddenNotes ?? false + })); + noteSet.notes = noteSet.notes.filter(n => allowedNoteIds.has(n.noteId)); + } - if (!notePathArray) { - throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`); - } + // Main filtering + const searchResults = noteSet.notes + .filter(note => { + if (!searchContext.includeArchivedNotes && note.isArchived) return false; + if (!searchContext.includeHiddenNotes && note.isInHiddenSubtree()) return false; + return true; + }) + .map(note => { + const notePathArray = + executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath(); + + if (!notePathArray) { + throw new Error( + `Can't find note path for note ${JSON.stringify(note.getPojo())}` + ); + } - return new SearchResult(notePathArray); - }); + return new SearchResult(notePathArray); + }); + // Compute scores for (const res of searchResults) { - res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching); + res.computeScore( + searchContext.fulltextQuery, + searchContext.highlightedTokens, + enableFuzzyMatching + ); + + // Optional fine-tuning: disable fuzzy for title if fuzzyAttributeSearch only + if (!enableFuzzyMatching && searchContext.fuzzyAttributeSearch) { + // Re-score attributes only, skip fuzzy on title + res.computeScore( + searchContext.fulltextQuery, + searchContext.highlightedTokens, + true // fuzzy for attributes + ); + } + + // If ignoring internal attributes, reduce their weight + if (searchContext.ignoreInternalAttributes) { + const note = becca.notes[res.noteId]; + const internalAttrs = note.getAttributes()?.filter(a => a.name.startsWith("_")) || []; + if (internalAttrs.length > 0) { + const penalty = 1 + internalAttrs.length * 0.05; + res.score = res.score / penalty; + } + } } - // Restore original fuzzy setting + // Restore fuzzy flag searchContext.enableFuzzyMatching = originalFuzzyMatching; + // Optional fast-search mode (e.g. autocomplete) + if (searchContext.fastSearch) { + // Skip fuzzy rescoring & heavy sorting logic + return searchResults + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 50); + } + + // Normal sorting if (!noteSet.sorted) { searchResults.sort((a, b) => { - if (a.score > b.score) { - return -1; - } else if (a.score < b.score) { - return 1; - } - - // if score does not decide then sort results by depth of the note. - // This is based on the assumption that more important results are closer to the note root. - if (a.notePathArray.length === b.notePathArray.length) { - return a.notePathTitle < b.notePathTitle ? -1 : 1; - } - - return a.notePathArray.length < b.notePathArray.length ? -1 : 1; + if (a.score !== b.score) return b.score - a.score; + if (a.notePathArray.length !== b.notePathArray.length) + return a.notePathArray.length - b.notePathArray.length; + return a.notePathTitle.localeCompare(b.notePathTitle); }); } @@ -326,10 +400,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] { // Create a map of exact result note IDs for deduplication const exactNoteIds = new Set(exactResults.map(result => result.noteId)); - + // Add fuzzy results that aren't already in exact results const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId)); - + // Sort exact results by score (best exact matches first) exactResults.sort((a, b) => { if (a.score > b.score) { @@ -345,7 +419,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // Sort fuzzy results by score (best fuzzy matches first) additionalFuzzyResults.sort((a, b) => { if (a.score > b.score) { @@ -361,7 +435,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // CRITICAL: Always put exact matches before fuzzy matches, regardless of scores return [...exactResults, ...additionalFuzzyResults]; } @@ -417,10 +491,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear } // If the query starts with '#', it's a pure expression query. - // Don't use progressive search for these as they may have complex + // Don't use progressive search for these as they may have complex // ordering or other logic that shouldn't be interfered with. const isPureExpressionQuery = query.trim().startsWith('#'); - + if (isPureExpressionQuery) { // For pure expression queries, use standard search without progressive phases return performSearch(expression, searchContext, searchContext.enableFuzzyMatching); @@ -429,6 +503,99 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear return findResultsWithExpression(expression, searchContext); } +function findResultsWithQueryIncremental( + query: string, + searchContext: SearchContext +): SearchResult[] { + query = query || ""; + const lowerQuery = query.toLowerCase(); + searchContext.originalQuery = query; + + // Immediate cache hit + const cachedHit = searchCache.get(lowerQuery); + if (cachedHit) { + return cachedHit.results; + } + + // Find the longest cached prefix + let prefix: string | undefined; + for (const key of (searchCache as any).map.keys()) { + if (lowerQuery.startsWith(key)) { + if (!prefix || key.length > prefix.length) { + prefix = key; + } + } + } + + const expression = parseQueryToExpression(query, searchContext); + if (!expression) { + searchCache.set(lowerQuery, { query, results: [], searchContext }); + return []; + } + + const isPureExpressionQuery = query.trim().startsWith("#"); + let results: SearchResult[]; + + if (isPureExpressionQuery) { + // Expression queries — use normal full search + results = performSearch(expression, searchContext, searchContext.enableFuzzyMatching); + } + // First character or no cached prefix → full expression search + else if (!prefix) { + results = findResultsWithExpression(expression, searchContext); + } + // Later keystrokes: reuse cached prefix results + else { + const cachedPrefix = searchCache.get(prefix); + if (cachedPrefix) { + const candidateNotes = cachedPrefix.results + .map(r => becca.notes[r.noteId]) + .filter(Boolean); + const candidateSet = new NoteSet(candidateNotes); + + const executionContext = { noteIdToNotePath: {} }; + const noteSet = expression.execute(candidateSet, executionContext, searchContext); + + const filteredNotes = noteSet.notes.filter(note => { + if (!searchContext.includeArchivedNotes && note.isArchived) return false; + if (!searchContext.includeHiddenNotes && note.isInHiddenSubtree()) return false; + return true; + }); + + const notePaths = filteredNotes.map(note => { + const notePathArray = + executionContext.noteIdToNotePath[note.noteId] || + becca.notes[note.noteId]?.getBestNotePath() || + note.getBestNotePath(); + if (!notePathArray) { + throw new Error(`Can't find note path for note ${note.noteId}`); + } + return notePathArray; + }); + + results = rankSearchResults( + notePaths, + searchContext.fulltextQuery, + searchContext.highlightedTokens, + searchContext.enableFuzzyMatching + ); + } else { + results = findResultsWithExpression(expression, searchContext); + } + } + + // Cache results and prune supersets + searchCache.set(lowerQuery, { query, results, searchContext }); + + for (const k of (searchCache as any).map.keys()) { + if (k.length > lowerQuery.length && k.startsWith(lowerQuery)) { + (searchCache as any).map.delete(k); + } + } + + return results; +} + function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null { const searchResults = findResultsWithQuery(query, searchContext); @@ -448,7 +615,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength try { let content = note.getContent(); - + if (!content || typeof content !== "string") { return ""; } @@ -489,7 +656,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength for (const token of searchTokens) { const normalizedToken = normalizeString(token.toLowerCase()); const matchIndex = normalizedContent.indexOf(normalizedToken); - + if (matchIndex !== -1) { // Center the snippet around the match snippetStart = Math.max(0, matchIndex - maxLength / 2); @@ -500,7 +667,7 @@ 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) { @@ -523,7 +690,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength } snippet = "..." + snippet; } - + if (snippetStart + maxLength < content.length) { const lastSpace = snippet.search(/\s[^\s]*$/); if (lastSpace > snippet.length - 20 && lastSpace > 0) { @@ -554,19 +721,19 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng } let matchingAttributes: Array<{name: string, value: string, type: string}> = []; - + // Look for attributes that match the search tokens for (const attr of attributes) { const attrName = attr.name?.toLowerCase() || ""; const attrValue = attr.value?.toLowerCase() || ""; const attrType = attr.type || ""; - + // Check if any search token matches the attribute name or value const hasMatch = searchTokens.some(token => { const normalizedToken = normalizeString(token.toLowerCase()); return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); }); - + if (hasMatch) { matchingAttributes.push({ name: attr.name || "", @@ -592,20 +759,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng const targetTitle = targetNote ? targetNote.title : attr.value; line = `~${attr.name}="${targetTitle}"`; } - + if (line) { lines.push(line); } } let snippet = lines.join('\n'); - + // Apply length limit while preserving line structure if (snippet.length > maxLength) { // Try to truncate at word boundaries but keep lines intact const truncated = snippet.substring(0, maxLength); const lastNewline = truncated.lastIndexOf('\n'); - + if (lastNewline > maxLength / 2) { // If we can keep most content by truncating to last complete line snippet = truncated.substring(0, lastNewline); @@ -649,6 +816,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { return trimmed.map((result) => { const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); return { + noteId: result.noteId, + highlightedNoteId: result.highlightedNoteId, notePath: result.notePath, noteTitle: title, notePathTitle: result.notePathTitle, @@ -668,32 +837,24 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { function highlightSearchResults(searchResults: SearchResult[], highlightedTokens: string[], ignoreInternalAttributes = false) { highlightedTokens = Array.from(new Set(highlightedTokens)); - // we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks - // which would make the resulting HTML string invalid. - // { and } are used for marking and tag (to avoid matches on single 'b' character) - // < and > are used for marking and - highlightedTokens = highlightedTokens.map((token) => token.replace("/[<\{\}]/g", "")).filter((token) => !!token?.trim()); + // Clean invalid characters that can break highlighting + highlightedTokens = highlightedTokens + .map((token) => token.replace(/[<{}]/g, "")) + .filter((token) => !!token?.trim()); - // sort by the longest, so we first highlight the longest matches + // Sort by longest first to avoid partial overlaps highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1)); for (const result of searchResults) { result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - - // Initialize highlighted content snippet + + // Escape and clean snippets if (result.contentSnippet) { - // Escape HTML but preserve newlines for later conversion to
- result.highlightedContentSnippet = escapeHtml(result.contentSnippet); - // Remove any stray < { } that might interfere with our highlighting markers - result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); + result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, ""); } - - // Initialize highlighted attribute snippet + if (result.attributeSnippet) { - // Escape HTML but preserve newlines for later conversion to
- result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet); - // Remove any stray < { } that might interfere with our highlighting markers - result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, ""); + result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet).replace(/[<{}]/g, ""); } } @@ -702,13 +863,9 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens } for (const token of highlightedTokens) { - if (!token) { - // Avoid empty tokens, which might cause an infinite loop. - continue; - } + if (!token) continue; // skip empty tokens for (const result of searchResults) { - // Reset token const tokenRegex = new RegExp(escapeRegExp(token), "gi"); let match; @@ -717,8 +874,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens const titleRegex = new RegExp(escapeRegExp(token), "gi"); while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) { result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}"); - // 2 characters are added, so we need to adjust the index - titleRegex.lastIndex += 2; + titleRegex.lastIndex += 2; // adjust for added markers } } @@ -727,7 +883,6 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens const contentRegex = new RegExp(escapeRegExp(token), "gi"); while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) { result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}"); - // 2 characters are added, so we need to adjust the index contentRegex.lastIndex += 2; } } @@ -737,10 +892,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens const attributeRegex = new RegExp(escapeRegExp(token), "gi"); while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) { result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}"); - // 2 characters are added, so we need to adjust the index attributeRegex.lastIndex += 2; } } + + // ✅ NEW: highlight noteId on exact match + if (token.toLowerCase() === result.noteId.toLowerCase()) { + result.highlightedNoteId = `${escapeHtml(result.noteId)}`; + } } } @@ -748,19 +907,27 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens if (result.highlightedNotePathTitle) { result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "").replace(/}/g, ""); } - + if (result.highlightedContentSnippet) { - // Replace highlighting markers with HTML tags - result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, ""); - // Convert newlines to
tags for HTML display - result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "
"); + result.highlightedContentSnippet = result.highlightedContentSnippet + .replace(/{/g, "") + .replace(/}/g, "") + .replace(/\n/g, "
"); } - + if (result.highlightedAttributeSnippet) { - // Replace highlighting markers with HTML tags - result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "").replace(/}/g, ""); - // Convert newlines to
tags for HTML display - result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "
"); + result.highlightedAttributeSnippet = result.highlightedAttributeSnippet + .replace(/{/g, "") + .replace(/}/g, "") + .replace(/\n/g, "
"); + } + + // We only want to highlight note ID when it is a full match to avoid + // distraction + if (!result.highlightedNoteId || + result.highlightedNoteId === escapeHtml(result.noteId) + ) { + delete result.highlightedNoteId; } } } @@ -769,6 +936,7 @@ export default { searchFromNote, searchNotesForAutocomplete, findResultsWithQuery, + findResultsWithQueryIncremental, findFirstNoteWithQuery, searchNotes }; diff --git a/package.json b/package.json index fe9a3511cd..1c57642ea8 100644 --- a/package.json +++ b/package.json @@ -120,5 +120,8 @@ "macos-alias", "utf-8-validate" ] + }, + "dependencies": { + "fuzzysort": "3.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a7c53b5ec..754bce3465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,10 @@ patchedDependencies: importers: .: + dependencies: + fuzzysort: + specifier: 3.1.0 + version: 3.1.0 devDependencies: '@electron/rebuild': specifier: 4.0.1