1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

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 <laurent22@users.noreply.github.com>
This commit is contained in:
Anjula Karunarathne 2020-03-28 13:05:00 +00:00 committed by GitHub
parent d54e52b1a8
commit a45128807e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 150 additions and 11 deletions

View File

@ -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}`);
});
}));
});

View File

@ -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}`);
});
});
}));
});

View File

@ -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, `<span style="font-weight: bold; color: ${theme.colorBright};">`, '</span>');
const titleHtml = item.fragments
? `<span style="font-weight: bold; color: ${theme.colorBright};">${item.title}</span>`
: surroundKeywords(this.state.keywords, item.title, `<span style="font-weight: bold; color: ${theme.colorBright};">`, '</span>');
const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, `<span style="font-weight: bold; color: ${theme.colorBright};">`, '</span>');
const pathComp = !item.path ? null : <div style={style.rowPath}>{item.path}</div>;
return (
<div key={item.id} style={rowStyle} onClick={this.listItem_onClick} data-id={item.id} data-parent-id={item.parent_id}>
<div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div>
<div style={style.rowFragments} dangerouslySetInnerHTML={{ __html: fragmentsHtml }}></div>
{pathComp}
</div>
);
@ -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 : <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>;
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>;
return (
<div style={theme.dialogModalLayer}>

View File

@ -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;

View File

@ -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);