1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Electron: Resolves #144, Resolves #311: Highlight search results and search in real time. Associated Ctrl+F with searching.

This commit is contained in:
Laurent Cozic 2018-03-19 23:04:48 +00:00
parent d5c2982093
commit 67608e29c8
15 changed files with 360 additions and 71 deletions

View File

@ -368,11 +368,11 @@ class Application extends BaseApplication {
}, { }, {
label: _('Search in all the notes'), label: _('Search in all the notes'),
screens: ['Main'], screens: ['Main'],
accelerator: 'F6', accelerator: 'CommandOrControl+F',
click: () => { click: () => {
this.dispatch({ this.dispatch({
type: 'WINDOW_COMMAND', type: 'WINDOW_COMMAND',
name: 'search', name: 'focus_search',
}); });
}, },
}], }],

View File

@ -6,6 +6,63 @@ const { _ } = require('lib/locale.js');
class HeaderComponent extends React.Component { class HeaderComponent extends React.Component {
constructor() {
super();
this.state = {
searchQuery: '',
};
this.scheduleSearchChangeEventIid_ = null;
this.searchOnQuery_ = null;
this.searchElement_ = null;
const triggerOnQuery = (query) => {
clearTimeout(this.scheduleSearchChangeEventIid_);
if (this.searchOnQuery_) this.searchOnQuery_(query);
this.scheduleSearchChangeEventIid_ = null;
}
this.search_onChange = (event) => {
this.setState({ searchQuery: event.target.value });
if (this.scheduleSearchChangeEventIid_) clearTimeout(this.scheduleSearchChangeEventIid_);
this.scheduleSearchChangeEventIid_ = setTimeout(() => {
triggerOnQuery(this.state.searchQuery);
}, 500);
};
this.search_onClear = (event) => {
this.setState({ searchQuery: '' });
triggerOnQuery('');
}
}
async componentWillReceiveProps(nextProps) {
if (nextProps.windowCommand) {
this.doCommand(nextProps.windowCommand);
}
}
async doCommand(command) {
if (!command) return;
let commandProcessed = true;
if (command.name === 'focus_search' && this.searchElement_) {
this.searchElement_.focus();
} else {
commandProcessed = false;
}
if (commandProcessed) {
this.props.dispatch({
type: 'WINDOW_COMMAND',
name: null,
});
}
}
back_click() { back_click() {
this.props.dispatch({ type: 'NAV_BACK' }); this.props.dispatch({ type: 'NAV_BACK' });
} }
@ -40,6 +97,59 @@ class HeaderComponent extends React.Component {
</a> </a>
} }
makeSearch(key, style, options, state) {
const inputStyle = {
display: 'flex',
flex: 1,
paddingLeft: 4,
paddingRight: 4,
color: style.color,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
};
const searchButton = {
paddingLeft: 4,
paddingRight: 4,
paddingTop: 2,
paddingBottom: 2,
textDecoration: 'none',
};
const iconStyle = {
display: 'flex',
fontSize: Math.round(style.fontSize) * 1.2,
color: style.color,
};
const containerStyle = {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
};
const iconName = state.searchQuery ? 'fa-times' : 'fa-search';
const icon = <i style={iconStyle} className={"fa " + iconName}></i>
if (options.onQuery) this.searchOnQuery_ = options.onQuery;
return (
<div key={key} style={containerStyle}>
<input
type="text"
style={inputStyle}
placeholder={options.title}
value={state.searchQuery}
onChange={this.search_onChange}
ref={elem => this.searchElement_ = elem}
/>
<a
href="#"
style={searchButton}
onClick={this.search_onClear}
>{icon}</a>
</div>);
}
render() { render() {
const style = Object.assign({}, this.props.style); const style = Object.assign({}, this.props.style);
const theme = themeStyle(this.props.theme); const theme = themeStyle(this.props.theme);
@ -50,9 +160,9 @@ class HeaderComponent extends React.Component {
style.borderBottom = '1px solid ' + theme.dividerColor; style.borderBottom = '1px solid ' + theme.dividerColor;
style.boxSizing = 'border-box'; style.boxSizing = 'border-box';
const buttons = []; const items = [];
const buttonStyle = { const itemStyle = {
height: theme.headerHeight, height: theme.headerHeight,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -67,19 +177,24 @@ class HeaderComponent extends React.Component {
}; };
if (showBackButton) { if (showBackButton) {
buttons.push(this.makeButton('back', buttonStyle, { title: _('Back'), onClick: () => this.back_click(), iconName: 'fa-chevron-left ' })); items.push(this.makeButton('back', itemStyle, { title: _('Back'), onClick: () => this.back_click(), iconName: 'fa-chevron-left ' }));
} }
if (this.props.buttons) { if (this.props.items) {
for (let i = 0; i < this.props.buttons.length; i++) { for (let i = 0; i < this.props.items.length; i++) {
const o = this.props.buttons[i]; const item = this.props.items[i];
buttons.push(this.makeButton('btn_' + i + '_' + o.title, buttonStyle, o));
if (item.type === 'search') {
items.push(this.makeSearch('item_' + i + '_search', itemStyle, item, this.state));
} else {
items.push(this.makeButton('item_' + i + '_' + item.title, itemStyle, item));
}
} }
} }
return ( return (
<div className="header" style={style}> <div className="header" style={style}>
{ buttons } { items }
</div> </div>
); );
} }
@ -87,7 +202,10 @@ class HeaderComponent extends React.Component {
} }
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
return { theme: state.settings.theme }; return {
theme: state.settings.theme,
windowCommand: state.windowCommand,
};
}; };
const Header = connect(mapStateToProps)(HeaderComponent); const Header = connect(mapStateToProps)(HeaderComponent);

