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 :
{item.path}
; + + const folderIcon = ; + const pathComp = !item.path ? null :
{folderIcon} {item.path}
; + const fragmentComp = !fragmentsHtml ? null :
; return (
-
+ {fragmentComp} {pathComp}
); @@ -374,17 +399,19 @@ class Dialog extends React.PureComponent { } renderList() { - const style = { + const style = this.style(); + + const itemListStyle = { marginTop: 5, - height: Math.min(itemHeight * this.state.results.length, 7 * itemHeight), + height: Math.min(style.itemHeight * this.state.results.length, 7 * style.itemHeight), }; return ( ); @@ -393,7 +420,7 @@ class Dialog extends React.PureComponent { render() { const theme = themeStyle(this.props.theme); const style = this.style(); - const helpComp = !this.state.showHelp ? null :
{_('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.')}
; + const helpComp = !this.state.showHelp ? null :
{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name.')}
; return (
diff --git a/README.md b/README.md index 50f21cc984..0dd41007f7 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ Notes are sorted by "relevance". Currently it means the notes that contain the r # Goto Anything -In the desktop application, press Ctrl+G or Cmd+G and type the title of a note to jump directly to it. You can also type `#` followed by a tag or `@` followed by a notebook title. +In the desktop application, press Ctrl+G or Cmd+G and type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. # Global shortcut diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 4674ab931c..a92ba489ab 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -124,6 +124,7 @@ class JoplinDatabase extends Database { this.initialized_ = false; this.tableFields_ = null; this.version_ = null; + this.tableFieldNames_ = {}; } initialized() { @@ -136,11 +137,14 @@ class JoplinDatabase extends Database { } tableFieldNames(tableName) { + if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName]; + const tf = this.tableFields(tableName); const output = []; for (let i = 0; i < tf.length; i++) { output.push(tf[i].name); } + this.tableFieldNames_[tableName] = output; return output; } diff --git a/ReactNativeClient/lib/services/SearchEngine.js b/ReactNativeClient/lib/services/SearchEngine.js index c477b94f37..5ea3c999ed 100644 --- a/ReactNativeClient/lib/services/SearchEngine.js +++ b/ReactNativeClient/lib/services/SearchEngine.js @@ -192,16 +192,17 @@ class SearchEngine { return row && row['total'] ? row['total'] : 0; } - columnIndexesFromOffsets_(offsets) { + fieldNamesFromOffsets_(offsets) { + const notesNormalizedFieldNames = this.db().tableFieldNames('notes_normalized'); const occurenceCount = Math.floor(offsets.length / 4); - const indexes = []; - + const output = []; for (let i = 0; i < occurenceCount; i++) { - const colIndex = offsets[i * 4] - 1; - if (indexes.indexOf(colIndex) < 0) indexes.push(colIndex); + const colIndex = offsets[i * 4]; + const fieldName = notesNormalizedFieldNames[colIndex]; + if (!output.includes(fieldName)) output.push(fieldName); } - return indexes; + return output; } calculateWeight_(offsets, termCount) { @@ -234,16 +235,17 @@ class SearchEngine { return occurenceCount / spread; } - orderResults_(rows, parsedQuery) { + processResults_(rows, parsedQuery) { for (let i = 0; i < rows.length; i++) { const row = rows[i]; const offsets = row.offsets.split(' ').map(o => Number(o)); row.weight = this.calculateWeight_(offsets, parsedQuery.termCount); - // row.colIndexes = this.columnIndexesFromOffsets_(offsets); - // row.offsets = offsets; + row.fields = this.fieldNamesFromOffsets_(offsets); } rows.sort((a, b) => { + if (a.fields.includes('title') && !b.fields.includes('title')) return -1; + if (!a.fields.includes('title') && b.fields.includes('title')) return +1; if (a.weight < b.weight) return +1; if (a.weight > b.weight) return -1; if (a.is_todo && a.todo_completed) return +1; @@ -404,7 +406,7 @@ class SearchEngine { const sql = 'SELECT notes_fts.id, notes_fts.title AS normalized_title, offsets(notes_fts) AS offsets, notes.title, notes.user_updated_time, notes.is_todo, notes.todo_completed, notes.parent_id FROM notes_fts LEFT JOIN notes ON notes_fts.id = notes.id WHERE notes_fts MATCH ?'; try { const rows = await this.db().selectAll(sql, [query]); - this.orderResults_(rows, parsedQuery); + this.processResults_(rows, parsedQuery); return rows; } catch (error) { this.logger().warn(`Cannot execute MATCH query: ${query}: ${error.message}`);