From a45128807e422d4505b305e8c57fbe44f203d030 Mon Sep 17 00:00:00 2001 From: Anjula Karunarathne Date: Sat, 28 Mar 2020 13:05:00 +0000 Subject: [PATCH] Desktop: Resolves #2683: Go To Anything by body (#2686) * Go to anything by body * Made limit parameter required * Made parameter required Co-authored-by: Laurent Cozic --- CliClient/tests/ArrayUtils.js | 37 ++++++++++++ CliClient/tests/StringUtils.js | 19 +++++++ ElectronClient/plugins/GotoAnything.jsx | 75 +++++++++++++++++++++---- ReactNativeClient/lib/ArrayUtils.js | 22 ++++++++ ReactNativeClient/lib/string-utils.js | 8 ++- 5 files changed, 150 insertions(+), 11 deletions(-) diff --git a/CliClient/tests/ArrayUtils.js b/CliClient/tests/ArrayUtils.js index ab8b891d03..ef16a6ef75 100644 --- a/CliClient/tests/ArrayUtils.js +++ b/CliClient/tests/ArrayUtils.js @@ -49,4 +49,41 @@ describe('ArrayUtils', function() { expect(ArrayUtils.contentEquals(['b'], ['a', 'b'])).toBe(false); })); + it('should merge overlapping intervals', asyncTest(async () => { + const testCases = [ + [ + [], + [], + ], + [ + [[0, 50]], + [[0, 50]], + ], + [ + [[0, 20], [20, 30]], + [[0, 30]], + ], + [ + [[0, 10], [10, 50], [15, 30], [20, 80], [80, 95]], + [[0, 95]], + ], + [ + [[0, 5], [0, 10], [25, 35], [30, 60], [50, 60], [85, 100]], + [[0, 10], [25, 60], [85, 100]], + ], + [ + [[0, 5], [10, 40], [35, 50], [35, 75], [50, 60], [80, 85], [80, 90]], + [[0, 5], [10, 75], [80, 90]], + ], + ]; + + testCases.forEach((t, i) => { + const intervals = t[0]; + const expected = t[1]; + + const actual = ArrayUtils.mergeOverlappingIntervals(intervals, intervals.length); + expect(actual).toEqual(expected, `Test case ${i}`); + }); + })); + }); diff --git a/CliClient/tests/StringUtils.js b/CliClient/tests/StringUtils.js index c4b8aaf0cd..0e1bed651b 100644 --- a/CliClient/tests/StringUtils.js +++ b/CliClient/tests/StringUtils.js @@ -41,4 +41,23 @@ describe('StringUtils', function() { } })); + it('should find the next whitespace character', asyncTest(async () => { + const testCases = [ + ['', [[0, 0]]], + ['Joplin', [[0, 6], [3, 6], [6, 6]]], + ['Joplin is a free, open source\n note taking and *to-do* application', [[0, 6], [12, 17], [23, 29], [48, 54]]], + ]; + + testCases.forEach((t, i) => { + const str = t[0]; + t[1].forEach((pair, j) => { + const begin = pair[0]; + const expected = pair[1]; + + const actual = StringUtils.nextWhitespaceIndex(str, begin); + expect(actual).toBe(expected, `Test string ${i} - case ${j}`); + }); + }); + })); + }); diff --git a/ElectronClient/plugins/GotoAnything.jsx b/ElectronClient/plugins/GotoAnything.jsx index f9e5482460..8c73f04248 100644 --- a/ElectronClient/plugins/GotoAnything.jsx +++ b/ElectronClient/plugins/GotoAnything.jsx @@ -6,10 +6,11 @@ const SearchEngine = require('lib/services/SearchEngine'); const BaseModel = require('lib/BaseModel'); const Tag = require('lib/models/Tag'); const Folder = require('lib/models/Folder'); +const Note = require('lib/models/Note'); const { ItemList } = require('../gui/ItemList.min'); const HelpButton = require('../gui/HelpButton.min'); -const { surroundKeywords } = require('lib/string-utils.js'); - +const { surroundKeywords, nextWhitespaceIndex } = require('lib/string-utils.js'); +const { mergeOverlappingIntervals } = require('lib/ArrayUtils.js'); const PLUGIN_NAME = 'gotoAnything'; const itemHeight = 60; @@ -76,13 +77,20 @@ class Dialog extends React.PureComponent { const rowTitleStyle = Object.assign({}, rowTextStyle, { fontSize: rowTextStyle.fontSize * 1.4, - marginBottom: 5, + marginBottom: 4, + color: theme.colorFaded, + }); + + const rowFragmentsStyle = Object.assign({}, rowTextStyle, { + fontSize: rowTextStyle.fontSize * 1.2, + marginBottom: 4, 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; return this.styles_[this.props.theme]; } @@ -125,14 +133,17 @@ class Dialog extends React.PureComponent { }, 10); } - makeSearchQuery(query) { - const splitted = query.split(' '); + makeSearchQuery(query, field) { const output = []; + const splitted = (field === 'title') + ? query.split(' ') + : query.substr(1).trim().split(' '); // body + for (let i = 0; i < splitted.length; i++) { const s = splitted[i].trim(); if (!s) continue; - output.push(`title:${s}*`); + output.push(field === 'title' ? `title:${s}*` : `body:${s}*`); } return output.join(' '); @@ -165,9 +176,49 @@ class Dialog extends React.PureComponent { const path = Folder.folderPathString(this.props.folders, row.parent_id); results[i] = Object.assign({}, row, { path: path ? path : '/' }); } - } else { // NOTES + } else if (this.state.query.indexOf('/') === 0) { // BODY listType = BaseModel.TYPE_NOTE; - searchQuery = this.makeSearchQuery(this.state.query); + searchQuery = this.makeSearchQuery(this.state.query, 'body'); + 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), {}); + + 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 += ' ...'; + } + + 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++) { @@ -248,13 +299,17 @@ class Dialog extends React.PureComponent { const theme = themeStyle(this.props.theme); const style = this.style(); const rowStyle = item.id === this.state.selectedItemId ? style.rowSelected : style.row; - const titleHtml = surroundKeywords(this.state.keywords, item.title, ``, ''); + const titleHtml = item.fragments + ? `${item.title}` + : surroundKeywords(this.state.keywords, item.title, ``, ''); + const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, ``, ''); const pathComp = !item.path ? null :
{item.path}
; return (
+
{pathComp}
); @@ -327,7 +382,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.')}
; + 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.')}
; return (
diff --git a/ReactNativeClient/lib/ArrayUtils.js b/ReactNativeClient/lib/ArrayUtils.js index f3a2d5d802..ba1956aaea 100644 --- a/ReactNativeClient/lib/ArrayUtils.js +++ b/ReactNativeClient/lib/ArrayUtils.js @@ -58,4 +58,26 @@ ArrayUtils.contentEquals = function(array1, array2) { return true; }; +// Merges multiple overlapping intervals into a single interval +// e.g. [0, 25], [20, 50], [75, 100] --> [0, 50], [75, 100] +ArrayUtils.mergeOverlappingIntervals = function(intervals, limit) { + intervals.sort((a, b) => a[0] - b[0]); + + const stack = []; + if (intervals.length) { + stack.push(intervals[0]); + for (let i = 1; i < intervals.length && stack.length < limit; i++) { + const top = stack[stack.length - 1]; + if (top[1] < intervals[i][0]) { + stack.push(intervals[i]); + } else if (top[1] < intervals[i][1]) { + top[1] = intervals[i][1]; + stack.pop(); + stack.push(top); + } + } + } + return stack; +}; + module.exports = ArrayUtils; diff --git a/ReactNativeClient/lib/string-utils.js b/ReactNativeClient/lib/string-utils.js index 509bc39a49..3314eee6e5 100644 --- a/ReactNativeClient/lib/string-utils.js +++ b/ReactNativeClient/lib/string-utils.js @@ -264,6 +264,12 @@ function substrWithEllipsis(s, start, length) { return `${s.substr(start, length - 3)}...`; } +function nextWhitespaceIndex(s, begin) { + // returns index of the next whitespace character + const i = s.slice(begin).search(/\s/); + return i < 0 ? s.length : begin + i; +} + const REGEX_JAPANESE = /[\u3000-\u303f]|[\u3040-\u309f]|[\u30a0-\u30ff]|[\uff00-\uff9f]|[\u4e00-\u9faf]|[\u3400-\u4dbf]/; const REGEX_CHINESE = /[\u4e00-\u9fff]|[\u3400-\u4dbf]|[\u{20000}-\u{2a6df}]|[\u{2a700}-\u{2b73f}]|[\u{2b740}-\u{2b81f}]|[\u{2b820}-\u{2ceaf}]|[\uf900-\ufaff]|[\u3300-\u33ff]|[\ufe30-\ufe4f]|[\uf900-\ufaff]|[\u{2f800}-\u{2fa1f}]/u; const REGEX_KOREAN = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; @@ -279,4 +285,4 @@ function scriptType(s) { return 'en'; } -module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon); +module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);