mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 07:08:55 +02:00
feat(quick_search): show the "matched" text in the search results, even if "edit distance" (misspelling) occurs
This commit is contained in:
parent
1928356ad5
commit
c603783a44
@ -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) => {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user