@@ -6,10 +6,11 @@ const SearchEngine = require('lib/services/SearchEngine');
66const BaseModel = require ( 'lib/BaseModel' ) ;
77const Tag = require ( 'lib/models/Tag' ) ;
88const Folder = require ( 'lib/models/Folder' ) ;
9+ const Note = require ( 'lib/models/Note' ) ;
910const { ItemList } = require ( '../gui/ItemList.min' ) ;
1011const HelpButton = require ( '../gui/HelpButton.min' ) ;
11- const { surroundKeywords } = require ( 'lib/string-utils.js' ) ;
12-
12+ const { surroundKeywords, nextWhitespaceIndex } = require ( 'lib/string-utils.js' ) ;
13+ const { mergeOverlappingIntervals } = require ( 'lib/ArrayUtils.js' ) ;
1314const PLUGIN_NAME = 'gotoAnything' ;
1415const itemHeight = 60 ;
1516
@@ -76,13 +77,20 @@ class Dialog extends React.PureComponent {
7677
7778 const rowTitleStyle = Object . assign ( { } , rowTextStyle , {
7879 fontSize : rowTextStyle . fontSize * 1.4 ,
79- marginBottom : 5 ,
80+ marginBottom : 4 ,
81+ color : theme . colorFaded ,
82+ } ) ;
83+
84+ const rowFragmentsStyle = Object . assign ( { } , rowTextStyle , {
85+ fontSize : rowTextStyle . fontSize * 1.2 ,
86+ marginBottom : 4 ,
8087 color : theme . colorFaded ,
8188 } ) ;
8289
8390 this . styles_ [ this . props . theme ] . rowSelected = Object . assign ( { } , this . styles_ [ this . props . theme ] . row , { backgroundColor : theme . selectedColor } ) ;
8491 this . styles_ [ this . props . theme ] . rowPath = rowTextStyle ;
8592 this . styles_ [ this . props . theme ] . rowTitle = rowTitleStyle ;
93+ this . styles_ [ this . props . theme ] . rowFragments = rowFragmentsStyle ;
8694
8795 return this . styles_ [ this . props . theme ] ;
8896 }
@@ -125,14 +133,17 @@ class Dialog extends React.PureComponent {
125133 } , 10 ) ;
126134 }
127135
128- makeSearchQuery ( query ) {
129- const splitted = query . split ( ' ' ) ;
136+ makeSearchQuery ( query , field ) {
130137 const output = [ ] ;
138+ const splitted = ( field === 'title' )
139+ ? query . split ( ' ' )
140+ : query . substr ( 1 ) . trim ( ) . split ( ' ' ) ; // body
141+
131142 for ( let i = 0 ; i < splitted . length ; i ++ ) {
132143 const s = splitted [ i ] . trim ( ) ;
133144 if ( ! s ) continue ;
134145
135- output . push ( `title:${ s } *` ) ;
146+ output . push ( field === 'title' ? `title: ${ s } *` : `body :${ s } *`) ;
136147 }
137148
138149 return output . join ( ' ' ) ;
@@ -165,9 +176,49 @@ class Dialog extends React.PureComponent {
165176 const path = Folder . folderPathString ( this . props . folders , row . parent_id ) ;
166177 results [ i ] = Object . assign ( { } , row , { path : path ? path : '/' } ) ;
167178 }
168- } else { // NOTES
179+ } else if ( this . state . query . indexOf ( '/' ) === 0 ) { // BODY
180+ listType = BaseModel . TYPE_NOTE ;
181+ searchQuery = this . makeSearchQuery ( this . state . query , 'body' ) ;
182+ results = await SearchEngine . instance ( ) . search ( searchQuery ) ;
183+
184+ const limit = 20 ;
185+ const searchKeywords = this . keywords ( searchQuery ) ;
186+ const notes = await Note . byIds ( results . map ( result => result . id ) . slice ( 0 , limit ) , { fields : [ 'id' , 'body' ] } ) ;
187+ const notesById = notes . reduce ( ( obj , { id, body } ) => ( ( obj [ [ id ] ] = body ) , obj ) , { } ) ;
188+
189+ for ( let i = 0 ; i < results . length ; i ++ ) {
190+ const row = results [ i ] ;
191+ let fragments = '...' ;
192+
193+ if ( i < limit ) { // Display note fragments of search keyword matches
194+ const indices = [ ] ;
195+ const body = notesById [ row . id ] ;
196+
197+ // Iterate over all matches in the body for each search keyword
198+ for ( const { valueRegex } of searchKeywords ) {
199+ for ( const match of body . matchAll ( new RegExp ( valueRegex , 'ig' ) ) ) {
200+ // Populate 'indices' with [begin index, end index] of each note fragment
201+ // Begins at the regex matching index, ends at the next whitespace after seeking 15 characters to the right
202+ indices . push ( [ match . index , nextWhitespaceIndex ( body , match . index + match [ 0 ] . length + 15 ) ] ) ;
203+ if ( indices . length > 20 ) break ;
204+ }
205+ }
206+
207+ // Merge multiple overlapping fragments into a single fragment to prevent repeated content
208+ // e.g. 'Joplin is a free, open source' and 'open source note taking application'
209+ // will result in 'Joplin is a free, open source note taking application'
210+ const mergedIndices = mergeOverlappingIntervals ( indices , 3 ) ;
211+ fragments = mergedIndices . map ( f => body . slice ( f [ 0 ] , f [ 1 ] ) ) . join ( ' ... ' ) ;
212+ // Add trailing ellipsis if the final fragment doesn't end where the note is ending
213+ if ( mergedIndices [ mergedIndices . length - 1 ] [ 1 ] !== body . length ) fragments += ' ...' ;
214+ }
215+
216+ const path = Folder . folderPathString ( this . props . folders , row . parent_id ) ;
217+ results [ i ] = Object . assign ( { } , row , { path, fragments } ) ;
218+ }
219+ } else { // TITLE
169220 listType = BaseModel . TYPE_NOTE ;
170- searchQuery = this . makeSearchQuery ( this . state . query ) ;
221+ searchQuery = this . makeSearchQuery ( this . state . query , 'title' ) ;
171222 results = await SearchEngine . instance ( ) . search ( searchQuery ) ;
172223
173224 for ( let i = 0 ; i < results . length ; i ++ ) {
@@ -248,13 +299,17 @@ class Dialog extends React.PureComponent {
248299 const theme = themeStyle ( this . props . theme ) ;
249300 const style = this . style ( ) ;
250301 const rowStyle = item . id === this . state . selectedItemId ? style . rowSelected : style . row ;
251- const titleHtml = surroundKeywords ( this . state . keywords , item . title , `<span style="font-weight: bold; color: ${ theme . colorBright } ;">` , '</span>' ) ;
302+ const titleHtml = item . fragments
303+ ? `<span style="font-weight: bold; color: ${ theme . colorBright } ;">${ item . title } </span>`
304+ : surroundKeywords ( this . state . keywords , item . title , `<span style="font-weight: bold; color: ${ theme . colorBright } ;">` , '</span>' ) ;
252305
306+ const fragmentsHtml = ! item . fragments ? null : surroundKeywords ( this . state . keywords , item . fragments , `<span style="font-weight: bold; color: ${ theme . colorBright } ;">` , '</span>' ) ;
253307 const pathComp = ! item . path ? null : < div style = { style . rowPath } > { item . path } </ div > ;
254308
255309 return (
256310 < div key = { item . id } style = { rowStyle } onClick = { this . listItem_onClick } data-id = { item . id } data-parent-id = { item . parent_id } >
257311 < div style = { style . rowTitle } dangerouslySetInnerHTML = { { __html : titleHtml } } > </ div >
312+ < div style = { style . rowFragments } dangerouslySetInnerHTML = { { __html : fragmentsHtml } } > </ div >
258313 { pathComp }
259314 </ div >
260315 ) ;
@@ -327,7 +382,7 @@ class Dialog extends React.PureComponent {
327382 render ( ) {
328383 const theme = themeStyle ( this . props . theme ) ;
329384 const style = this . style ( ) ;
330- const helpComp = ! this . state . showHelp ? null : < div style = { style . help } > { _ ( 'Type a note title to jump to it. Or type # followed by a tag name, or @ followed by a notebook name.' ) } </ div > ;
385+ const helpComp = ! this . state . showHelp ? null : < div style = { style . help } > { _ ( 'Type a note title to jump to it. Or type # followed by a tag name, or @ followed by a notebook name, or / followed by note content .' ) } </ div > ;
331386
332387 return (
333388 < div style = { theme . dialogModalLayer } >
0 commit comments