feat(quick_search): format multi-line results better (#6672)

This commit is contained in:
Elian Doran 2025-08-18 23:14:15 +03:00 committed by GitHub
commit ed56ed2be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 166 additions and 19 deletions

View File

@ -93,6 +93,8 @@ interface QuickSearchResponse {
highlightedNotePathTitle: string; highlightedNotePathTitle: string;
contentSnippet?: string; contentSnippet?: string;
highlightedContentSnippet?: string; highlightedContentSnippet?: string;
attributeSnippet?: string;
highlightedAttributeSnippet?: string;
icon: string; icon: string;
}>; }>;
error: string; error: string;
@ -241,7 +243,12 @@ export default class QuickSearchWidget extends BasicWidget {
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span> <span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
</div>`; </div>`;
// Add content snippet below the title if available // Add attribute snippet (tags/attributes) below the title if available
if (result.highlightedAttributeSnippet) {
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>`;
}
// Add content snippet below the attributes if available
if (result.highlightedContentSnippet) { if (result.highlightedContentSnippet) {
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>`; 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>`;
} }

View File

@ -35,6 +35,8 @@ class SearchResult {
highlightedNotePathTitle?: string; highlightedNotePathTitle?: string;
contentSnippet?: string; contentSnippet?: string;
highlightedContentSnippet?: string; highlightedContentSnippet?: string;
attributeSnippet?: string;
highlightedAttributeSnippet?: string;
private fuzzyScore: number; // Track fuzzy score separately private fuzzyScore: number; // Track fuzzy score separately
constructor(notePathArray: string[]) { constructor(notePathArray: string[]) {

View File

@ -468,8 +468,13 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
content = striptags(content); content = striptags(content);
} }
// Normalize whitespace // Normalize whitespace while preserving paragraph breaks
content = content.replace(/\s+/g, " ").trim(); // 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) { if (!content) {
return ""; return "";
@ -495,9 +500,23 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
// Extract snippet // Extract snippet
let snippet = content.substring(snippetStart, snippetStart + maxLength); 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');
// 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 // Try to start/end at word boundaries
if (snippetStart > 0) { if (snippetStart > 0) {
const firstSpace = snippet.indexOf(" "); const firstSpace = snippet.search(/\s/);
if (firstSpace > 0 && firstSpace < 20) { if (firstSpace > 0 && firstSpace < 20) {
snippet = snippet.substring(firstSpace + 1); snippet = snippet.substring(firstSpace + 1);
} }
@ -505,12 +524,13 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
} }
if (snippetStart + maxLength < content.length) { if (snippetStart + maxLength < content.length) {
const lastSpace = snippet.lastIndexOf(" "); const lastSpace = snippet.search(/\s[^\s]*$/);
if (lastSpace > snippet.length - 20) { if (lastSpace > snippet.length - 20 && lastSpace > 0) {
snippet = snippet.substring(0, lastSpace); snippet = snippet.substring(0, lastSpace);
} }
snippet = snippet + "..."; snippet = snippet + "...";
} }
}
return snippet; return snippet;
} catch (e) { } catch (e) {
@ -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) { function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const searchContext = new SearchContext({ const searchContext = new SearchContext({
fastSearch: fastSearch, fastSearch: fastSearch,
@ -533,9 +637,10 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const trimmed = allSearchResults.slice(0, 200); const trimmed = allSearchResults.slice(0, 200);
// Extract content snippets // Extract content and attribute snippets
for (const result of trimmed) { for (const result of trimmed) {
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens); result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
} }
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
@ -549,6 +654,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
highlightedNotePathTitle: result.highlightedNotePathTitle, highlightedNotePathTitle: result.highlightedNotePathTitle,
contentSnippet: result.contentSnippet, contentSnippet: result.contentSnippet,
highlightedContentSnippet: result.highlightedContentSnippet, highlightedContentSnippet: result.highlightedContentSnippet,
attributeSnippet: result.attributeSnippet,
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
icon: icon ?? "bx bx-note" icon: icon ?? "bx bx-note"
}; };
}); });
@ -574,7 +681,18 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
// Initialize highlighted content snippet // Initialize highlighted content snippet
if (result.contentSnippet) { if (result.contentSnippet) {
result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, ""); // Escape HTML but preserve newlines for later conversion to <br>
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 <br>
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; 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) { if (result.highlightedContentSnippet) {
// Replace highlighting markers with HTML tags
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>"); result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
// Convert newlines to <br> tags for HTML display
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>");
}
if (result.highlightedAttributeSnippet) {
// Replace highlighting markers with HTML tags
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
// Convert newlines to <br> tags for HTML display
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "<br>");
} }
} }
} }