Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Go To Anything by body #2686

Merged
merged 3 commits into from Mar 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions CliClient/tests/ArrayUtils.js
Expand Up @@ -49,4 +49,41 @@ describe('ArrayUtils', function() {
expect(ArrayUtils.contentEquals(['b'], ['a', 'b'])).toBe(false);
}));

it('should merge overlapping intervals', asyncTest(async () => {
const testCases = [
anjulalk marked this conversation as resolved.
Show resolved Hide resolved
[
[],
[],
],
[
[[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}`);
});
}));

});
19 changes: 19 additions & 0 deletions CliClient/tests/StringUtils.js
Expand Up @@ -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}`);
});
});
}));

});
75 changes: 65 additions & 10 deletions ElectronClient/plugins/GotoAnything.jsx
Expand Up @@ -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;

Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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(' ');
Expand Down Expand Up @@ -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, '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);
searchQuery = this.makeSearchQuery(this.state.query, 'title');
results = await SearchEngine.instance().search(searchQuery);

for (let i = 0; i < results.length; i++) {
Expand Down Expand Up @@ -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>
);
Expand Down Expand Up @@ -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}>
Expand Down
22 changes: 22 additions & 0 deletions ReactNativeClient/lib/ArrayUtils.js
Expand Up @@ -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;
8 changes: 7 additions & 1 deletion ReactNativeClient/lib/string-utils.js
Expand Up @@ -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]/;
Expand All @@ -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);