You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Search in title and body by default when using Goto Anything
This commit is contained in:
		| @@ -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}`); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user