Skip to content

Commit ed56ed2

Browse files
authored
feat(quick_search): format multi-line results better (#6672)
2 parents 648aa7e + d971554 commit ed56ed2

File tree

3 files changed

+164
-17
lines changed

3 files changed

+164
-17
lines changed

apps/client/src/widgets/quick_search.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ interface QuickSearchResponse {
9393
highlightedNotePathTitle: string;
9494
contentSnippet?: string;
9595
highlightedContentSnippet?: string;
96+
attributeSnippet?: string;
97+
highlightedAttributeSnippet?: string;
9698
icon: string;
9799
}>;
98100
error: string;
@@ -241,7 +243,12 @@ export default class QuickSearchWidget extends BasicWidget {
241243
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
242244
</div>`;
243245

244-
// Add content snippet below the title if available
246+
// Add attribute snippet (tags/attributes) below the title if available
247+
if (result.highlightedAttributeSnippet) {
248+
itemHtml += `<div style="font-size: 0.75em; color: var(--muted-text-color); opacity: 0.5; margin-left: 20px; margin-top: 2px; line-height: 1.2;" class="search-result-attributes">${result.highlightedAttributeSnippet}</div>`;
249+
}
250+
251+
// Add content snippet below the attributes if available
245252
if (result.highlightedContentSnippet) {
246253
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
247254
}

apps/server/src/services/search/search_result.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class SearchResult {
3535
highlightedNotePathTitle?: string;
3636
contentSnippet?: string;
3737
highlightedContentSnippet?: string;
38+
attributeSnippet?: string;
39+
highlightedAttributeSnippet?: string;
3840
private fuzzyScore: number; // Track fuzzy score separately
3941

4042
constructor(notePathArray: string[]) {

apps/server/src/services/search/services/search.ts

Lines changed: 154 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,13 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
468468
content = striptags(content);
469469
}
470470

471-
// Normalize whitespace
472-
content = content.replace(/\s+/g, " ").trim();
471+
// Normalize whitespace while preserving paragraph breaks
472+
// First, normalize multiple newlines to double newlines (paragraph breaks)
473+
content = content.replace(/\n\s*\n/g, "\n\n");
474+
// Then normalize spaces within lines
475+
content = content.split('\n').map(line => line.replace(/\s+/g, " ").trim()).join('\n');
476+
// Finally trim the whole content
477+
content = content.trim();
473478

474479
if (!content) {
475480
return "";
@@ -495,26 +500,125 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
495500
// Extract snippet
496501
let snippet = content.substring(snippetStart, snippetStart + maxLength);
497502

498-
// Try to start/end at word boundaries
499-
if (snippetStart > 0) {
500-
const firstSpace = snippet.indexOf(" ");
501-
if (firstSpace > 0 && firstSpace < 20) {
502-
snippet = snippet.substring(firstSpace + 1);
503+
// If snippet contains linebreaks, limit to max 4 lines and override character limit
504+
const lines = snippet.split('\n');
505+
if (lines.length > 4) {
506+
snippet = lines.slice(0, 4).join('\n');
507+
// Add ellipsis if we truncated lines
508+
snippet = snippet + "...";
509+
} else if (lines.length > 1) {
510+
// For multi-line snippets, just limit to 4 lines (keep existing snippet)
511+
snippet = lines.slice(0, 4).join('\n');
512+
if (lines.length > 4) {
513+
snippet = snippet + "...";
514+
}
515+
} else {
516+
// Single line content - apply original word boundary logic
517+
// Try to start/end at word boundaries
518+
if (snippetStart > 0) {
519+
const firstSpace = snippet.search(/\s/);
520+
if (firstSpace > 0 && firstSpace < 20) {
521+
snippet = snippet.substring(firstSpace + 1);
522+
}
523+
snippet = "..." + snippet;
524+
}
525+
526+
if (snippetStart + maxLength < content.length) {
527+
const lastSpace = snippet.search(/\s[^\s]*$/);
528+
if (lastSpace > snippet.length - 20 && lastSpace > 0) {
529+
snippet = snippet.substring(0, lastSpace);
530+
}
531+
snippet = snippet + "...";
532+
}
533+
}
534+
535+
return snippet;
536+
} catch (e) {
537+
log.error(`Error extracting content snippet for note ${noteId}: ${e}`);
538+
return "";
539+
}
540+
}
541+
542+
function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
543+
const note = becca.notes[noteId];
544+
if (!note) {
545+
return "";
546+
}
547+
548+
try {
549+
// Get all attributes for this note
550+
const attributes = note.getAttributes();
551+
if (!attributes || attributes.length === 0) {
552+
return "";
553+
}
554+
555+
let matchingAttributes: Array<{name: string, value: string, type: string}> = [];
556+
557+
// Look for attributes that match the search tokens
558+
for (const attr of attributes) {
559+
const attrName = attr.name?.toLowerCase() || "";
560+
const attrValue = attr.value?.toLowerCase() || "";
561+
const attrType = attr.type || "";
562+
563+
// Check if any search token matches the attribute name or value
564+
const hasMatch = searchTokens.some(token => {
565+
const normalizedToken = normalizeString(token.toLowerCase());
566+
return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken);
567+
});
568+
569+
if (hasMatch) {
570+
matchingAttributes.push({
571+
name: attr.name || "",
572+
value: attr.value || "",
573+
type: attrType
574+
});
575+
}
576+
}
577+
578+
if (matchingAttributes.length === 0) {
579+
return "";
580+
}
581+
582+
// Limit to 4 lines maximum, similar to content snippet logic
583+
const lines: string[] = [];
584+
for (const attr of matchingAttributes.slice(0, 4)) {
585+
let line = "";
586+
if (attr.type === "label") {
587+
line = attr.value ? `#${attr.name}="${attr.value}"` : `#${attr.name}`;
588+
} else if (attr.type === "relation") {
589+
// For relations, show the target note title if possible
590+
const targetNote = attr.value ? becca.notes[attr.value] : null;
591+
const targetTitle = targetNote ? targetNote.title : attr.value;
592+
line = `~${attr.name}="${targetTitle}"`;
593+
}
594+
595+
if (line) {
596+
lines.push(line);
503597
}
504-
snippet = "..." + snippet;
505598
}
599+
600+
let snippet = lines.join('\n');
506601

