mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
* 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:
parent
d54e52b1a8
commit
a45128807e
@ -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}`);
|
||||
});
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -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}>
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user