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, "
");
}
}
}