mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-27 08:21:03 +02:00
Desktop: Search in title and body by default when using Goto Anything
This commit is contained in:
parent
30c175ef29
commit
6164e2d8eb
@ -92,6 +92,32 @@ describe('services_SearchEngine', function() {
|
||||
expect(rows[2].id).toBe(n1.id);
|
||||
}));
|
||||
|
||||
it('should tell where the results are found', asyncTest(async () => {
|
||||
const notes = [
|
||||
await Note.save({ title: 'abcd efgh', body: 'abcd' }),
|
||||
await Note.save({ title: 'abcd' }),
|
||||
await Note.save({ title: 'efgh', body: 'abcd' }),
|
||||
];
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
const testCases = [
|
||||
['abcd', ['title', 'body'], ['title'], ['body']],
|
||||
['efgh', ['title'], [], ['title']],
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const rows = await engine.search(testCase[0]);
|
||||
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const row = rows.find(row => row.id === notes[i].id);
|
||||
const actual = row ? row.fields.sort().join(',') : '';
|
||||
const expected = testCase[i + 1].sort().join(',');
|
||||
expect(expected).toBe(actual);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
it('should order search results by relevance (2)', asyncTest(async () => {
|
||||
// 1
|
||||
const n1 = await Note.save({ title: 'abcd efgh', body: 'XX abcd XX efgh' });
|
||||
|
@ -12,7 +12,6 @@ const HelpButton = require('../gui/HelpButton.min');
|
||||
const { surroundKeywords, nextWhitespaceIndex } = require('lib/string-utils.js');
|
||||
const { mergeOverlappingIntervals } = require('lib/ArrayUtils.js');
|
||||
const PLUGIN_NAME = 'gotoAnything';
|
||||
const itemHeight = 60;
|
||||
|
||||
class GotoAnything {
|
||||
|
||||
@ -38,6 +37,7 @@ class Dialog extends React.PureComponent {
|
||||
keywords: [],
|
||||
listType: BaseModel.TYPE_NOTE,
|
||||
showHelp: false,
|
||||
resultsInBody: false,
|
||||
};
|
||||
|
||||
this.styles_ = {};
|
||||
@ -55,14 +55,30 @@ class Dialog extends React.PureComponent {
|
||||
}
|
||||
|
||||
style() {
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
const styleKey = [this.props.theme, this.state.resultsInBody ? '1' : '0'].join('-');
|
||||
|
||||
if (this.styles_[styleKey]) return this.styles_[styleKey];
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
this.styles_[this.props.theme] = {
|
||||
const itemHeight = this.state.resultsInBody ? 84 : 64;
|
||||
|
||||
this.styles_[styleKey] = {
|
||||
dialogBox: Object.assign({}, theme.dialogBox, { minWidth: '50%', maxWidth: '50%' }),
|
||||
input: Object.assign({}, theme.inputStyle, { flex: 1 }),
|
||||
row: { overflow: 'hidden', height: itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10 },
|
||||
row: {
|
||||
overflow: 'hidden',
|
||||
height: itemHeight,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: theme.dividerColor,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
help: Object.assign({}, theme.textStyle, { marginBottom: 10 }),
|
||||
inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' },
|
||||
};
|
||||
@ -78,22 +94,23 @@ class Dialog extends React.PureComponent {
|
||||
|
||||
const rowTitleStyle = Object.assign({}, rowTextStyle, {
|
||||
fontSize: rowTextStyle.fontSize * 1.4,
|
||||
marginBottom: 4,
|
||||
marginBottom: this.state.resultsInBody ? 6 : 4,
|
||||
color: theme.colorFaded,
|
||||
});
|
||||
|
||||
const rowFragmentsStyle = Object.assign({}, rowTextStyle, {
|
||||
fontSize: rowTextStyle.fontSize * 1.2,
|
||||
marginBottom: 4,
|
||||
marginBottom: this.state.resultsInBody ? 8 : 6,
|
||||
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;
|
||||
this.styles_[styleKey].rowSelected = Object.assign({}, this.styles_[styleKey].row, { backgroundColor: theme.selectedColor });
|
||||
this.styles_[styleKey].rowPath = rowTextStyle;
|
||||
this.styles_[styleKey].rowTitle = rowTitleStyle;
|
||||
this.styles_[styleKey].rowFragments = rowFragmentsStyle;
|
||||
this.styles_[styleKey].itemHeight = itemHeight;
|
||||
|
||||
return this.styles_[this.props.theme];
|
||||
return this.styles_[styleKey];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -144,17 +161,14 @@ class Dialog extends React.PureComponent {
|
||||
}, 10);
|
||||
}
|
||||
|
||||
makeSearchQuery(query, field) {
|
||||
makeSearchQuery(query) {
|
||||
const output = [];
|
||||
const splitted = (field === 'title')
|
||||
? query.split(' ')
|
||||
: query.substr(1).trim().split(' '); // body
|
||||
const splitted = query.split(' ');
|
||||
|
||||
for (let i = 0; i < splitted.length; i++) {
|
||||
const s = splitted[i].trim();
|
||||
if (!s) continue;
|
||||
|
||||
output.push(field === 'title' ? `title:${s}*` : `body:${s}*`);
|
||||
output.push(`${s}*`);
|
||||
}
|
||||
|
||||
return output.join(' ');
|
||||
@ -166,6 +180,8 @@ class Dialog extends React.PureComponent {
|
||||
}
|
||||
|
||||
async updateList() {
|
||||
let resultsInBody = false;
|
||||
|
||||
if (!this.state.query) {
|
||||
this.setState({ results: [], keywords: [] });
|
||||
} else {
|
||||
@ -187,55 +203,60 @@ class Dialog extends React.PureComponent {
|
||||
const path = Folder.folderPathString(this.props.folders, row.parent_id);
|
||||
results[i] = Object.assign({}, row, { path: path ? path : '/' });
|
||||
}
|
||||
} else if (this.state.query.indexOf('/') === 0) { // BODY
|
||||
} else { // Note TITLE or BODY
|
||||
listType = BaseModel.TYPE_NOTE;
|
||||
searchQuery = this.makeSearchQuery(this.state.query, 'body');
|
||||
searchQuery = this.makeSearchQuery(this.state.query);
|
||||
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), {});
|
||||
resultsInBody = !!results.find(row => row.fields.includes('body'));
|
||||
|
||||
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 += ' ...';
|
||||
if (!resultsInBody) {
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const row = results[i];
|
||||
const path = Folder.folderPathString(this.props.folders, row.parent_id);
|
||||
results[i] = Object.assign({}, row, { path: path });
|
||||
}
|
||||
} else {
|
||||
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), {});
|
||||
|
||||
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++) {
|
||||
const row = results[i];
|
||||
const path = Folder.folderPathString(this.props.folders, row.parent_id);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const row = results[i];
|
||||
const path = Folder.folderPathString(this.props.folders, row.parent_id);
|
||||
results[i] = Object.assign({}, row, { path: path });
|
||||
if (row.fields.includes('body')) {
|
||||
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 += ' ...';
|
||||
}
|
||||
|
||||
results[i] = Object.assign({}, row, { path, fragments });
|
||||
} else {
|
||||
results[i] = Object.assign({}, row, { path: path, fragments: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,6 +273,7 @@ class Dialog extends React.PureComponent {
|
||||
results: results,
|
||||
keywords: this.keywords(searchQuery),
|
||||
selectedItemId: selectedItemId,
|
||||
resultsInBody: resultsInBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -315,12 +337,15 @@ class Dialog extends React.PureComponent {
|
||||
: 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>;
|
||||
|
||||
const folderIcon = <i style={{ fontSize: theme.fontSize, marginRight: 2 }} className="fa fa-book" />;
|
||||
const pathComp = !item.path ? null : <div style={style.rowPath}>{folderIcon} {item.path}</div>;
|
||||
const fragmentComp = !fragmentsHtml ? null : <div style={style.rowFragments} dangerouslySetInnerHTML={{ __html: fragmentsHtml }}></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>
|
||||
{fragmentComp}
|
||||
{pathComp}
|
||||
</div>
|
||||
);
|
||||
@ -374,17 +399,19 @@ class Dialog extends React.PureComponent {
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const style = {
|
||||
const style = this.style();
|
||||
|
||||
const itemListStyle = {
|
||||
marginTop: 5,
|
||||
height: Math.min(itemHeight * this.state.results.length, 7 * itemHeight),
|
||||
height: Math.min(style.itemHeight * this.state.results.length, 7 * style.itemHeight),
|
||||
};
|
||||
|
||||
return (
|
||||
<ItemList
|
||||
ref={this.itemListRef}
|
||||
itemHeight={itemHeight}
|
||||
itemHeight={style.itemHeight}
|
||||
items={this.state.results}
|
||||
style={style}
|
||||
style={itemListStyle}
|
||||
itemRenderer={this.listItemRenderer}
|
||||
/>
|
||||
);
|
||||
@ -393,7 +420,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, or / followed by note content.')}</div>;
|
||||
const helpComp = !this.state.showHelp ? null : <div style={style.help}>{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name.')}</div>;
|
||||
|
||||
return (
|
||||
<div onClick={this.modalLayer_onClick} style={theme.dialogModalLayer}>
|
||||
|
@ -301,7 +301,7 @@ Notes are sorted by "relevance". Currently it means the notes that contain the r
|
||||
|
||||
# Goto Anything
|
||||
|
||||
In the desktop application, press Ctrl+G or Cmd+G and type the title of a note to jump directly to it. You can also type `#` followed by a tag or `@` followed by a notebook title.
|
||||
In the desktop application, press <kbd>Ctrl+G</kbd> or <kbd>Cmd+G</kbd> and type a note title or part of its content to jump to it. Or type <kbd>#</kbd> followed by a tag name, or <kbd>@</kbd> followed by a notebook name.
|
||||
|
||||
# Global shortcut
|
||||
|
||||
|
@ -124,6 +124,7 @@ class JoplinDatabase extends Database {
|
||||
this.initialized_ = false;
|
||||
this.tableFields_ = null;
|
||||
this.version_ = null;
|
||||
this.tableFieldNames_ = {};
|
||||
}
|
||||
|
||||
initialized() {
|
||||
@ -136,11 +137,14 @@ class JoplinDatabase extends Database {
|
||||
}
|
||||
|
||||
tableFieldNames(tableName) {
|
||||
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName];
|
||||
|
||||
const tf = this.tableFields(tableName);
|
||||
const output = [];
|
||||
for (let i = 0; i < tf.length; i++) {
|
||||
output.push(tf[i].name);
|
||||
}
|
||||
this.tableFieldNames_[tableName] = output;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
@ -192,16 +192,17 @@ class SearchEngine {
|
||||
return row && row['total'] ? row['total'] : 0;
|
||||
}
|
||||
|
||||
columnIndexesFromOffsets_(offsets) {
|
||||
fieldNamesFromOffsets_(offsets) {
|
||||
const notesNormalizedFieldNames = this.db().tableFieldNames('notes_normalized');
|
||||
const occurenceCount = Math.floor(offsets.length / 4);
|
||||
const indexes = [];
|
||||
|
||||
const output = [];
|
||||
for (let i = 0; i < occurenceCount; i++) {
|
||||
const colIndex = offsets[i * 4] - 1;
|
||||
if (indexes.indexOf(colIndex) < 0) indexes.push(colIndex);
|
||||
const colIndex = offsets[i * 4];
|
||||
const fieldName = notesNormalizedFieldNames[colIndex];
|
||||
if (!output.includes(fieldName)) output.push(fieldName);
|
||||
}
|
||||
|
||||
return indexes;
|
||||
return output;
|
||||
}
|
||||
|
||||
calculateWeight_(offsets, termCount) {
|
||||
@ -234,16 +235,17 @@ class SearchEngine {
|
||||
return occurenceCount / spread;
|
||||
}
|
||||
|
||||
orderResults_(rows, parsedQuery) {
|
||||
processResults_(rows, parsedQuery) {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const offsets = row.offsets.split(' ').map(o => Number(o));
|
||||
row.weight = this.calculateWeight_(offsets, parsedQuery.termCount);
|
||||
// row.colIndexes = this.columnIndexesFromOffsets_(offsets);
|
||||
// row.offsets = offsets;
|
||||
row.fields = this.fieldNamesFromOffsets_(offsets);
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.fields.includes('title') && !b.fields.includes('title')) return -1;
|
||||
if (!a.fields.includes('title') && b.fields.includes('title')) return +1;
|
||||
if (a.weight < b.weight) return +1;
|
||||
if (a.weight > b.weight) return -1;
|
||||
if (a.is_todo && a.todo_completed) return +1;
|
||||
@ -404,7 +406,7 @@ class SearchEngine {
|
||||
const sql = 'SELECT notes_fts.id, notes_fts.title AS normalized_title, offsets(notes_fts) AS offsets, notes.title, notes.user_updated_time, notes.is_todo, notes.todo_completed, notes.parent_id FROM notes_fts LEFT JOIN notes ON notes_fts.id = notes.id WHERE notes_fts MATCH ?';
|
||||
try {
|
||||
const rows = await this.db().selectAll(sql, [query]);
|
||||
this.orderResults_(rows, parsedQuery);
|
||||
this.processResults_(rows, parsedQuery);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
this.logger().warn(`Cannot execute MATCH query: ${query}: ${error.message}`);
|
||||
|
Loading…
Reference in New Issue
Block a user