@@ -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