diff --git a/CliClient/tests/services_SearchEngine.js b/CliClient/tests/services_SearchEngine.js index 5d2fa62564..7499a6ee21 100644 --- a/CliClient/tests/services_SearchEngine.js +++ b/CliClient/tests/services_SearchEngine.js @@ -92,6 +92,32 @@ describe('services_SearchEngine', function() { expect(rows[2].id).toBe(n1.id); })); + it('should tell where the results are found', asyncTest(async () => { + const notes = [ + await Note.save({ title: 'abcd efgh', body: 'abcd' }), + await Note.save({ title: 'abcd' }), + await Note.save({ title: 'efgh', body: 'abcd' }), + ]; + + await engine.syncTables(); + + const testCases = [ + ['abcd', ['title', 'body'], ['title'], ['body']], + ['efgh', ['title'], [], ['title']], + ]; + + for (const testCase of testCases) { + const rows = await engine.search(testCase[0]); + + for (let i = 0; i < notes.length; i++) { + const row = rows.find(row => row.id === notes[i].id); + const actual = row ? row.fields.sort().join(',') : ''; + const expected = testCase[i + 1].sort().join(','); + expect(expected).toBe(actual); + } + } + })); + it('should order search results by relevance (2)', asyncTest(async () => { // 1 const n1 = await Note.save({ title: 'abcd efgh', body: 'XX abcd XX efgh' }); diff --git a/ElectronClient/plugins/GotoAnything.jsx b/ElectronClient/plugins/GotoAnything.jsx index f692ef886d..9ad3bdccb8 100644 --- a/ElectronClient/plugins/GotoAnything.jsx +++ b/ElectronClient/plugins/GotoAnything.jsx @@ -12,7 +12,6 @@ const HelpButton = require('../gui/HelpButton.min'); const { surroundKeywords, nextWhitespaceIndex } = require('lib/string-utils.js'); const { mergeOverlappingIntervals } = require('lib/ArrayUtils.js'); const PLUGIN_NAME = 'gotoAnything'; -const itemHeight = 60; class GotoAnything { @@ -38,6 +37,7 @@ class Dialog extends React.PureComponent { keywords: [], listType: BaseModel.TYPE_NOTE, showHelp: false, + resultsInBody: false, }; this.styles_ = {}; @@ -55,14 +55,30 @@ class Dialog extends React.PureComponent { } style() { - if (this.styles_[this.props.theme]) return this.styles_[this.props.theme]; + const styleKey = [this.props.theme, this.state.resultsInBody ? '1' : '0'].join('-'); + + if (this.styles_[styleKey]) return this.styles_[styleKey]; const theme = themeStyle(this.props.theme); - this.styles_[this.props.theme] = { + const itemHeight = this.state.resultsInBody ? 84 : 64; + + this.styles_[styleKey] = { dialogBox: Object.assign({}, theme.dialogBox, { minWidth: '50%', maxWidth: '50%' }), input: Object.assign({}, theme.inputStyle, { flex: 1 }), - row: { overflow: 'hidden', height: itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10 }, + row: { + overflow: 'hidden', + height: itemHeight, + display: 'flex', + justifyContent: 'center', + flexDirection: 'column', + paddingLeft: 10, + paddingRight: 10, + borderBottomWidth: 1, + borderBottomStyle: 'solid', + borderBottomColor: theme.dividerColor, + boxSizing: 'border-box', + }, help: Object.assign({}, theme.textStyle, { marginBottom: 10 }), inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' }, }; @@ -78,22 +94,23 @@ class Dialog extends React.PureComponent { const rowTitleStyle = Object.assign({}, rowTextStyle, { fontSize: rowTextStyle.fontSize * 1.4, - marginBottom: 4, + marginBottom: this.state.resultsInBody ? 6 : 4, color: theme.colorFaded, }); const rowFragmentsStyle = Object.assign({}, rowTextStyle, { fontSize: rowTextStyle.fontSize * 1.2, - marginBottom: 4, + marginBottom: this.state.resultsInBody ? 8 : 6, color: theme.colorFaded, }); - this.styles_[this.props.theme].rowSelected = Object.assign({}, this.styles_[this.props.theme].row, { backgroundColor: theme.selectedColor }); - this.styles_[this.props.theme].rowPath = rowTextStyle; - this.styles_[this.props.theme].rowTitle = rowTitleStyle; - this.styles_[this.props.theme].rowFragments = rowFragmentsStyle; + this.styles_[styleKey].rowSelected = Object.assign({}, this.styles_[styleKey].row, { backgroundColor: theme.selectedColor }); + this.styles_[styleKey].rowPath = rowTextStyle; + this.styles_[styleKey].rowTitle = rowTitleStyle; + this.styles_[styleKey].rowFragments = rowFragmentsStyle; + this.styles_[styleKey].itemHeight = itemHeight; - return this.styles_[this.props.theme]; + return this.styles_[styleKey]; } componentDidMount() { @@ -144,17 +161,14 @@ class Dialog extends React.PureComponent { }, 10); } - makeSearchQuery(query, field) { + makeSearchQuery(query) { const output = []; - const splitted = (field === 'title') - ? query.split(' ') - : query.substr(1).trim().split(' '); // body + const splitted = query.split(' '); for (let i = 0; i < splitted.length; i++) { const s = splitted[i].trim(); if (!s) continue; - - output.push(field === 'title' ? `title:${s}*` : `body:${s}*`); + output.push(`${s}*`); } return output.join(' '); @@ -166,6 +180,8 @@ class Dialog extends React.PureComponent { } async updateList() { + let resultsInBody = false; + if (!this.state.query) { this.setState({ results: [], keywords: [] }); } else { @@ -187,55 +203,60 @@ class Dialog extends React.PureComponent { const path = Folder.folderPathString(this.props.folders, row.parent_id); results[i] = Object.assign({}, row, { path: path ? path : '/' }); } - } else if (this.state.query.indexOf('/') === 0) { // BODY + } else { // Note TITLE or BODY listType = BaseModel.TYPE_NOTE; - searchQuery = this.makeSearchQuery(this.state.query, 'body'); + searchQuery = this.makeSearchQuery(this.state.query); results = await SearchEngine.instance().search(searchQuery); - const limit = 20; - const searchKeywords = this.keywords(searchQuery); - const notes = await Note.byIds(results.map(result => result.id).slice(0, limit), { fields: ['id', 'body'] }); - const notesById = notes.reduce((obj, { id, body }) => ((obj[[id]] = body), obj), {}); + resultsInBody = !!results.find(row => row.fields.includes('body')); - for (let i = 0; i < results.length; i++) { - const row = results[i]; - let fragments = '...'; - - if (i < limit) { // Display note fragments of search keyword matches - const indices = []; - const body = notesById[row.id]; - - // Iterate over all matches in the body for each search keyword - for (const { valueRegex } of searchKeywords) { - for (const match of body.matchAll(new RegExp(valueRegex, 'ig'))) { - // Populate 'indices' with [begin index, end index] of each note fragment - // Begins at the regex matching index, ends at the next whitespace after seeking 15 characters to the right - indices.push([match.index, nextWhitespaceIndex(body, match.index + match[0].length + 15)]); - if (indices.length > 20) break; - } - } - - // Merge multiple overlapping fragments into a single fragment to prevent repeated content - // e.g. 'Joplin is a free, open source' and 'open source note taking application' - // will result in 'Joplin is a free, open source note taking application' - const mergedIndices = mergeOverlappingIntervals(indices, 3); - fragments = mergedIndices.map(f => body.slice(f[0], f[1])).join(' ... '); - // Add trailing ellipsis if the final fragment doesn't end where the note is ending - if (mergedIndices[mergedIndices.length - 1][1] !== body.length) fragments += ' ...'; + if (!resultsInBody) { + for (let i = 0; i < results.length; i++) { + const row = results[i]; + const path = Folder.folderPathString(this.props.folders, row.parent_id); + results[i] = Object.assign({}, row, { path: path }); } + } else { + const limit = 20; + const searchKeywords = this.keywords(searchQuery); + const notes = await Note.byIds(results.map(result => result.id).slice(0, limit), { fields: ['id', 'body'] }); + const notesById = notes.reduce((obj, { id, body }) => ((obj[[id]] = body), obj), {}); - const path = Folder.folderPathString(this.props.folders, row.parent_id); - results[i] = Object.assign({}, row, { path, fragments }); - } - } else { // TITLE - listType = BaseModel.TYPE_NOTE; - searchQuery = this.makeSearchQuery(this.state.query, 'title'); - results = await SearchEngine.instance().search(searchQuery); + for (let i = 0; i < results.length; i++) { + const row = results[i]; + const path = Folder.folderPathString(this.props.folders, row.parent_id); - for (let i = 0; i < results.length; i++) { - const row = results[i]; - const path = Folder.folderPathString(this.props.folders, row.parent_id); - results[i] = Object.assign({}, row, { path: path }); + if (row.fields.includes('body')) { + let fragments = '...'; + + if (i < limit) { // Display note fragments of search keyword matches + const indices = []; + const body = notesById[row.id]; + + // Iterate over all matches in the body for each search keyword + for (const { valueRegex } of searchKeywords) { + for (const match of body.matchAll(new RegExp(valueRegex, 'ig'))) { + // Populate 'indices' with [begin index, end index] of each note fragment + // Begins at the regex matching index, ends at the next whitespace after seeking 15 characters to the right + indices.push([match.index, nextWhitespaceIndex(body, match.index + match[0].length + 15)]); + if (indices.length > 20) break; + } + } + + // Merge multiple overlapping fragments into a single fragment to prevent repeated content + // e.g. 'Joplin is a free, open source' and 'open source note taking application' + // will result in 'Joplin is a free, open source note taking application' + const mergedIndices = mergeOverlappingIntervals(indices, 3); + fragments = mergedIndices.map(f => body.slice(f[0], f[1])).join(' ... '); + // Add trailing ellipsis if the final fragment doesn't end where the note is ending + if (mergedIndices[mergedIndices.length - 1][1] !== body.length) fragments += ' ...'; + } + + results[i] = Object.assign({}, row, { path, fragments }); + } else { + results[i] = Object.assign({}, row, { path: path, fragments: '' }); + } + } } } @@ -252,6 +273,7 @@ class Dialog extends React.PureComponent { results: results, keywords: this.keywords(searchQuery), selectedItemId: selectedItemId, + resultsInBody: resultsInBody, }); } } @@ -315,12 +337,15 @@ class Dialog extends React.PureComponent { : surroundKeywords(this.state.keywords, item.title, ``, ''); const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, ``, ''); - const pathComp = !item.path ? null :