507-
if (snippetStart + maxLength < content.length) {
508-
const lastSpace = snippet.lastIndexOf(" ");
509-
if (lastSpace > snippet.length - 20) {
510-
snippet = snippet.substring(0, lastSpace);
602+
// Apply length limit while preserving line structure
603+
if (snippet.length > maxLength) {
604+
// Try to truncate at word boundaries but keep lines intact
605+
const truncated = snippet.substring(0, maxLength);
606+
const lastNewline = truncated.lastIndexOf('\n');
607+
608+
if (lastNewline > maxLength / 2) {
609+
// If we can keep most content by truncating to last complete line
610+
snippet = truncated.substring(0, lastNewline);
611+
} else {
612+
// Otherwise just truncate and add ellipsis
613+
const lastSpace = truncated.lastIndexOf(' ');
614+
snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3);
615+
snippet = snippet + "...";
511616
}
512-
snippet = snippet + "...";
513617
}
514618

515619
return snippet;
516620
} catch (e) {
517-
log.error(`Error extracting content snippet for note ${noteId}: ${e}`);
621+
log.error(`Error extracting attribute snippet for note ${noteId}: ${e}`);
518622
return "";
519623
}
520624
}
@@ -533,9 +637,10 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
533637

534638
const trimmed = allSearchResults.slice(0, 200);
535639

536-
// Extract content snippets
640+
// Extract content and attribute snippets
537641
for (const result of trimmed) {
538642
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
643+
result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
539644
}
540645

541646
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
@@ -549,6 +654,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
549654
highlightedNotePathTitle: result.highlightedNotePathTitle,
550655
contentSnippet: result.contentSnippet,
551656
highlightedContentSnippet: result.highlightedContentSnippet,
657+
attributeSnippet: result.attributeSnippet,
658+
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
552659
icon: icon ?? "bx bx-note"
553660
};
554661
});
@@ -574,7 +681,18 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
574681

575682
// Initialize highlighted content snippet
576683
if (result.contentSnippet) {
577-
result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, "");
684+
// Escape HTML but preserve newlines for later conversion to <br>
685+
result.highlightedContentSnippet = escapeHtml(result.contentSnippet);
686+
// Remove any stray < { } that might interfere with our highlighting markers
687+
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, "");
688+
}
689+
690+
// Initialize highlighted attribute snippet
691+
if (result.attributeSnippet) {
692+
// Escape HTML but preserve newlines for later conversion to <br>
693+
result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet);
694+
// Remove any stray < { } that might interfere with our highlighting markers
695+
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, "");
578696
}
579697
}
580698

@@ -612,6 +730,16 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
612730
contentRegex.lastIndex += 2;
613731
}
614732
}
733+
734+
// Highlight in attribute snippet
735+
if (result.highlightedAttributeSnippet) {
736+
const attributeRegex = new RegExp(escapeRegExp(token), "gi");
737+
while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) {
738+
result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}");
739+
// 2 characters are added, so we need to adjust the index
740+
attributeRegex.lastIndex += 2;
741+
}
742+
}
615743
}
616744
}
617745

@@ -621,7 +749,17 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
621749
}
622750

623751
if (result.highlightedContentSnippet) {
752+
// Replace highlighting markers with HTML tags
624753
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
754+
// Convert newlines to <br> tags for HTML display
755+
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>");
756+
}
757+
758+
if (result.highlightedAttributeSnippet) {
759+
// Replace highlighting markers with HTML tags
760+
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
761+
// Convert newlines to <br> tags for HTML display
762+
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "<br>");
625763
}
626764
}
627765
}

0 commit comments

Comments
 (0)