diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 3c23eaea5..4ffcaa4ab 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -23,12 +23,21 @@ const TPL = /*html*/` .quick-search .dropdown-menu { max-height: 600px; - max-width: 400px; + max-width: 600px; overflow-y: auto; overflow-x: hidden; text-overflow: ellipsis; box-shadow: -30px 50px 93px -50px black; } + + .quick-search .dropdown-item { + white-space: normal; + padding: 8px 16px; + } + + .quick-search .dropdown-item:hover { + background-color: #f8f9fa; + }
@@ -50,6 +59,8 @@ interface QuickSearchResponse { noteTitle: string; notePathTitle: string; highlightedNotePathTitle: string; + contentSnippet?: string; + highlightedContentSnippet?: string; icon: string; }>; error: string; @@ -151,7 +162,16 @@ export default class QuickSearchWidget extends BasicWidget { if (!noteId) continue; const $item = $(''); - $item.html(` ${result.highlightedNotePathTitle}`); + + // Build the display HTML + let itemHtml = `
${result.highlightedNotePathTitle}
`; + + // Add content snippet if available + if (result.highlightedContentSnippet) { + itemHtml += `
${result.highlightedContentSnippet}
`; + } + + $item.html(itemHtml); $item.on("click", (e) => { this.dropdown.hide(); diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index dc3460a60..88773584d 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -29,6 +29,8 @@ class SearchResult { score: number; notePathTitle: string; highlightedNotePathTitle?: string; + contentSnippet?: string; + highlightedContentSnippet?: string; constructor(notePathArray: string[]) { this.notePathArray = notePathArray; diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 7c53546e7..de3415744 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -17,6 +17,8 @@ import type { SearchParams, TokenStructure } from "./types.js"; import type Expression from "../expressions/expression.js"; import sql from "../../sql.js"; import scriptService from "../../script.js"; +import striptags from "striptags"; +import protectedSessionService from "../../protected_session.js"; export interface SearchNoteResult { searchResultNoteIds: string[]; @@ -337,6 +339,91 @@ function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BN return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null; } +function extractContentSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string { + const note = becca.notes[noteId]; + if (!note) { + return ""; + } + + // Only extract content for text-based notes + if (!["text", "code", "mermaid", "canvas", "mindMap"].includes(note.type)) { + return ""; + } + + try { + let content = note.getContent(); + + if (!content || typeof content !== "string") { + return ""; + } + + // Handle protected notes + if (note.isProtected && protectedSessionService.isProtectedSessionAvailable()) { + try { + content = protectedSessionService.decryptString(content) || ""; + } catch (e) { + return ""; // Can't decrypt, don't show content + } + } else if (note.isProtected) { + return ""; // Protected but no session available + } + + // Strip HTML tags for text notes + if (note.type === "text") { + content = striptags(content); + } + + // Normalize whitespace + content = content.replace(/\s+/g, " ").trim(); + + if (!content) { + return ""; + } + + // Try to find a snippet around the first matching token + const normalizedContent = normalizeString(content.toLowerCase()); + let snippetStart = 0; + let matchFound = false; + + for (const token of searchTokens) { + const normalizedToken = normalizeString(token.toLowerCase()); + const matchIndex = normalizedContent.indexOf(normalizedToken); + + if (matchIndex !== -1) { + // Center the snippet around the match + snippetStart = Math.max(0, matchIndex - maxLength / 2); + matchFound = true; + break; + } + } + + // Extract snippet + let snippet = content.substring(snippetStart, snippetStart + maxLength); + + // Try to start/end at word boundaries + if (snippetStart > 0) { + const firstSpace = snippet.indexOf(" "); + if (firstSpace > 0 && firstSpace < 20) { + snippet = snippet.substring(firstSpace + 1); + } + snippet = "..." + snippet; + } + + if (snippetStart + maxLength < content.length) { + const lastSpace = snippet.lastIndexOf(" "); + if (lastSpace > snippet.length - 20) { + snippet = snippet.substring(0, lastSpace); + } + snippet = snippet + "..."; + } + + return snippet; + } catch (e) { + log.error(`Error extracting content snippet for note ${noteId}: ${e}`); + return ""; + } +} + function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const searchContext = new SearchContext({ fastSearch: fastSearch, @@ -351,6 +438,11 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const trimmed = allSearchResults.slice(0, 200); + // Extract content snippets + for (const result of trimmed) { + result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens); + } + highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); return trimmed.map((result) => { @@ -360,6 +452,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { noteTitle: title, notePathTitle: result.notePathTitle, highlightedNotePathTitle: result.highlightedNotePathTitle, + contentSnippet: result.contentSnippet, + highlightedContentSnippet: result.highlightedContentSnippet, icon: icon ?? "bx bx-note" }; }); @@ -381,26 +475,11 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1)); for (const result of searchResults) { - const note = becca.notes[result.noteId]; - result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - - if (highlightedTokens.find((token) => note.type.includes(token))) { - result.highlightedNotePathTitle += ` "type: ${note.type}'`; - } - - if (highlightedTokens.find((token) => note.mime.includes(token))) { - result.highlightedNotePathTitle += ` "mime: ${note.mime}'`; - } - - for (const attr of note.getAttributes()) { - if (attr.type === "relation" && attr.name === "internalLink" && ignoreInternalAttributes) { - continue; - } - - if (highlightedTokens.find((token) => normalize(attr.name).includes(token) || normalize(attr.value).includes(token))) { - result.highlightedNotePathTitle += ` "${formatAttribute(attr)}'`; - } + + // Initialize highlighted content snippet + if (result.contentSnippet) { + result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, ""); } } @@ -419,40 +498,36 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens const tokenRegex = new RegExp(escapeRegExp(token), "gi"); let match; - // Find all matches - if (!result.highlightedNotePathTitle) { - continue; + // Highlight in note path title + if (result.highlightedNotePathTitle) { + const titleRegex = new RegExp(escapeRegExp(token), "gi"); + while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) { + result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}"); + // 2 characters are added, so we need to adjust the index + titleRegex.lastIndex += 2; + } } - while ((match = tokenRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) { - result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}"); - // 2 characters are added, so we need to adjust the index - tokenRegex.lastIndex += 2; + // Highlight in content snippet + if (result.highlightedContentSnippet) { + const contentRegex = new RegExp(escapeRegExp(token), "gi"); + while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) { + result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}"); + // 2 characters are added, so we need to adjust the index + contentRegex.lastIndex += 2; + } } } } for (const result of searchResults) { - if (!result.highlightedNotePathTitle) { - continue; + if (result.highlightedNotePathTitle) { + result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "").replace(/}/g, ""); } - result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/"/g, "").replace(/'/g, "").replace(/{/g, "").replace(/}/g, ""); - } -} - -function formatAttribute(attr: BAttribute) { - if (attr.type === "relation") { - return `~${escapeHtml(attr.name)}=…`; - } else if (attr.type === "label") { - let label = `#${escapeHtml(attr.name)}`; - - if (attr.value) { - const val = /[^\w-]/.test(attr.value) ? `"${attr.value}"` : attr.value; - - label += `=${escapeHtml(val)}`; + + if (result.highlightedContentSnippet) { + result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, ""); } - - return label; } }