From c603783a448d135c3b671483b6df4bd327ce60ef Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 3 Aug 2025 19:30:35 +0000 Subject: [PATCH] feat(quick_search): show the "matched" text in the search results, even if "edit distance" (misspelling) occurs --- apps/client/src/widgets/quick_search.ts | 31 ++++++++++-- .../search/expressions/note_flat_text.ts | 24 ++++++--- .../src/services/search/utils/text_utils.ts | 50 ++++++++++++++----- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 4ffcaa4ab..43f0afe81 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -32,12 +32,30 @@ const TPL = /*html*/` .quick-search .dropdown-item { white-space: normal; - padding: 8px 16px; + padding: 12px 16px; + line-height: 1.4; + border-bottom: 1px solid #dee2e6 !important; + } + + .quick-search .dropdown-item:last-child { + border-bottom: none !important; + } + + .quick-search .dropdown-item.disabled { + border-bottom: 1px solid #f8f9fa !important; + } + + .quick-search .dropdown-divider + .dropdown-item { + border-top: none !important; } .quick-search .dropdown-item:hover { background-color: #f8f9fa; } + + .quick-search .dropdown-divider { + margin: 0; + }
@@ -163,14 +181,17 @@ export default class QuickSearchWidget extends BasicWidget { const $item = $(''); - // Build the display HTML - let itemHtml = `
${result.highlightedNotePathTitle}
`; + // Build the display HTML with content snippet below the title + let itemHtml = `
+
${result.highlightedNotePathTitle}
`; - // Add content snippet if available + // Add content snippet below the title if available if (result.highlightedContentSnippet) { - itemHtml += `
${result.highlightedContentSnippet}
`; + itemHtml += `
${result.highlightedContentSnippet}
`; } + itemHtml += `
`; + $item.html(itemHtml); $item.on("click", (e) => { diff --git a/apps/server/src/services/search/expressions/note_flat_text.ts b/apps/server/src/services/search/expressions/note_flat_text.ts index 1fb6487d2..d77ac06a9 100644 --- a/apps/server/src/services/search/expressions/note_flat_text.ts +++ b/apps/server/src/services/search/expressions/note_flat_text.ts @@ -7,7 +7,7 @@ import Expression from "./expression.js"; import NoteSet from "../note_set.js"; import becca from "../../../becca/becca.js"; import { normalize } from "../../utils.js"; -import { normalizeSearchText, fuzzyMatchWord } from "../utils/text_utils.js"; +import { normalizeSearchText, fuzzyMatchWord, fuzzyMatchWordWithResult } from "../utils/text_utils.js"; import beccaService from "../../../becca/becca_service.js"; class NoteFlatTextExp extends Expression { @@ -78,7 +78,7 @@ class NoteFlatTextExp extends Expression { const foundTokens: string[] = foundAttrTokens.slice(); for (const token of remainingTokens) { - if (this.smartMatch(title, token)) { + if (this.smartMatch(title, token, searchContext)) { foundTokens.push(token); } } @@ -93,7 +93,7 @@ class NoteFlatTextExp extends Expression { } }; - const candidateNotes = this.getCandidateNotes(inputNoteSet); + const candidateNotes = this.getCandidateNotes(inputNoteSet, searchContext); for (const note of candidateNotes) { // autocomplete should be able to find notes by their noteIds as well (only leafs) @@ -121,7 +121,7 @@ class NoteFlatTextExp extends Expression { const foundTokens = foundAttrTokens.slice(); for (const token of this.tokens) { - if (this.smartMatch(title, token)) { + if (this.smartMatch(title, token, searchContext)) { foundTokens.push(token); } } @@ -154,13 +154,13 @@ class NoteFlatTextExp extends Expression { /** * Returns noteIds which have at least one matching tokens */ - getCandidateNotes(noteSet: NoteSet): BNote[] { + getCandidateNotes(noteSet: NoteSet, searchContext?: SearchContext): BNote[] { const candidateNotes: BNote[] = []; for (const note of noteSet.notes) { const normalizedFlatText = normalizeSearchText(note.getFlatText()); for (const token of this.tokens) { - if (this.smartMatch(normalizedFlatText, token)) { + if (this.smartMatch(normalizedFlatText, token, searchContext)) { candidateNotes.push(note); break; } @@ -174,9 +174,10 @@ class NoteFlatTextExp extends Expression { * Smart matching that tries exact match first, then fuzzy fallback * @param text The text to search in * @param token The token to search for + * @param searchContext The search context to track matched words for highlighting * @returns True if match found (exact or fuzzy) */ - private smartMatch(text: string, token: string): boolean { + private smartMatch(text: string, token: string, searchContext?: SearchContext): boolean { // Exact match has priority if (text.includes(token)) { return true; @@ -184,7 +185,14 @@ class NoteFlatTextExp extends Expression { // Fuzzy fallback only for tokens >= 4 characters if (token.length >= 4) { - return fuzzyMatchWord(token, text); + const matchedWord = fuzzyMatchWordWithResult(token, text); + if (matchedWord) { + // Track the fuzzy matched word for highlighting + if (searchContext && !searchContext.highlightedTokens.includes(matchedWord)) { + searchContext.highlightedTokens.push(matchedWord); + } + return true; + } } return false; diff --git a/apps/server/src/services/search/utils/text_utils.ts b/apps/server/src/services/search/utils/text_utils.ts index bbaadb58e..9274241cb 100644 --- a/apps/server/src/services/search/utils/text_utils.ts +++ b/apps/server/src/services/search/utils/text_utils.ts @@ -249,22 +249,29 @@ export function validateAndPreprocessContent(content: string, noteId?: string): } /** - * Checks if a word matches a token with fuzzy matching. + * Escapes special regex characters in a string for use in RegExp constructor + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Checks if a word matches a token with fuzzy matching and returns the matched word. * Optimized for common case where distances are small. * * @param token The search token (should be normalized) - * @param word The word to match against (should be normalized) + * @param text The text to match against (should be normalized) * @param maxDistance Maximum allowed edit distance - * @returns True if the word matches the token within the distance threshold + * @returns The matched word if found, null otherwise */ -export function fuzzyMatchWord(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): boolean { +export function fuzzyMatchWordWithResult(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): string | null { // Input validation if (typeof token !== 'string' || typeof text !== 'string') { - return false; + return null; } if (token.length === 0 || text.length === 0) { - return false; + return null; } try { @@ -274,14 +281,20 @@ export function fuzzyMatchWord(token: string, text: string, maxDistance: number // Exact match check first (most common case) if (normalizedText.includes(normalizedToken)) { - return true; + // Find the exact match in the original text to preserve case + const exactMatch = text.match(new RegExp(escapeRegExp(token), 'i')); + return exactMatch ? exactMatch[0] : token; } // For fuzzy matching, we need to check individual words in the text // Split the text into words and check each word against the token const words = normalizedText.split(/\s+/).filter(word => word.length > 0); + const originalWords = text.split(/\s+/).filter(word => word.length > 0); - for (const word of words) { + for (let i = 0; i < words.length; i++) { + const word = words[i]; + const originalWord = originalWords[i]; + // Skip if word is too different in length for fuzzy matching if (Math.abs(word.length - normalizedToken.length) > maxDistance) { continue; @@ -295,14 +308,27 @@ export function fuzzyMatchWord(token: string, text: string, maxDistance: number // Use optimized edit distance calculation const distance = calculateOptimizedEditDistance(normalizedToken, word, maxDistance); if (distance <= maxDistance) { - return true; + return originalWord; // Return the original word with case preserved } } - return false; + return null; } catch (error) { - // Log error and return false for safety + // Log error and return null for safety console.warn('Error in fuzzy word matching:', error); - return false; + return null; } +} + +/** + * Checks if a word matches a token with fuzzy matching. + * Optimized for common case where distances are small. + * + * @param token The search token (should be normalized) + * @param word The word to match against (should be normalized) + * @param maxDistance Maximum allowed edit distance + * @returns True if the word matches the token within the distance threshold + */ +export function fuzzyMatchWord(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): boolean { + return fuzzyMatchWordWithResult(token, text, maxDistance) !== null; } \ No newline at end of file