Skip to content

Commit a451288

Browse files
anjulalklaurent22
andauthored
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 <[email protected]>
1 parent d54e52b commit a451288

File tree

5 files changed

+150
-11
lines changed

5 files changed

+150
-11
lines changed

CliClient/tests/ArrayUtils.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,41 @@ describe('ArrayUtils', function() {
4949
expect(ArrayUtils.contentEquals(['b'], ['a', 'b'])).toBe(false);
5050
}));
5151

52+
it('should merge overlapping intervals', asyncTest(async () => {
53+
const testCases = [
54+
[
55+
[],
56+
[],
57+
],
58+
[
59+
[[0, 50]],
60+
[[0, 50]],
61+
],
62+
[
63+
[[0, 20], [20, 30]],
64+
[[0, 30]],
65+
],
66+
[
67+
[[0, 10], [10, 50], [15, 30], [20, 80], [80, 95]],
68+
[[0, 95]],
69+
],
70+
[
71+
[[0, 5], [0, 10], [25, 35], [30, 60], [50, 60], [85, 100]],
72+
[[0, 10], [25, 60], [85, 100]],
73+
],
74+
[
75+
[[0, 5], [10, 40], [35, 50], [35, 75], [50, 60], [80, 85], [80, 90]],
76+
[[0, 5], [10, 75], [80, 90]],
77+
],
78+
];
79+
80+
testCases.forEach((t, i) => {
81+
const intervals = t[0];
82+
const expected = t[1];
83+
84+
const actual = ArrayUtils.mergeOverlappingIntervals(intervals, intervals.length);
85+
expect(actual).toEqual(expected, `Test case ${i}`);
86+
});
87+
}));
88+
5289
});

CliClient/tests/StringUtils.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,23 @@ describe('StringUtils', function() {
4141
}
4242
}));
4343

44+
it('should find the next whitespace character', asyncTest(async () => {
45+
const testCases = [
46+
['', [[0, 0]]],
47+
['Joplin', [[0, 6], [3, 6], [6, 6]]],
48+
['Joplin is a free, open source\n note taking and *to-do* application', [[0, 6], [12, 17], [23, 29], [48, 54]]],
49+
];
50+
51+
testCases.forEach((t, i) => {
52+
const str = t[0];
53+
t[1].forEach((pair, j) => {
54+
const begin = pair[0];
55+
const expected = pair[1];
56+
57+
const actual = StringUtils.nextWhitespaceIndex(str, begin);
58+
expect(actual).toBe(expected, `Test string ${i} - case ${j}`);
59+
});
60+
});
61+
}));
62+
4463
});

ElectronClient/plugins/GotoAnything.jsx

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ const SearchEngine = require('lib/services/SearchEngine');
66
const BaseModel = require('lib/BaseModel');
77
const Tag = require('lib/models/Tag');
88
const Folder = require('lib/models/Folder');
9+
const Note = require('lib/models/Note');
910
const { ItemList } = require('../gui/ItemList.min');
1011
const 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');
1314
const PLUGIN_NAME = 'gotoAnything';
1415
const 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}>

ReactNativeClient/lib/ArrayUtils.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,26 @@ ArrayUtils.contentEquals = function(array1, array2) {
5858
return true;
5959
};
6060

61+
// Merges multiple overlapping intervals into a single interval
62+
// e.g. [0, 25], [20, 50], [75, 100] --> [0, 50], [75, 100]
63+
ArrayUtils.mergeOverlappingIntervals = function(intervals, limit) {
64+
intervals.sort((a, b) => a[0] - b[0]);
65+
66+
const stack = [];
67+
if (intervals.length) {
68+
stack.push(intervals[0]);
69+
for (let i = 1; i < intervals.length && stack.length < limit; i++) {
70+
const top = stack[stack.length - 1];
71+
if (top[1] < intervals[i][0]) {
72+
stack.push(intervals[i]);
73+
} else if (top[1] < intervals[i][1]) {
74+
top[1] = intervals[i][1];
75+
stack.pop();
76+
stack.push(top);
77+
}
78+
}
79+
}
80+
return stack;
81+
};
82+
6183
module.exports = ArrayUtils;

ReactNativeClient/lib/string-utils.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ function substrWithEllipsis(s, start, length) {
264264
return `${s.substr(start, length - 3)}...`;
265265
}
266266

267+
function nextWhitespaceIndex(s, begin) {
268+
// returns index of the next whitespace character
269+
const i = s.slice(begin).search(/\s/);
270+
return i < 0 ? s.length : begin + i;
271+
}
272+
267273
const REGEX_JAPANESE = /[\u3000-\u303f]|[\u3040-\u309f]|[\u30a0-\u30ff]|[\uff00-\uff9f]|[\u4e00-\u9faf]|[\u3400-\u4dbf]/;
268274
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;
269275
const REGEX_KOREAN = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/;
@@ -279,4 +285,4 @@ function scriptType(s) {
279285
return 'en';
280286
}
281287

282-
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);
288+
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);

0 commit comments

Comments
 (0)