feat(quick_search): show the "matched" text in the search results, even if "edit distance" (misspelling) occurs

This commit is contained in:
perf3ct 2025-08-03 19:30:35 +00:00
parent 1928356ad5
commit c603783a44
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
3 changed files with 80 additions and 25 deletions

View File

@ -32,12 +32,30 @@ const TPL = /*html*/`
.quick-search .dropdown-item { .quick-search .dropdown-item {
white-space: normal; 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 { .quick-search .dropdown-item:hover {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
.quick-search .dropdown-divider {
margin: 0;
}
</style> </style>
<div class="input-group-prepend"> <div class="input-group-prepend">
@ -163,14 +181,17 @@ export default class QuickSearchWidget extends BasicWidget {
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">'); const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
// Build the display HTML // Build the display HTML with content snippet below the title
let itemHtml = `<div><span class="${result.icon}"></span> ${result.highlightedNotePathTitle}</div>`; let itemHtml = `<div style="display: flex; flex-direction: column;">
<div><span class="${result.icon}"></span> ${result.highlightedNotePathTitle}</div>`;
// Add content snippet if available // Add content snippet below the title if available
if (result.highlightedContentSnippet) { if (result.highlightedContentSnippet) {
itemHtml += `<div style="font-size: 0.85em; color: #666; margin-left: 20px; margin-top: 2px;">${result.highlightedContentSnippet}</div>`; itemHtml += `<div style="font-size: 0.85em; color: #666; margin-left: 20px; margin-top: 4px;">${result.highlightedContentSnippet}</div>`;
} }
itemHtml += `</div>`;
$item.html(itemHtml); $item.html(itemHtml);
$item.on("click", (e) => { $item.on("click", (e) => {

View File

@ -7,7 +7,7 @@ import Expression from "./expression.js";
import NoteSet from "../note_set.js"; import NoteSet from "../note_set.js";
import becca from "../../../becca/becca.js"; import becca from "../../../becca/becca.js";
import { normalize } from "../../utils.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"; import beccaService from "../../../becca/becca_service.js";
class NoteFlatTextExp extends Expression { class NoteFlatTextExp extends Expression {
@ -78,7 +78,7 @@ class NoteFlatTextExp extends Expression {
const foundTokens: string[] = foundAttrTokens.slice(); const foundTokens: string[] = foundAttrTokens.slice();
for (const token of remainingTokens) { for (const token of remainingTokens) {
if (this.smartMatch(title, token)) { if (this.smartMatch(title, token, searchContext)) {
foundTokens.push(token); 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) { for (const note of candidateNotes) {
// autocomplete should be able to find notes by their noteIds as well (only leafs) // 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(); const foundTokens = foundAttrTokens.slice();
for (const token of this.tokens) { for (const token of this.tokens) {
if (this.smartMatch(title, token)) { if (this.smartMatch(title, token, searchContext)) {
foundTokens.push(token); foundTokens.push(token);
} }
} }
@ -154,13 +154,13 @@ class NoteFlatTextExp extends Expression {
/** /**
* Returns noteIds which have at least one matching tokens * Returns noteIds which have at least one matching tokens
*/ */
getCandidateNotes(noteSet: NoteSet): BNote[] { getCandidateNotes(noteSet: NoteSet, searchContext?: SearchContext): BNote[] {
const candidateNotes: BNote[] = []; const candidateNotes: BNote[] = [];
for (const note of noteSet.notes) { for (const note of noteSet.notes) {
const normalizedFlatText = normalizeSearchText(note.getFlatText()); const normalizedFlatText = normalizeSearchText(note.getFlatText());
for (const token of this.tokens) { for (const token of this.tokens) {
if (this.smartMatch(normalizedFlatText, token)) { if (this.smartMatch(normalizedFlatText, token, searchContext)) {
candidateNotes.push(note); candidateNotes.push(note);
break; break;
} }
@ -174,9 +174,10 @@ class NoteFlatTextExp extends Expression {
* Smart matching that tries exact match first, then fuzzy fallback * Smart matching that tries exact match first, then fuzzy fallback
* @param text The text to search in * @param text The text to search in
* @param token The token to search for * @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) * @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 // Exact match has priority
if (text.includes(token)) { if (text.includes(token)) {
return true; return true;
@ -184,7 +185,14 @@ class NoteFlatTextExp extends Expression {
// Fuzzy fallback only for tokens >= 4 characters // Fuzzy fallback only for tokens >= 4 characters
if (token.length >= 4) { 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; return false;

View File

@ -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. * Optimized for common case where distances are small.
* *
* @param token The search token (should be normalized) * @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 * @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 // Input validation
if (typeof token !== 'string' || typeof text !== 'string') { if (typeof token !== 'string' || typeof text !== 'string') {
return false; return null;
} }
if (token.length === 0 || text.length === 0) { if (token.length === 0 || text.length === 0) {
return false; return null;
} }
try { try {
@ -274,14 +281,20 @@ export function fuzzyMatchWord(token: string, text: string, maxDistance: number
// Exact match check first (most common case) // Exact match check first (most common case)
if (normalizedText.includes(normalizedToken)) { 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 // For fuzzy matching, we need to check individual words in the text
// Split the text into words and check each word against the token // Split the text into words and check each word against the token
const words = normalizedText.split(/\s+/).filter(word => word.length > 0); const words = normalizedText.split(/\s+/).filter(word => word.length > 0);
const originalWords = text.split(/\s+/).filter(word => word.length > 0);
for (let i = 0; i < words.length; i++) {
const word = words[i];
const originalWord = originalWords[i];
for (const word of words) {
// Skip if word is too different in length for fuzzy matching // Skip if word is too different in length for fuzzy matching
if (Math.abs(word.length - normalizedToken.length) > maxDistance) { if (Math.abs(word.length - normalizedToken.length) > maxDistance) {
continue; continue;
@ -295,14 +308,27 @@ export function fuzzyMatchWord(token: string, text: string, maxDistance: number
// Use optimized edit distance calculation // Use optimized edit distance calculation
const distance = calculateOptimizedEditDistance(normalizedToken, word, maxDistance); const distance = calculateOptimizedEditDistance(normalizedToken, word, maxDistance);
if (distance <= maxDistance) { if (distance <= maxDistance) {
return true; return originalWord; // Return the original word with case preserved
} }
} }
return false; return null;
} catch (error) { } catch (error) {
// Log error and return false for safety // Log error and return null for safety
console.warn('Error in fuzzy word matching:', error); 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;
}