diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 094404182..2449d4bc5 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -93,6 +93,8 @@ interface QuickSearchResponse { highlightedNotePathTitle: string; contentSnippet?: string; highlightedContentSnippet?: string; + attributeSnippet?: string; + highlightedAttributeSnippet?: string; icon: string; }>; error: string; @@ -241,7 +243,12 @@ export default class QuickSearchWidget extends BasicWidget { ${result.highlightedNotePathTitle} `; - // Add content snippet below the title if available + // Add attribute snippet (tags/attributes) below the title if available + if (result.highlightedAttributeSnippet) { + itemHtml += `
${result.highlightedAttributeSnippet}
`; + } + + // Add content snippet below the attributes if available if (result.highlightedContentSnippet) { itemHtml += `
${result.highlightedContentSnippet}
`; } diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index 9d7aa247c..bf8a33524 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -35,6 +35,8 @@ class SearchResult { highlightedNotePathTitle?: string; contentSnippet?: string; highlightedContentSnippet?: string; + attributeSnippet?: string; + highlightedAttributeSnippet?: string; private fuzzyScore: number; // Track fuzzy score separately constructor(notePathArray: string[]) { diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 3236b6edb..0d3592455 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -468,8 +468,13 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength content = striptags(content); } - // Normalize whitespace - content = content.replace(/\s+/g, " ").trim(); + // Normalize whitespace while preserving paragraph breaks + // First, normalize multiple newlines to double newlines (paragraph breaks) + content = content.replace(/\n\s*\n/g, "\n\n"); + // Then normalize spaces within lines + content = content.split('\n').map(line => line.replace(/\s+/g, " ").trim()).join('\n'); + // Finally trim the whole content + content = content.trim(); if (!content) { return ""; @@ -495,21 +500,36 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength // 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); - } + // 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'); + // 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 + "..."; + } + } else { + // Single line content - apply original word boundary logic + // Try to start/end at word boundaries + if (snippetStart > 0) { + const firstSpace = snippet.search(/\s/); + if (firstSpace > 0 && firstSpace < 20) { + snippet = snippet.substring(firstSpace + 1); + } + snippet = "..." + snippet; + } + + if (snippetStart + maxLength < content.length) { + const lastSpace = snippet.search(/\s[^\s]*$/); + if (lastSpace > snippet.length - 20 && lastSpace > 0) { + snippet = snippet.substring(0, lastSpace); + } + snippet = snippet + "..."; + } } return snippet; @@ -519,6 +539,90 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength } } +function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string { + const note = becca.notes[noteId]; + if (!note) { + return ""; + } + + try { + // Get all attributes for this note + const attributes = note.getAttributes(); + if (!attributes || attributes.length === 0) { + return ""; + } + + let matchingAttributes: Array<{name: string, value: string, type: string}> = []; + + // Look for attributes that match the search tokens + for (const attr of attributes) { + const attrName = attr.name?.toLowerCase() || ""; + const attrValue = attr.value?.toLowerCase() || ""; + const attrType = attr.type || ""; + + // Check if any search token matches the attribute name or value + const hasMatch = searchTokens.some(token => { + const normalizedToken = normalizeString(token.toLowerCase()); + return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); + }); + + if (hasMatch) { + matchingAttributes.push({ + name: attr.name || "", + value: attr.value || "", + type: attrType + }); + } + } + + if (matchingAttributes.length === 0) { + return ""; + } + + // Limit to 4 lines maximum, similar to content snippet logic + const lines: string[] = []; + for (const attr of matchingAttributes.slice(0, 4)) { + let line = ""; + if (attr.type === "label") { + line = attr.value ? `#${attr.name}="${attr.value}"` : `#${attr.name}`; + } else if (attr.type === "relation") { + // For relations, show the target note title if possible + const targetNote = attr.value ? becca.notes[attr.value] : null; + const targetTitle = targetNote ? targetNote.title : attr.value; + line = `~${attr.name}="${targetTitle}"`; + } + + if (line) { + lines.push(line); + } + } + + let snippet = lines.join('\n'); + + // Apply length limit while preserving line structure + if (snippet.length > maxLength) { + // Try to truncate at word boundaries but keep lines intact + const truncated = snippet.substring(0, maxLength); + const lastNewline = truncated.lastIndexOf('\n'); + + if (lastNewline > maxLength / 2) { + // If we can keep most content by truncating to last complete line + snippet = truncated.substring(0, lastNewline); + } else { + // Otherwise just truncate and add ellipsis + const lastSpace = truncated.lastIndexOf(' '); + snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3); + snippet = snippet + "..."; + } + } + + return snippet; + } catch (e) { + log.error(`Error extracting attribute snippet for note ${noteId}: ${e}`); + return ""; + } +} + function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const searchContext = new SearchContext({ fastSearch: fastSearch, @@ -533,9 +637,10 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const trimmed = allSearchResults.slice(0, 200); - // Extract content snippets + // Extract content and attribute snippets for (const result of trimmed) { result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens); + result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens); } highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); @@ -549,6 +654,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { highlightedNotePathTitle: result.highlightedNotePathTitle, contentSnippet: result.contentSnippet, highlightedContentSnippet: result.highlightedContentSnippet, + attributeSnippet: result.attributeSnippet, + highlightedAttributeSnippet: result.highlightedAttributeSnippet, icon: icon ?? "bx bx-note" }; }); @@ -574,7 +681,18 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens // Initialize highlighted content snippet if (result.contentSnippet) { - result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, ""); + // Escape HTML but preserve newlines for later conversion to
+ result.highlightedContentSnippet = escapeHtml(result.contentSnippet); + // Remove any stray < { } that might interfere with our highlighting markers + result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); + } + + // Initialize highlighted attribute snippet + if (result.attributeSnippet) { + // Escape HTML but preserve newlines for later conversion to
+ result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet); + // Remove any stray < { } that might interfere with our highlighting markers + result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, ""); } } @@ -612,6 +730,16 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens contentRegex.lastIndex += 2; } } + + // Highlight in attribute snippet + if (result.highlightedAttributeSnippet) { + const attributeRegex = new RegExp(escapeRegExp(token), "gi"); + while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) { + result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}"); + // 2 characters are added, so we need to adjust the index + attributeRegex.lastIndex += 2; + } + } } } @@ -621,7 +749,17 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens } if (result.highlightedContentSnippet) { + // Replace highlighting markers with HTML tags result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, ""); + // Convert newlines to
tags for HTML display + result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "
"); + } + + if (result.highlightedAttributeSnippet) { + // Replace highlighting markers with HTML tags + result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "").replace(/}/g, ""); + // Convert newlines to
tags for HTML display + result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "
"); } } }