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 {
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;
}
</style>
<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:">');
// Build the display HTML
let itemHtml = `<div><span class="${result.icon}"></span> ${result.highlightedNotePathTitle}</div>`;
// Build the display HTML with content snippet below the title
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) {
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.on("click", (e) => {

View File

@ -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;

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.
*
* @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;
}