mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 20:06:08 +01:00 
			
		
		
		
	feat(quick_search): format multi-line results better (#6672)
This commit is contained in:
		@@ -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>`;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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[]) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user