View File

@ -140,33 +140,27 @@ class MainScreenComponent extends React.Component {
}, },
}); });
} else if (command.name === 'search') { } else if (command.name === 'search') {
this.setState({
promptOptions: { if (!this.searchId_) this.searchId_ = uuid.create();
label: _('Search:'),
onClose: async (answer) => {
if (answer !== null) {
const searchId = uuid.create();
this.props.dispatch({ this.props.dispatch({
type: 'SEARCH_ADD', type: 'SEARCH_UPDATE',
search: { search: {
id: searchId, id: this.searchId_,
title: answer, title: command.query,
query_pattern: answer, query_pattern: command.query,
query_folder_id: null, query_folder_id: null,
type_: BaseModel.TYPE_SEARCH, type_: BaseModel.TYPE_SEARCH,
}, },
}); });
if (command.query) {
this.props.dispatch({ this.props.dispatch({
type: 'SEARCH_SELECT', type: 'SEARCH_SELECT',
id: searchId, id: this.searchId_,
}); });
} }
this.setState({ promptOptions: null });
}
},
});
} else if (command.name === 'toggleVisiblePanes') { } else if (command.name === 'toggleVisiblePanes') {
this.toggleVisiblePanes(); this.toggleVisiblePanes();
} else if (command.name === 'showModalMessage') { } else if (command.name === 'showModalMessage') {
@ -298,41 +292,42 @@ class MainScreenComponent extends React.Component {
const selectedFolderId = this.props.selectedFolderId; const selectedFolderId = this.props.selectedFolderId;
const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId(); const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId();
const headerButtons = []; const headerItems = [];
headerButtons.push({ headerItems.push({
title: _('New note'), title: _('New note'),
iconName: 'fa-file-o', iconName: 'fa-file-o',
enabled: !!folders.length && !onConflictFolder, enabled: !!folders.length && !onConflictFolder,
onClick: () => { this.doCommand({ name: 'newNote' }) }, onClick: () => { this.doCommand({ name: 'newNote' }) },
}); });
headerButtons.push({ headerItems.push({
title: _('New to-do'), title: _('New to-do'),
iconName: 'fa-check-square-o', iconName: 'fa-check-square-o',
enabled: !!folders.length && !onConflictFolder, enabled: !!folders.length && !onConflictFolder,
onClick: () => { this.doCommand({ name: 'newTodo' }) }, onClick: () => { this.doCommand({ name: 'newTodo' }) },
}); });
headerButtons.push({ headerItems.push({
title: _('New notebook'), title: _('New notebook'),
iconName: 'fa-folder-o', iconName: 'fa-folder-o',
onClick: () => { this.doCommand({ name: 'newNotebook' }) }, onClick: () => { this.doCommand({ name: 'newNotebook' }) },
}); });
headerButtons.push({ headerItems.push({
title: _('Search'),
iconName: 'fa-search',
onClick: () => { this.doCommand({ name: 'search' }) },
});
headerButtons.push({
title: _('Layout'), title: _('Layout'),
iconName: 'fa-columns', iconName: 'fa-columns',
enabled: !!notes.length, enabled: !!notes.length,
onClick: () => { this.doCommand({ name: 'toggleVisiblePanes' }) }, onClick: () => { this.doCommand({ name: 'toggleVisiblePanes' }) },
}); });
headerItems.push({
title: _('Search...'),
iconName: 'fa-search',
onQuery: (query) => { this.doCommand({ name: 'search', query: query }) },
type: 'search',
});
if (!this.promptOnClose_) { if (!this.promptOnClose_) {
this.promptOnClose_ = (answer, buttonType) => { this.promptOnClose_ = (answer, buttonType) => {
return this.state.promptOptions.onClose(answer, buttonType); return this.state.promptOptions.onClose(answer, buttonType);
@ -389,7 +384,7 @@ class MainScreenComponent extends React.Component {
visible={!!this.state.promptOptions} visible={!!this.state.promptOptions}
buttons={promptOptions && ('buttons' in promptOptions) ? promptOptions.buttons : null} buttons={promptOptions && ('buttons' in promptOptions) ? promptOptions.buttons : null}
inputType={promptOptions && ('inputType' in promptOptions) ? promptOptions.inputType : null} /> inputType={promptOptions && ('inputType' in promptOptions) ? promptOptions.inputType : null} />
<Header style={styles.header} showBackButton={false} buttons={headerButtons} /> <Header style={styles.header} showBackButton={false} items={headerItems} />
{messageComp} {messageComp}
<SideBar style={styles.sideBar} /> <SideBar style={styles.sideBar} />
<NoteList style={styles.noteList} /> <NoteList style={styles.noteList} />

View File

@ -11,6 +11,8 @@ const MenuItem = bridge().MenuItem;
const eventManager = require('../eventManager'); const eventManager = require('../eventManager');
const InteropService = require('lib/services/InteropService'); const InteropService = require('lib/services/InteropService');
const InteropServiceHelper = require('../InteropServiceHelper.js'); const InteropServiceHelper = require('../InteropServiceHelper.js');
const Search = require('lib/models/Search');
const Mark = require('mark.js/dist/mark.min.js');
class NoteListComponent extends React.Component { class NoteListComponent extends React.Component {
@ -164,6 +166,12 @@ class NoteListComponent extends React.Component {
const hPadding = 10; const hPadding = 10;
let highlightedWords = [];
if (this.props.notesParentType === 'Search') {
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
highlightedWords = search ? Search.keywords(search.query_pattern) : [];
}
let style = Object.assign({ width: width }, this.style().listItem); let style = Object.assign({ width: width }, this.style().listItem);
if (this.props.selectedNoteIds.indexOf(item.id) >= 0) { if (this.props.selectedNoteIds.indexOf(item.id) >= 0) {
@ -182,6 +190,28 @@ class NoteListComponent extends React.Component {
listItemTitleStyle.paddingLeft = !checkbox ? hPadding : 4; listItemTitleStyle.paddingLeft = !checkbox ? hPadding : 4;
if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, this.style().listItemTitleCompleted); if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, this.style().listItemTitleCompleted);
let displayTitle = Note.displayTitle(item);
let titleComp = null;
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
const mark = new Mark(titleElement, {
exclude: ['img'],
acrossElements: true,
});
mark.mark(highlightedWords);
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
// is a span tag that we created and that contains data that's been inserted as plain text
// with `textContent` so it cannot contain any XSS attacks. We use this feature because
// mark.js can only deal with DOM elements.
// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
titleComp = <span dangerouslySetInnerHTML={{ __html: titleElement.outerHTML }}></span>
} else {
titleComp = <span>{displayTitle}</span>
}
// Need to include "todo_completed" in key so that checkbox is updated when // Need to include "todo_completed" in key so that checkbox is updated when
// item is changed via sync. // item is changed via sync.
return <div key={item.id + '_' + item.todo_completed} style={style}> return <div key={item.id + '_' + item.todo_completed} style={style}>
@ -196,7 +226,7 @@ class NoteListComponent extends React.Component {
onDragStart={(event) => onDragStart(event) } onDragStart={(event) => onDragStart(event) }
data-id={item.id} data-id={item.id}
> >
{Note.displayTitle(item)} {titleComp}
</a> </a>
</div> </div>
} }
@ -239,7 +269,9 @@ const mapStateToProps = (state) => {
folders: state.folders, folders: state.folders,
selectedNoteIds: state.selectedNoteIds, selectedNoteIds: state.selectedNoteIds,
theme: state.settings.theme, theme: state.settings.theme,
// uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, notesParentType: state.notesParentType,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
}; };
}; };

View File

@ -1,5 +1,7 @@
const React = require('react'); const React = require('react');
const Note = require('lib/models/Note.js'); const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const Search = require('lib/models/Search.js');
const { time } = require('lib/time-utils.js'); const { time } = require('lib/time-utils.js');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const { IconButton } = require('./IconButton.min.js'); const { IconButton } = require('./IconButton.min.js');
@ -195,7 +197,9 @@ class NoteTextComponent extends React.Component {
this.editorSetScrollTop(1); this.editorSetScrollTop(1);
this.restoreScrollTop_ = 0; this.restoreScrollTop_ = 0;
if (note) { // If a search is in progress we don't focus any field otherwise it will
// take the focus out of the search box.
if (note && this.props.notesParentType !== 'Search') {
const focusSettingName = !!note.is_todo ? 'newTodoFocus' : 'newNoteFocus'; const focusSettingName = !!note.is_todo ? 'newTodoFocus' : 'newNoteFocus';
if (Setting.value(focusSettingName) === 'title') { if (Setting.value(focusSettingName) === 'title') {
@ -366,7 +370,7 @@ class NoteTextComponent extends React.Component {
webviewReady: true, webviewReady: true,
}); });
// if (Setting.value('env') === 'dev') this.webview_.openDevTools(); if (Setting.value('env') === 'dev') this.webview_.openDevTools();
} }
webview_ref(element) { webview_ref(element) {
@ -393,6 +397,18 @@ class NoteTextComponent extends React.Component {
if (this.editor_) { if (this.editor_) {
this.editor_.editor.renderer.on('afterRender', this.onAfterEditorRender_); this.editor_.editor.renderer.on('afterRender', this.onAfterEditorRender_);
const cancelledKeys = ['Ctrl+F', 'Ctrl+T', 'Ctrl+P', 'Ctrl+Q', 'Ctrl+L', 'Ctrl+,'];
for (let i = 0; i < cancelledKeys.length; i++) {
const k = cancelledKeys[i];
this.editor_.editor.commands.bindKey(k, () => {
// HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing
// an exception from this undocumented function seems to cancel it without any
// side effect.
// https://stackoverflow.com/questions/36075846
throw new Error('HACK: Overriding Ace Editor shortcut: ' + k);
});
}
} }
} }
@ -643,6 +659,10 @@ class NoteTextComponent extends React.Component {
const html = this.mdToHtml().render(bodyToRender, theme, mdOptions); const html = this.mdToHtml().render(bodyToRender, theme, mdOptions);
this.webview_.send('setHtml', html); this.webview_.send('setHtml', html);
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
const keywords = search ? Search.keywords(search.query_pattern) : [];
this.webview_.send('setMarkers', keywords);
} }
const toolbarItems = []; const toolbarItems = [];
@ -695,6 +715,24 @@ class NoteTextComponent extends React.Component {
ref={(elem) => { this.webview_ref(elem); } } ref={(elem) => { this.webview_ref(elem); } }
/> />
// const markers = [{
// startRow: 2,
// startCol: 3,
// endRow: 2,
// endCol: 6,
// type: 'text',
// className: 'test-marker'
// }];
// markers={markers}
// editorProps={{$useWorker: false}}
// #note-editor .test-marker {
// background-color: red;
// color: yellow;
// position: absolute;
// }
const editorRootStyle = Object.assign({}, editorStyle); const editorRootStyle = Object.assign({}, editorStyle);
delete editorRootStyle.width; delete editorRootStyle.width;
delete editorRootStyle.height; delete editorRootStyle.height;
@ -751,6 +789,9 @@ const mapStateToProps = (state) => {
syncStarted: state.syncStarted, syncStarted: state.syncStarted,
newNote: state.newNote, newNote: state.newNote,
windowCommand: state.windowCommand, windowCommand: state.windowCommand,
notesParentType: state.notesParentType,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
}; };
}; };

View File

@ -345,17 +345,17 @@ class SideBarComponent extends React.Component {
); );
} }
if (this.props.searches.length) { // if (this.props.searches.length) {
items.push(this.makeHeader("searchHeader", _("Searches"), "fa-search")); // items.push(this.makeHeader("searchHeader", _("Searches"), "fa-search"));
const searchItems = shared.renderSearches(this.props, this.searchItem.bind(this)); // const searchItems = shared.renderSearches(this.props, this.searchItem.bind(this));
items.push( // items.push(
<div className="searches" key="search_items"> // <div className="searches" key="search_items">
{searchItems} // {searchItems}
</div> // </div>
); // );
} // }
let lines = Synchronizer.reportToLines(this.props.syncReport); let lines = Synchronizer.reportToLines(this.props.syncReport);
const syncReportText = []; const syncReportText = [];

View File

@ -1,6 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8">
<style> <style>
body { body {
overflow: hidden; overflow: hidden;
@ -13,12 +15,18 @@
padding-right: 10px; padding-right: 10px;
} }
mark {
background: #CF3F00;
color: white;
}
.katex { font-size: 1.3em; } /* This controls the global Katex font size*/ .katex { font-size: 1.3em; } /* This controls the global Katex font size*/
</style> </style>
</head> </head>
<body id="body"> <body id="body">
<div id="hlScriptContainer"></div> <div id="hlScriptContainer"></div>
<div id="markScriptContainer"></div>
<div id="content" ondragstart="return false;" ondrop="return false;"></div> <div id="content" ondragstart="return false;" ondrop="return false;"></div>
<script> <script>
@ -160,6 +168,36 @@
setPercentScroll(percent); setPercentScroll(percent);
}); });
let mark_ = null;
function setMarkers(keywords) {
if (!mark_) {
mark_ = new Mark(document.getElementById('content'), {
exclude: ['img'],
acrossElements: true,
});
}
mark_.mark(keywords);
}
let markLoaded_ = false;
ipcRenderer.on('setMarkers', (event, keywords) => {
if (!keywords.length && !markLoaded_) return;
if (!markLoaded_) {
markLoaded_ = true;
const script = document.createElement('script');
script.onload = function() {
setMarkers(keywords);
};
script.src = '../../node_modules/mark.js/dist/mark.min.js';
document.getElementById('markScriptContainer').appendChild(script);
} else {
setMarkers(keywords);
}
});
function maxScrollTop() { function maxScrollTop() {
return Math.max(0, contentElement.scrollHeight - contentElement.clientHeight); return Math.max(0, contentElement.scrollHeight - contentElement.clientHeight);
} }

