From 8e227a61460d5dbbc0853d669878a8bd7644d468 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 21 Oct 2025 14:35:31 -0700 Subject: [PATCH] fix(search): make sure to highlight exact search results too --- apps/server/src/routes/api/search.ts | 40 ++++++++++++++++--- .../expressions/note_content_fulltext.ts | 7 ++++ .../src/services/search/services/search.ts | 38 ++++++++++++++---- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts index 29d75c6dc..cbd584529 100644 --- a/apps/server/src/routes/api/search.ts +++ b/apps/server/src/routes/api/search.ts @@ -10,6 +10,8 @@ import cls from "../../services/cls.js"; import attributeFormatter from "../../services/attribute_formatter.js"; import ValidationError from "../../errors/validation_error.js"; import type SearchResult from "../../services/search/search_result.js"; +import hoistedNoteService from "../../services/hoisted_note.js"; +import beccaService from "../../becca/becca_service.js"; function searchFromNote(req: Request): SearchNoteResult { const note = becca.getNoteOrThrow(req.params.noteId); @@ -49,13 +51,41 @@ function quickSearch(req: Request) { const searchContext = new SearchContext({ fastSearch: false, includeArchivedNotes: false, - fuzzyAttributeSearch: false + includeHiddenNotes: true, + fuzzyAttributeSearch: true, + ignoreInternalAttributes: true, + ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId() + }); + + // Execute search with our context + const allSearchResults = searchService.findResultsWithQuery(searchString, searchContext); + const trimmed = allSearchResults.slice(0, 200); + + // Extract snippets using highlightedTokens from our context + for (const result of trimmed) { + result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens); + result.attributeSnippet = searchService.extractAttributeSnippet(result.noteId, searchContext.highlightedTokens); + } + + // Highlight the results + searchService.highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); + + // Map to API format + const searchResults = trimmed.map((result) => { + const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); + return { + notePath: result.notePath, + noteTitle: title, + notePathTitle: result.notePathTitle, + highlightedNotePathTitle: result.highlightedNotePathTitle, + contentSnippet: result.contentSnippet, + highlightedContentSnippet: result.highlightedContentSnippet, + attributeSnippet: result.attributeSnippet, + highlightedAttributeSnippet: result.highlightedAttributeSnippet, + icon: icon + }; }); - // Use the same highlighting logic as autocomplete for consistency - const searchResults = searchService.searchNotesForAutocomplete(searchString, false); - - // Extract note IDs for backward compatibility const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[]; return { diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index cf68f6e23..d459bdaf7 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -75,6 +75,13 @@ class NoteContentFulltextExp extends Expression { return inputNoteSet; } + // Add tokens to highlightedTokens so snippet extraction knows what to look for + for (const token of this.tokens) { + if (!searchContext.highlightedTokens.includes(token)) { + searchContext.highlightedTokens.push(token); + } + } + const resultNoteSet = new NoteSet(); // Search through notes with content diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 22dbe6d9f..5ca4bda4a 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -500,19 +500,38 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength // Extract snippet let snippet = content.substring(snippetStart, snippetStart + maxLength); - + // If snippet contains linebreaks, limit to max 4 lines and override character limit const lines = snippet.split('\n'); if (lines.length > 4) { - snippet = lines.slice(0, 4).join('\n'); + // Find which lines contain the search tokens to ensure they're included + const normalizedLines = lines.map(line => normalizeString(line.toLowerCase())); + const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase())); + + // Find the first line that contains a search token + let firstMatchLine = -1; + for (let i = 0; i < normalizedLines.length; i++) { + if (normalizedTokens.some(token => normalizedLines[i].includes(token))) { + firstMatchLine = i; + break; + } + } + + if (firstMatchLine !== -1) { + // Center the 4-line window around the first match + // Try to show 1 line before and 2 lines after the match + const startLine = Math.max(0, firstMatchLine - 1); + const endLine = Math.min(lines.length, startLine + 4); + snippet = lines.slice(startLine, endLine).join('\n'); + } else { + // No match found in lines (shouldn't happen), just take first 4 + snippet = lines.slice(0, 4).join('\n'); + } // Add ellipsis if we truncated lines snippet = snippet + "..."; } else if (lines.length > 1) { - // For multi-line snippets, just limit to 4 lines (keep existing snippet) - snippet = lines.slice(0, 4).join('\n'); - if (lines.length > 4) { - snippet = snippet + "..."; - } + // For multi-line snippets that are 4 or fewer lines, keep them as-is + // No need to truncate } else { // Single line content - apply original word boundary logic // Try to start/end at word boundaries @@ -770,5 +789,8 @@ export default { searchNotesForAutocomplete, findResultsWithQuery, findFirstNoteWithQuery, - searchNotes + searchNotes, + extractContentSnippet, + extractAttributeSnippet, + highlightSearchResults };