import * as React from 'react'; import { Dispatch } from 'redux'; import { AppState } from '../app.reducer'; import CommandService, { SearchResult as CommandSearchResult } from '@joplin/lib/services/CommandService'; import KeymapService from '@joplin/lib/services/KeymapService'; import { connect } from 'react-redux'; import { _ } from '@joplin/lib/locale'; import { themeStyle } from '@joplin/lib/theme'; import SearchEngine, { ComplexTerm } from '@joplin/lib/services/search/SearchEngine'; import gotoAnythingStyleQuery from '@joplin/lib/services/search/gotoAnythingStyleQuery'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import Tag from '@joplin/lib/models/Tag'; import Folder from '@joplin/lib/models/Folder'; import Note from '@joplin/lib/models/Note'; import ItemList from '../gui/ItemList'; import HelpButton from '../gui/HelpButton'; import { surroundKeywords, nextWhitespaceIndex, removeDiacritics, escapeRegExp, KeywordType } from '@joplin/lib/string-utils'; import { mergeOverlappingIntervals } from '@joplin/lib/ArrayUtils'; import markupLanguageUtils from '@joplin/lib/utils/markupLanguageUtils'; import focusEditorIfEditorCommand from '@joplin/lib/services/commands/focusEditorIfEditorCommand'; import Logger from '@joplin/utils/Logger'; import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer'; import Resource from '@joplin/lib/models/Resource'; import { FolderEntity, NoteEntity, ResourceEntity, TagEntity } from '@joplin/lib/services/database/types'; import Dialog from '@joplin/lib/components/Dialog'; import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; import { PluginManifest } from '@joplin/lib/services/PluginManager'; const logger = Logger.create('GotoAnything'); const PLUGIN_NAME = 'gotoAnything'; interface GotoAnythingSearchResult { id: string; title: string; parent_id: string; fields: string[]; fragments?: string; path?: string; type?: number; item_id?: string; item_type?: ModelType; } // GotoAnything supports several modes: // // - Default: Search in note title, body. Can search for folders, tags, etc. This is the full // featured GotoAnything. // // - TitleOnly: Search in note titles only. // // These different modes can be set from the `gotoAnything` command. export enum Mode { Default = 0, TitleOnly, } export interface UserDataCallbackEvent { type: ModelType; item: NoteEntity | FolderEntity | ResourceEntity | TagEntity; } export type UserDataCallbackResolve = (event: UserDataCallbackEvent)=> void; export type UserDataCallbackReject = (error: Error)=> void; export interface UserDataCallback { resolve: UserDataCallbackResolve; reject: UserDataCallbackReject; } export interface GotoAnythingUserData { startString?: string; mode?: Mode; callback?: UserDataCallback; alwaysShowHelp?: boolean; } interface Props { themeId: number; dispatch: Dispatch; folders: FolderEntity[]; showCompletedTodos: boolean; userData: GotoAnythingUserData; } interface State { query: string; results: GotoAnythingSearchResult[]; selectedItemId: string; keywords: (string | ComplexTerm)[]; listType: number; showHelp: boolean; resultsInBody: boolean; commandArgs: string[]; } interface CommandQuery { name: string; args: string[]; } const getContentMarkupLanguageAndBody = (result: GotoAnythingSearchResult, notesById: Record, resources: ResourceEntity[]) => { if (result.item_type === ModelType.Resource) { const resource = resources.find(r => r.id === result.item_id); if (!resource) { logger.warn('Could not find resources associated with result:', result); return { markupLanguage: MarkupLanguage.Markdown, content: '' }; } else { return { markupLanguage: MarkupLanguage.Markdown, content: resource.ocr_text }; } } else { // a note const note = notesById[result.id]; return { markupLanguage: note.markup_language, content: note.body }; } }; // A result row contains an `id` property (the note ID) and, if the current row // is a resource, an `item_id` property, which is the resource ID. In that case, // the row also has an `id` property, which is the note that contains the // resource. // // It means a result set may include multiple results with the same `id` // property, if it contains one or more resources that are in a note that's // already in the result set. For that reason, when we need a unique ID for the // result, we use this function - which returns either the item_id, if present, // or the note ID. const getResultId = (result: GotoAnythingSearchResult) => { // This ID used as a DOM ID for accessibility purposes, so it is prefixed to prevent // name collisions. return `goto-anything-result-${result.item_id ? result.item_id : result.id}`; }; const itemListId = 'goto-anything-item-list'; class GotoAnything { public dispatch: Dispatch; public static Dialog: React.ComponentType; public static manifest: PluginManifest; public onTrigger(event: { userData: GotoAnythingUserData }) { this.dispatch({ type: 'PLUGINLEGACY_DIALOG_SET', open: true, pluginName: PLUGIN_NAME, userData: event.userData, }); } } class DialogComponent extends React.PureComponent { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous style blocks; styles_ holds different shapes per styling key private styles_: any; private inputRef: React.RefObject; private itemListRef: React.RefObject>; private listUpdateQueue_: AsyncActionQueue; private markupToHtml_: MarkupToHtml; private userCallback_: UserDataCallback|null = null; private mode_: Mode; private alwaysShowHelp_: boolean; private lastMouseCoords_: { x: number; y: number } | null = null; public constructor(props: Props) { super(props); const startString = props?.userData?.startString ? props?.userData?.startString : ''; this.userCallback_ = props?.userData?.callback; this.listUpdateQueue_ = new AsyncActionQueue(100); this.mode_ = props?.userData?.mode ? props.userData.mode : Mode.Default; this.alwaysShowHelp_ = !!props?.userData?.alwaysShowHelp; this.state = { query: startString, results: [], selectedItemId: null, keywords: [], listType: BaseModel.TYPE_NOTE, showHelp: false, resultsInBody: false, commandArgs: [], }; this.styles_ = {}; this.inputRef = React.createRef(); this.itemListRef = React.createRef(); this.input_onChange = this.input_onChange.bind(this); this.input_onKeyDown = this.input_onKeyDown.bind(this); this.renderItem = this.renderItem.bind(this); this.listItem_onClick = this.listItem_onClick.bind(this); this.listItem_onMouseMove = this.listItem_onMouseMove.bind(this); this.helpButton_onClick = this.helpButton_onClick.bind(this); if (startString) this.scheduleListUpdate(); } public style() { const styleKey = [this.props.themeId, this.state.listType, this.state.resultsInBody ? '1' : '0'].join('-'); if (this.styles_[styleKey]) return this.styles_[styleKey]; const theme = themeStyle(this.props.themeId); let itemHeight = this.state.resultsInBody ? 84 : 64; if (this.state.listType === BaseModel.TYPE_COMMAND) { itemHeight = 40; } this.styles_[styleKey] = { dialogBox: { ...theme.dialogBox, width: 'min(600px, calc(100vw - 48px))', minWidth: 0, maxWidth: '100%' }, input: { ...theme.inputStyle, flex: 1 }, 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', }, inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' }, }; delete this.styles_[styleKey].dialogBox.maxHeight; const rowTextStyle = { fontSize: theme.fontSize, color: theme.color, fontFamily: theme.fontFamily, whiteSpace: 'nowrap', opacity: 0.7, userSelect: 'none', }; const rowTitleStyle = { ...rowTextStyle, fontSize: rowTextStyle.fontSize * 1.4, marginBottom: this.state.resultsInBody ? 6 : 4, color: theme.colorFaded }; const rowFragmentsStyle = { ...rowTextStyle, fontSize: rowTextStyle.fontSize * 1.2, marginBottom: this.state.resultsInBody ? 8 : 6, color: theme.colorFaded }; this.styles_[styleKey].rowSelected = { ...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_[styleKey]; } public componentDidMount() { this.props.dispatch({ type: 'VISIBLE_DIALOGS_ADD', name: 'gotoAnything', }); } public componentWillUnmount() { void this.listUpdateQueue_.reset(); this.props.dispatch({ type: 'VISIBLE_DIALOGS_REMOVE', name: 'gotoAnything', }); } private modalLayer_onDismiss = () => { this.props.dispatch({ pluginName: PLUGIN_NAME, type: 'PLUGINLEGACY_DIALOG_SET', open: false, }); }; private helpButton_onClick() { this.setState({ showHelp: !this.state.showHelp }); } private input_onChange(event: React.ChangeEvent) { this.setState({ query: event.target.value }); this.scheduleListUpdate(); } public scheduleListUpdate() { this.listUpdateQueue_.push(() => this.updateList()); } public async keywords(searchQuery: string) { const parsedQuery = await SearchEngine.instance().parseQuery(searchQuery); return SearchEngine.instance().allParsedQueryTerms(parsedQuery); } public markupToHtml() { if (this.markupToHtml_) return this.markupToHtml_; this.markupToHtml_ = markupLanguageUtils.newMarkupToHtml(); return this.markupToHtml_; } private parseCommandQuery(query: string): CommandQuery { const fullQuery = query; const splitted = fullQuery.split(/\s+/); return { name: splitted.length ? splitted[0] : '', args: splitted.slice(1), }; } public async updateList() { let resultsInBody = false; if (!this.state.query) { this.setState({ results: [], keywords: [] }); } else { let results: GotoAnythingSearchResult[] = []; let listType = null; let searchQuery = ''; let keywords = null; let commandArgs: string[] = []; if (this.state.query.indexOf(':') === 0) { // COMMANDS const commandQuery = this.parseCommandQuery(this.state.query.substr(1)); listType = BaseModel.TYPE_COMMAND; keywords = [commandQuery.name]; commandArgs = commandQuery.args; const commandResults = CommandService.instance().searchCommands(commandQuery.name, true); results = commandResults.map((result: CommandSearchResult) => { return { id: result.commandName, title: result.title, parent_id: null as string, fields: [] as string[], type: BaseModel.TYPE_COMMAND, }; }); } else if (this.state.query.indexOf('#') === 0) { // TAGS listType = BaseModel.TYPE_TAG; searchQuery = `*${this.state.query.split(' ')[0].substr(1).trim()}*`; results = await Tag.searchAllWithNotes({ titlePattern: searchQuery }) as unknown as GotoAnythingSearchResult[]; } else if (this.state.query.indexOf('@') === 0) { // FOLDERS listType = BaseModel.TYPE_FOLDER; searchQuery = this.state.query.substr(1).trim(); const normalizedSearchQuery = removeDiacritics(searchQuery).toLowerCase(); results = []; for (const folder of this.props.folders) { if (folder.deleted_time) continue; const normalizedTitle = removeDiacritics(folder.title).toLowerCase(); if (normalizedSearchQuery && normalizedTitle.indexOf(normalizedSearchQuery) < 0) continue; const path = Folder.folderPathString(this.props.folders, folder.parent_id); results.push({ ...folder, path: path ? path : '/' } as unknown as GotoAnythingSearchResult); } } else { // Note TITLE or BODY listType = BaseModel.TYPE_NOTE; searchQuery = gotoAnythingStyleQuery(this.state.query); // SearchEngine returns the title normalized, that is why we need to // override this field below with the original title results = (await SearchEngine.instance().search(searchQuery)) as unknown as GotoAnythingSearchResult[]; resultsInBody = !!results.find((row) => row.fields.includes('body')); if (this.mode_ === Mode.TitleOnly) { resultsInBody = false; results = results.filter(r => { return r.fields.includes('title'); }); } const resourceIds = results.filter(r => r.item_type === ModelType.Resource).map(r => r.item_id); const resources = await Resource.resourceOcrTextsByIds(resourceIds); if (!resultsInBody || this.state.query.length <= 1) { const notes = await Note.byIds(results.map(result => result.id), { fields: ['id', 'title'] }); for (let i = 0; i < results.length; i++) { const row = results[i]; const path = Folder.folderPathString(this.props.folders, row.parent_id); const originalNote = notes.find(note => note.id === row.id); results[i] = { ...row, path: path, title: originalNote.title }; } } else { const limit = 20; // Note: any filtering must be done **before** fetching the notes, because we're // going to apply a limit to the number of fetched notes. // https://github.com/laurent22/joplin/issues/9944 if (!this.props.showCompletedTodos) { results = results.filter((row: GotoAnythingSearchResult & { is_todo?: boolean; todo_completed?: number }) => !row.is_todo || !row.todo_completed); } const notes = await Note.byIds(results.map(result => result.id).slice(0, limit), { fields: ['id', 'body', 'markup_language', 'is_todo', 'todo_completed', 'title'] }); const notesById = notes.reduce((obj: Record, { id, body, markup_language, title }) => { obj[id] = { id, body, markup_language, title }; return obj; }, {}); // Filter out search results that are associated with non-existing notes. // https://github.com/laurent22/joplin/issues/5417 results = results.filter(r => !!notesById[r.id]) .map(r => ({ ...r, title: notesById[r.id].title })); const keywordRegexes = (await this.keywords(searchQuery)).map(term => { if (typeof term === 'string') { return new RegExp(escapeRegExp(term), 'ig'); } else if (term.valueRegex) { return new RegExp(removeDiacritics(term.valueRegex), 'ig'); } else { return new RegExp(escapeRegExp(term.value), 'ig'); } }); for (let i = 0; i < results.length; i++) { const row = results[i]; const path = Folder.folderPathString(this.props.folders, row.parent_id); if (row.fields.includes('body')) { let fragments = '...'; const loadFragments = (markupLanguage: MarkupLanguage, content: string) => { const indices: [number, number][] = []; const body = this.markupToHtml().stripMarkup(markupLanguage, content, { collapseWhiteSpaces: true }); const normalizedBody = removeDiacritics(body); // Iterate over all matches in the body for each search keyword for (const keywordRegex of keywordRegexes) { for (const match of normalizedBody.matchAll(keywordRegex)) { // 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: [number, number]) => body.slice(f[0], f[1])).join(' ... '); // Add trailing ellipsis if the final fragment doesn't end where the note is ending if (mergedIndices.length && mergedIndices[mergedIndices.length - 1][1] !== body.length) fragments += ' ...'; }; if (i < limit) { // Display note fragments of search keyword matches const { markupLanguage, content } = getContentMarkupLanguageAndBody( row, notesById, resources, ); // Don't load fragments for long notes -- doing so can lead to UI freezes. if (content.length < 100_000) { loadFragments(markupLanguage, content); } } results[i] = { ...row, path, fragments }; } else { results[i] = { ...row, path: path, fragments: '' }; } } } } // make list scroll to top in every search this.makeItemIndexVisible(0); const keywordsWithoutEmptyString = keywords?.filter(v => !!v); this.setState({ listType: listType, results: results, keywords: keywordsWithoutEmptyString ? keywordsWithoutEmptyString : await this.keywords(searchQuery), selectedItemId: results.length === 0 ? null : getResultId(results[0]), resultsInBody: resultsInBody, commandArgs: commandArgs, }); } } private makeItemIndexVisible(index: number) { // Looks like it's not always defined // https://github.com/laurent22/joplin/issues/5184#issuecomment-879714850 if (!this.itemListRef || !this.itemListRef.current) { logger.warn('Trying to set item index but the item list is not defined. Index: ', index); return; } this.itemListRef.current.makeItemIndexVisible(index); } public async gotoItem(item: GotoAnythingSearchResult & { commandArgs?: string[] }) { this.props.dispatch({ pluginName: PLUGIN_NAME, type: 'PLUGINLEGACY_DIALOG_SET', open: false, }); if (this.userCallback_) { logger.info('gotoItem: user callback', item); this.userCallback_.resolve({ type: this.state.listType, item: { ...item }, }); return; } if (item.type === BaseModel.TYPE_COMMAND) { logger.info('gotoItem: execute command', item); // Defer so the close above renders before the command runs — otherwise // commands that reopen the dialog (e.g. linkToNote) get batched with the // close, the dialog never remounts, and its constructor-read userData is // stale. See https://github.com/laurent22/joplin/issues/15780 setTimeout(() => { void CommandService.instance().execute(item.id, ...item.commandArgs); void focusEditorIfEditorCommand(item.id, CommandService.instance()); }, 0); return; } if (this.state.listType === BaseModel.TYPE_NOTE || this.state.listType === BaseModel.TYPE_FOLDER) { const folderPath = await Folder.folderPath(this.props.folders, item.parent_id); for (const folder of folderPath) { this.props.dispatch({ type: 'FOLDER_SET_COLLAPSED', id: folder.id, collapsed: false, }); } } if (this.state.listType === BaseModel.TYPE_NOTE) { logger.info('gotoItem: note', item); this.props.dispatch({ type: 'FOLDER_AND_NOTE_SELECT', folderId: item.parent_id, noteId: item.id, }); CommandService.instance().scheduleExecute('focusElement', 'noteBody'); } else if (this.state.listType === BaseModel.TYPE_TAG) { logger.info('gotoItem: tag', item); this.props.dispatch({ type: 'TAG_SELECT', id: item.id, }); } else if (this.state.listType === BaseModel.TYPE_FOLDER) { logger.info('gotoItem: folder', item); this.props.dispatch({ type: 'FOLDER_SELECT', id: item.id, }); } } private listItem_onClick(event: React.MouseEvent) { const targetResultId = event.currentTarget.getAttribute('id'); void this.gotoItem(this.selectedItem(targetResultId)); } private listItem_onMouseMove(event: React.MouseEvent) { // Only update selection when the mouse actually moves. Without this, a stationary // cursor over a row that gets re-mounted (e.g. after keyboard navigation re-renders // the list) would steal the keyboard selection. if (this.lastMouseCoords_ && this.lastMouseCoords_.x === event.clientX && this.lastMouseCoords_.y === event.clientY) { return; } this.lastMouseCoords_ = { x: event.clientX, y: event.clientY }; const targetResultId = event.currentTarget.getAttribute('id'); if (targetResultId && targetResultId !== this.state.selectedItemId) { this.setState({ selectedItemId: targetResultId }); } } public renderItem(item: GotoAnythingSearchResult, index: number) { const theme = themeStyle(this.props.themeId); const style = this.style(); const resultId = getResultId(item); const isSelected = resultId === this.state.selectedItemId; const rowStyle = isSelected ? style.rowSelected : style.row; const wrapKeywordMatches = (unescapedContent: string) => { return surroundKeywords( this.state.keywords as KeywordType, unescapedContent, ``, '', { escapeHtml: true }, ); }; const titleHtml = wrapKeywordMatches(item.title); const fragmentsHtml = !item.fragments ? null : wrapKeywordMatches(item.fragments); const folderIcon = ; const pathComp = !item.path ? null :
{folderIcon} {item.path}
; const fragmentComp = !fragmentsHtml ? null :
; return (
{this.mode_ === Mode.TitleOnly ? null : fragmentComp} {this.mode_ === Mode.TitleOnly ? null : pathComp}
); } public selectedItemIndex(results: GotoAnythingSearchResult[] = undefined, itemId: string = undefined) { if (typeof results === 'undefined') results = this.state.results; if (typeof itemId === 'undefined') itemId = this.state.selectedItemId; for (let i = 0; i < results.length; i++) { const r = results[i]; if (getResultId(r) === itemId) return i; } return -1; } public selectedItem(itemId: string = undefined) { const index = this.selectedItemIndex(undefined, itemId); if (index < 0) return null; return { ...this.state.results[index], commandArgs: this.state.commandArgs }; } private input_onKeyDown(event: React.KeyboardEvent) { const keyCode = event.keyCode; if (this.state.results.length > 0 && (keyCode === 40 || keyCode === 38)) { // DOWN / UP event.preventDefault(); const inc = keyCode === 38 ? -1 : +1; let index = this.selectedItemIndex(); if (index < 0) return; // Not possible, but who knows index += inc; if (index < 0) index = 0; if (index >= this.state.results.length) index = this.state.results.length - 1; const newId = getResultId(this.state.results[index]); this.makeItemIndexVisible(index); this.setState({ selectedItemId: newId }); } if (keyCode === 13) { // ENTER event.preventDefault(); const item = this.selectedItem(); if (!item) return; void this.gotoItem(item); } } private calculateMaxHeight(itemHeight: number) { const listContainer: HTMLElement|null = this.itemListRef.current?.container; const containerWindow = listContainer?.ownerDocument?.defaultView ?? window; const maxItemCount = Math.floor((0.7 * containerWindow.innerHeight) / itemHeight); return maxItemCount * itemHeight; } public renderList() { const style = this.style(); if (!this.state.results.length && this.state.query) { const theme = themeStyle(this.props.themeId); return (
{_('No matching notes, notebooks, tags or commands.')}
); } const itemListStyle = { marginTop: 5, height: Math.min(style.itemHeight * this.state.results.length, this.calculateMaxHeight(style.itemHeight)), }; return ( ); } private helpText() { if (this.mode_ === Mode.TitleOnly) { return _('Type a note title to search for it.'); } else { return _('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. Or type : to search for commands.'); } } public render() { const style = this.style(); const helpTextId = 'goto-anything-help-text'; const helpComp = ( ); return ( {helpComp}
{this.alwaysShowHelp_ ? null : }
{this.renderList()}
); } } const mapStateToProps = (state: AppState) => { return { folders: state.folders, themeId: state.settings.theme, showCompletedTodos: state.settings.showCompletedTodos, highlightedWords: state.highlightedWords, }; }; GotoAnything.Dialog = connect(mapStateToProps)(DialogComponent); GotoAnything.manifest = { name: PLUGIN_NAME, menuItems: [ { id: 'gotoAnything', name: 'main', parent: 'go', label: _('Goto Anything...'), accelerator: () => KeymapService.instance().getAccelerator('gotoAnything'), screens: ['Main'], }, { id: 'commandPalette', name: 'main', parent: 'tools', label: _('Command palette'), accelerator: () => KeymapService.instance().getAccelerator('commandPalette'), screens: ['Main'], userData: { startString: ':', }, }, { id: 'controlledApi', }, ], }; export default GotoAnything;