View File

@ -17,6 +17,10 @@
.smalltalk .page { .smalltalk .page {
max-width: 30em; max-width: 30em;
} }
mark {
background: #CF3F00;
color: white;
}
</style> </style>
</head> </head>
<body> <body>

View File

@ -3767,6 +3767,11 @@
"integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
"dev": true "dev": true
}, },
"mark.js": {
"version": "8.11.1",
"resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
"integrity": "sha1-GA8fnr74sOY45BZq1S24eb6y/8U="
},
"markdown-it": { "markdown-it": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.0.tgz",

View File

@ -71,6 +71,7 @@
"katex": "^0.9.0-beta1", "katex": "^0.9.0-beta1",
"levenshtein": "^1.0.5", "levenshtein": "^1.0.5",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"mark.js": "^8.11.1",
"markdown-it": "^8.4.0", "markdown-it": "^8.4.0",
"markdown-it-katex": "^2.0.3", "markdown-it-katex": "^2.0.3",
"md5": "^2.2.1", "md5": "^2.2.1",

View File

@ -260,7 +260,7 @@ class BaseApplication {
const newState = store.getState(); const newState = store.getState();
let refreshNotes = false; let refreshNotes = false;
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE') { if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE' || (action.type === 'SEARCH_UPDATE' && newState.notesParentType === 'Folder')) {
Setting.setValue('activeFolderId', newState.selectedFolderId); Setting.setValue('activeFolderId', newState.selectedFolderId);
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null; this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
refreshNotes = true; refreshNotes = true;

View File

@ -3,6 +3,7 @@ const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode; const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js'); const Resource = require('lib/models/Resource.js');
const ModelCache = require('lib/ModelCache'); const ModelCache = require('lib/ModelCache');
const ObjectUtils = require('lib/ObjectUtils');
const { shim } = require('lib/shim.js'); const { shim } = require('lib/shim.js');
const md5 = require('md5'); const md5 = require('md5');
const MdToHtml_Katex = require('lib/MdToHtml_Katex'); const MdToHtml_Katex = require('lib/MdToHtml_Katex');
@ -336,6 +337,7 @@ class MdToHtml {
// Insert the extra CSS at the top of the HTML // Insert the extra CSS at the top of the HTML
if (!ObjectUtils.isEmpty(extraCssBlocks)) {
const temp = ['<style>']; const temp = ['<style>'];
for (let n in extraCssBlocks) { for (let n in extraCssBlocks) {
if (!extraCssBlocks.hasOwnProperty(n)) continue; if (!extraCssBlocks.hasOwnProperty(n)) continue;
@ -344,6 +346,7 @@ class MdToHtml {
temp.push('</style>'); temp.push('</style>');
output = temp.concat(output); output = temp.concat(output);
}
return output.join(''); return output.join('');
} }

View File

@ -53,4 +53,9 @@ ObjectUtils.convertValuesToFunctions = function(o) {
return output; return output;
} }
ObjectUtils.isEmpty = function(o) {
if (!o) return true;
return Object.keys(o).length === 0 && o.constructor === Object;
}
module.exports = ObjectUtils; module.exports = ObjectUtils;

View File

@ -0,0 +1,23 @@
const BaseModel = require('lib/BaseModel.js');
const Note = require('lib/models/Note.js');
class Search extends BaseModel {
static tableName() {
throw new Error('Not using database');
}
static modelType() {
return BaseModel.TYPE_SEARCH;
}
static keywords(query) {
let output = query.trim();
output = output.split(/[\s\t\n]+/);
output = output.filter(o => !!o);
return output;
}
}
module.exports = Search;

View File

@ -426,11 +426,35 @@ const reducer = (state = defaultState, action) => {
case 'SEARCH_ADD': case 'SEARCH_ADD':
newState = Object.assign({}, state); newState = Object.assign({}, state);
let searches = newState.searches.slice(); var searches = newState.searches.slice();
searches.push(action.search); searches.push(action.search);
newState.searches = searches; newState.searches = searches;
break; break;
case 'SEARCH_UPDATE':
newState = Object.assign({}, state);
var searches = newState.searches.slice();
var found = false;
for (let i = 0; i < searches.length; i++) {
if (searches[i].id === action.search.id) {
searches[i] = Object.assign({}, action.search);
found = true;
break;
}
}
if (!found) searches.push(action.search);
if (!action.search.query_pattern) {
newState.notesParentType = defaultNotesParentType(state, 'Search');
} else {
newState.notesParentType = 'Search';
}
newState.searches = searches;
break;
case 'SEARCH_DELETE': case 'SEARCH_DELETE':
newState = handleItemDelete(state, action); newState = handleItemDelete(state, action);