+
${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