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

Desktop: Added Goto Anything dialog and implemented basic plugin system

This commit is contained in:
Laurent Cozic 2019-04-01 19:43:13 +00:00
parent db04906416
commit 6b2910c3c7
14 changed files with 1018 additions and 90 deletions

View File

@ -265,6 +265,7 @@ describe('services_SearchEngine', function() {
['title:abcd efgh', { _: ['efgh'], title: ['abcd'] }], ['title:abcd efgh', { _: ['efgh'], title: ['abcd'] }],
['title:abcd', { title: ['abcd'] }], ['title:abcd', { title: ['abcd'] }],
['"abcd efgh"', { _: ['abcd efgh'] }], ['"abcd efgh"', { _: ['abcd efgh'] }],
['title:abcd title:efgh', { title: ['abcd', 'efgh'] }],
]; ];
for (let i = 0; i < testCases.length; i++) { for (let i = 0; i < testCases.length; i++) {
@ -283,33 +284,6 @@ describe('services_SearchEngine', function() {
} }
})); }));
// it('should parse query strings with wildcards', asyncTest(async () => {
// let rows;
// const testCases = [
// ['do*', ['do', 'dog', 'domino'], [] ],
// // "*" is a wildcard only when used at the end (to search for documents with the specified prefix)
// // If it's at the beginning, it's ignored, if it's in the middle, it's treated as a litteral "*".
// ['*an*', ['an', 'anneau'], ['piano', 'plan'] ],
// ['no*no', ['no*no'], ['nonono'] ],
// ];
// for (let i = 0; i < testCases.length; i++) {
// const t = testCases[i];
// const input = t[0];
// const shouldMatch = t[1];
// const shouldNotMatch = t[2];
// const regex = new RegExp(engine.parseQuery(input).terms._[0].value, 'gmi');
// for (let j = 0; j < shouldMatch.length; j++) {
// const r = shouldMatch[j].match(regex);
// expect(!!r).toBe(true, '"' + input + '" should match "' + shouldMatch[j] + '"');
// }
// }
// expect(engine.parseQuery('*').termCount).toBe(0);
// }));
it('should handle queries with special characters', asyncTest(async () => { it('should handle queries with special characters', asyncTest(async () => {
let rows; let rows;

View File

@ -28,6 +28,11 @@ const ExternalEditWatcher = require('lib/services/ExternalEditWatcher');
const { bridge } = require('electron').remote.require('./bridge'); const { bridge } = require('electron').remote.require('./bridge');
const Menu = bridge().Menu; const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem; const MenuItem = bridge().MenuItem;
const PluginManager = require('lib/services/PluginManager');
const pluginClasses = [
require('./plugins/GotoAnything.min'),
];
const appDefaultState = Object.assign({}, defaultState, { const appDefaultState = Object.assign({}, defaultState, {
route: { route: {
@ -441,7 +446,7 @@ class Application extends BaseApplication {
const printItem = { const printItem = {
label: _('Print'), label: _('Print'),
accelerator: 'CommandOrControl+P', // accelerator: 'CommandOrControl+P',
screens: ['Main'], screens: ['Main'],
click: () => { click: () => {
this.dispatch({ this.dispatch({
@ -502,8 +507,8 @@ class Application extends BaseApplication {
}); });
} }
const template = [ const rootMenus = {
{ macOsApp: {
/* Using a dummy entry for macOS here, because first menu /* Using a dummy entry for macOS here, because first menu
* becomes 'Joplin' and we need a nenu called 'File' later. */ * becomes 'Joplin' and we need a nenu called 'File' later. */
label: shim.isMac() ? '&JoplinMainMenu' : _('&File'), label: shim.isMac() ? '&JoplinMainMenu' : _('&File'),
@ -572,7 +577,8 @@ class Application extends BaseApplication {
accelerator: 'CommandOrControl+Q', accelerator: 'CommandOrControl+Q',
click: () => { bridge().electronApp().quit() } click: () => { bridge().electronApp().quit() }
}] }]
}, { },
file: {
label: _('&File'), label: _('&File'),
visible: shim.isMac() ? true : false, visible: shim.isMac() ? true : false,
submenu: [ submenu: [
@ -591,7 +597,8 @@ class Application extends BaseApplication {
}, },
printItem printItem
] ]
}, { },
edit: {
label: _('&Edit'), label: _('&Edit'),
submenu: [{ submenu: [{
label: _('Copy'), label: _('Copy'),
@ -692,7 +699,8 @@ class Application extends BaseApplication {
}); });
}, },
}], }],
}, { },
view: {
label: _('&View'), label: _('&View'),
submenu: [{ submenu: [{
label: _('Toggle sidebar'), label: _('Toggle sidebar'),
@ -749,11 +757,12 @@ class Application extends BaseApplication {
screens: ['Main'], screens: ['Main'],
submenu: focusItems, submenu: focusItems,
}], }],
}, { },
tools: {
label: _('&Tools'), label: _('&Tools'),
visible: shim.isMac() ? false : true, submenu: shim.isMac() ? [] : toolsItems,
submenu: toolsItems },
}, { help: {
label: _('&Help'), label: _('&Help'),
submenu: [{ submenu: [{
label: _('Website and documentation'), label: _('Website and documentation'),
@ -775,7 +784,22 @@ class Application extends BaseApplication {
visible: shim.isMac() ? false : true, visible: shim.isMac() ? false : true,
click: () => _showAbout() click: () => _showAbout()
}] }]
}, },
};
const pluginMenuItems = PluginManager.instance().menuItems();
for (const item of pluginMenuItems) {
let itemParent = rootMenus[item.parent] ? rootMenus[item.parent] : 'tools';
itemParent.submenu.push(item);
}
const template = [
rootMenus.macOsApp,
rootMenus.file,
rootMenus.edit,
rootMenus.view,
rootMenus.tools,
rootMenus.help,
]; ];
function isEmptyMenu(template) { function isEmptyMenu(template) {
@ -891,6 +915,10 @@ class Application extends BaseApplication {
bridge().window().webContents.openDevTools(); bridge().window().webContents.openDevTools();
} }
PluginManager.instance().dispatch_ = this.dispatch.bind(this);
PluginManager.instance().setLogger(reg.logger());
PluginManager.instance().register(pluginClasses);
this.updateMenu('Main'); this.updateMenu('Main');
this.initRedux(); this.initRedux();

View File

@ -3,7 +3,6 @@ const spawnSync = require('child_process').spawnSync;
const babelPath = __dirname + '/node_modules/.bin/babel' + (process.platform === 'win32' ? '.cmd' : ''); const babelPath = __dirname + '/node_modules/.bin/babel' + (process.platform === 'win32' ? '.cmd' : '');
const basePath = __dirname + '/../..'; const basePath = __dirname + '/../..';
const guiPath = __dirname + '/gui';
function fileIsNewerThan(path1, path2) { function fileIsNewerThan(path1, path2) {
if (!fs.existsSync(path2)) return true; if (!fs.existsSync(path2)) return true;
@ -14,31 +13,36 @@ function fileIsNewerThan(path1, path2) {
return stat1.mtime > stat2.mtime; return stat1.mtime > stat2.mtime;
} }
fs.readdirSync(guiPath).forEach((filename) => { function convertJsx(path) {
const jsxPath = guiPath + '/' + filename; fs.readdirSync(path).forEach((filename) => {
const p = jsxPath.split('.'); const jsxPath = path + '/' + filename;
if (p.length <= 1) return; const p = jsxPath.split('.');
const ext = p[p.length - 1]; if (p.length <= 1) return;
if (ext !== 'jsx') return; const ext = p[p.length - 1];
p.pop(); if (ext !== 'jsx') return;
p.pop();
const basePath = p.join('.'); const basePath = p.join('.');
const jsPath = basePath + '.min.js'; const jsPath = basePath + '.min.js';
if (fileIsNewerThan(jsxPath, jsPath)) { if (fileIsNewerThan(jsxPath, jsPath)) {
console.info('Compiling ' + jsxPath + '...'); console.info('Compiling ' + jsxPath + '...');
const result = spawnSync(babelPath, ['--presets', 'react', '--out-file', jsPath, jsxPath]); const result = spawnSync(babelPath, ['--presets', 'react', '--out-file', jsPath, jsxPath]);
if (result.status !== 0) { if (result.status !== 0) {
const msg = []; const msg = [];
if (result.stdout) msg.push(result.stdout.toString()); if (result.stdout) msg.push(result.stdout.toString());
if (result.stderr) msg.push(result.stderr.toString()); if (result.stderr) msg.push(result.stderr.toString());
console.error(msg.join('\n')); console.error(msg.join('\n'));
if (result.error) console.error(result.error); if (result.error) console.error(result.error);
process.exit(result.status); process.exit(result.status);
}
} }
} });
}); }
convertJsx(__dirname + '/gui');
convertJsx(__dirname + '/plugins');
const libContent = [ const libContent = [
fs.readFileSync(basePath + '/ReactNativeClient/lib/string-utils-common.js', 'utf8'), fs.readFileSync(basePath + '/ReactNativeClient/lib/string-utils-common.js', 'utf8'),

View File

@ -18,6 +18,7 @@ const layoutUtils = require('lib/layout-utils.js');
const { bridge } = require('electron').remote.require('./bridge'); const { bridge } = require('electron').remote.require('./bridge');
const eventManager = require('../eventManager'); const eventManager = require('../eventManager');
const VerticalResizer = require('./VerticalResizer.min'); const VerticalResizer = require('./VerticalResizer.min');
const PluginManager = require('lib/services/PluginManager');
class MainScreenComponent extends React.Component { class MainScreenComponent extends React.Component {
@ -458,6 +459,9 @@ class MainScreenComponent extends React.Component {
); );
} }
const dialogInfo = PluginManager.instance().pluginDialogToShow(this.props.plugins);
const pluginDialog = !dialogInfo ? null : <dialogInfo.Dialog {...dialogInfo.props}/>;
const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' }); const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' });
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
@ -491,6 +495,8 @@ class MainScreenComponent extends React.Component {
<NoteList style={styles.noteList} /> <NoteList style={styles.noteList} />
<VerticalResizer style={styles.verticalResizer} onDrag={this.noteList_onDrag}/> <VerticalResizer style={styles.verticalResizer} onDrag={this.noteList_onDrag}/>
<NoteText style={styles.noteText} visiblePanes={this.props.noteVisiblePanes} /> <NoteText style={styles.noteText} visiblePanes={this.props.noteVisiblePanes} />
{pluginDialog}
</div> </div>
); );
} }
@ -512,6 +518,7 @@ const mapStateToProps = (state) => {
sidebarWidth: state.settings['style.sidebar.width'], sidebarWidth: state.settings['style.sidebar.width'],
noteListWidth: state.settings['style.noteList.width'], noteListWidth: state.settings['style.noteList.width'],
selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null,
plugins: state.plugins,
}; };
}; };

View File

@ -102,25 +102,25 @@ class NotePropertiesDialog extends React.Component {
this.styles_ = {}; this.styles_ = {};
this.styleKey_ = styleKey; this.styleKey_ = styleKey;
this.styles_.modalLayer = { // this.styles_.modalLayer = {
zIndex: 9999, // zIndex: 9999,
display: 'flex', // display: 'flex',
position: 'absolute', // position: 'absolute',
top: 0, // top: 0,
left: 0, // left: 0,
width: '100%', // width: '100%',
height: '100%', // height: '100%',
backgroundColor: 'rgba(0,0,0,0.6)', // backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'flex-start', // alignItems: 'flex-start',
justifyContent: 'center', // justifyContent: 'center',
}; // };
this.styles_.dialogBox = { // this.styles_.dialogBox = {
backgroundColor: theme.backgroundColor, // backgroundColor: theme.backgroundColor,
padding: 16, // padding: 16,
boxShadow: '6px 6px 20px rgba(0,0,0,0.5)', // boxShadow: '6px 6px 20px rgba(0,0,0,0.5)',
marginTop: 20, // marginTop: 20,
} // }
this.styles_.controlBox = { this.styles_.controlBox = {
marginBottom: '1em', marginBottom: '1em',
@ -153,7 +153,7 @@ class NotePropertiesDialog extends React.Component {
borderColor: theme.dividerColor, borderColor: theme.dividerColor,
}; };
this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' }); // this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' });
return this.styles_; return this.styles_;
} }
@ -368,8 +368,6 @@ class NotePropertiesDialog extends React.Component {
const noteComps = []; const noteComps = [];
const modalLayerStyle = Object.assign({}, styles.modalLayer);
if (formNote) { if (formNote) {
for (let key in formNote) { for (let key in formNote) {
if (!formNote.hasOwnProperty(key)) continue; if (!formNote.hasOwnProperty(key)) continue;
@ -379,9 +377,9 @@ class NotePropertiesDialog extends React.Component {
} }
return ( return (
<div style={modalLayerStyle}> <div style={theme.dialogModalLayer}>
<div style={styles.dialogBox}> <div style={theme.dialogBox}>
<div style={styles.dialogTitle}>{_('Note properties')}</div> <div style={theme.dialogTitle}>{_('Note properties')}</div>
<div>{noteComps}</div> <div>{noteComps}</div>
<div style={{ textAlign: 'right', marginTop: 10 }}> <div style={{ textAlign: 'right', marginTop: 10 }}>
{buttonComps} {buttonComps}

View File

@ -800,7 +800,7 @@ class NoteTextComponent extends React.Component {
}); });
if (Setting.value('env') === 'dev') { if (Setting.value('env') === 'dev') {
this.webviewRef_.current.wrappedInstance.openDevTools(); // this.webviewRef_.current.wrappedInstance.openDevTools();
} }
} }

View File

@ -0,0 +1,358 @@
const React = require('react');
const { connect } = require('react-redux');
const { _ } = require('lib/locale.js');
const { themeStyle } = require('../theme.js');
const SearchEngine = require('lib/services/SearchEngine');
const BaseModel = require('lib/BaseModel');
const Tag = require('lib/models/Tag');
const { ItemList } = require('../gui/ItemList.min');
const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js');
const PLUGIN_NAME = 'gotoAnything';
const itemHeight = 60;
class GotoAnything {
onTrigger(event) {
this.dispatch({
type: 'PLUGIN_DIALOG_SET',
open: true,
pluginName: PLUGIN_NAME,
});
}
}
class Dialog extends React.PureComponent {
constructor() {
super();
this.state = {
query: '',
results: [],
selectedItemId: null,
keywords: [],
listType: BaseModel.TYPE_NOTE,
showHelp: false,
};
this.styles_ = {};
this.inputRef = React.createRef();
this.itemListRef = React.createRef();
this.onKeyDown = this.onKeyDown.bind(this);
this.input_onChange = this.input_onChange.bind(this);
this.input_onKeyDown = this.input_onKeyDown.bind(this);
this.listItemRenderer = this.listItemRenderer.bind(this);
this.listItem_onClick = this.listItem_onClick.bind(this);
this.helpButton_onClick = this.helpButton_onClick.bind(this);
}
style() {
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
const theme = themeStyle(this.props.theme);
this.styles_[this.props.theme] = {
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},
help: Object.assign({}, theme.textStyle, { marginBottom: 10 }),
inputHelpWrapper: {display: 'flex', flexDirection: 'row', alignItems: 'center'},
helpIcon: {flex:0, width: 16, height: 16, marginLeft: 10},
helpButton: {color: theme.color, textDecoration: 'none'},
};
const rowTextStyle = {
fontSize: theme.fontSize,
color: theme.color,
fontFamily: theme.fontFamily,
whiteSpace: 'nowrap',
opacity: 0.7,
userSelect: 'none',
};
const rowTitleStyle = Object.assign({}, rowTextStyle, {
fontSize: rowTextStyle.fontSize * 1.4,
marginBottom: 5,
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;
return this.styles_[this.props.theme];
}
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
if (this.listUpdateIID_) clearTimeout(this.listUpdateIID_);
document.removeEventListener('keydown', this.onKeyDown);
}
onKeyDown(event) {
if (event.keyCode === 27) { // ESCAPE
this.props.dispatch({
pluginName: PLUGIN_NAME,
type: 'PLUGIN_DIALOG_SET',
open: false,
});
}
}
helpButton_onClick(event) {
this.setState({ showHelp: !this.state.showHelp });
}
input_onChange(event) {
this.setState({ query: event.target.value });
this.scheduleListUpdate();
}
scheduleListUpdate() {
if (this.listUpdateIID_) return;
this.listUpdateIID_ = setTimeout(async () => {
await this.updateList();
this.listUpdateIID_ = null;
}, 10);
}
makeSearchQuery(query) {
const splitted = query.split(' ');
const output = [];
for (let i = 0; i < splitted.length; i++) {
const s = splitted[i].trim();
if (!s) continue;
output.push('title:' + s + '*');
}
return output.join(' ');
}
keywords(searchQuery) {
const parsedQuery = SearchEngine.instance().parseQuery(searchQuery);
return SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
async updateList() {
if (!this.state.query) {
this.setState({ results: [], keywords: [] });
} else {
let results = [];
let listType = null;
let searchQuery = '';
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 });
} else if (this.state.query.indexOf('@') === 0) { // FOLDERS
listType = BaseModel.TYPE_FOLDER;
searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*';
results = await Folder.search({ titlePattern: searchQuery });
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 ? path : '/' });
}
} else { // NOTES
listType = BaseModel.TYPE_NOTE;
searchQuery = this.makeSearchQuery(this.state.query);
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);
results[i] = Object.assign({}, row, { path: path });
}
}
let selectedItemId = null;
const itemIndex = this.selectedItemIndex(results, this.state.selectedItemId);
if (itemIndex > 0) {
selectedItemId = this.state.selectedItemId;
} else if (results.length > 0) {
selectedItemId = results[0].id;
}
this.setState({
listType: listType,
results: results,
keywords: this.keywords(searchQuery),
selectedItemId: selectedItemId,
});
}
}
gotoItem(item) {
this.props.dispatch({
pluginName: PLUGIN_NAME,
type: 'PLUGIN_DIALOG_SET',
open: false,
});
if (this.state.listType === BaseModel.TYPE_NOTE) {
this.props.dispatch({
type: "FOLDER_AND_NOTE_SELECT",
folderId: item.parent_id,
noteId: item.id,
});
} else if (this.state.listType === BaseModel.TYPE_TAG) {
this.props.dispatch({
type: "TAG_SELECT",
id: item.id,
});
} else if (this.state.listType === BaseModel.TYPE_FOLDER) {
this.props.dispatch({
type: "FOLDER_SELECT",
id: item.id,
});
}
}
listItem_onClick(event) {
const itemId = event.currentTarget.getAttribute('data-id');
const parentId = event.currentTarget.getAttribute('data-parent-id');
this.gotoItem({
id: itemId,
parent_id: parentId,
});
}
listItemRenderer(item) {
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 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>
{pathComp}
</div>
);
}
selectedItemIndex(results, itemId) {
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 (r.id === itemId) return i;
}
return -1;
}
selectedItem() {
const index = this.selectedItemIndex();
if (index < 0) return null;
return this.state.results[index];
}
input_onKeyDown(event) {
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 = this.state.results[index].id;
this.itemListRef.current.makeItemIndexVisible(index);
this.setState({ selectedItemId: newId });
}
if (keyCode === 13) { // ENTER
event.preventDefault();
const item = this.selectedItem();
if (!item) return;
this.gotoItem(item);
}
}
renderList() {
const style = {
marginTop: 5,
height: Math.min(itemHeight * this.state.results.length, 7 * itemHeight),
};
return (
<ItemList
ref={this.itemListRef}
itemHeight={itemHeight}
items={this.state.results}
style={style}
itemRenderer={this.listItemRenderer}
/>
);
}
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>
return (
<div style={theme.dialogModalLayer}>
<div style={style.dialogBox}>
{helpComp}
<div style={style.inputHelpWrapper}>
<input autoFocus type="text" style={style.input} ref={this.inputRef} value={this.state.query} onChange={this.input_onChange} onKeyDown={this.input_onKeyDown}/>
<a href="#" style={style.helpButton} onClick={this.helpButton_onClick}><i style={style.helpIcon} className={"fa fa-question-circle"}></i></a>
</div>
{this.renderList()}
</div>
</div>
);
}
}
const mapStateToProps = (state) => {
return {
folders: state.folders,
theme: state.settings.theme,
};
};
GotoAnything.Dialog = connect(mapStateToProps)(Dialog);
GotoAnything.manifest = {
name: PLUGIN_NAME,
menuItems: [
{
name: 'main',
parent: 'tools',
label: _('Goto Anything...'),
accelerator: 'CommandOrControl+P',
screens: ['Main'],
},
],
};
module.exports = GotoAnything;

View File

@ -0,0 +1,376 @@
const React = require('react');
const { connect } = require('react-redux');
const { _ } = require('lib/locale.js');
const { themeStyle } = require('../theme.js');
const SearchEngine = require('lib/services/SearchEngine');
const BaseModel = require('lib/BaseModel');
const Tag = require('lib/models/Tag');
const { ItemList } = require('../gui/ItemList.min');
const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js');
const PLUGIN_NAME = 'gotoAnything';
const itemHeight = 60;
class GotoAnything {
onTrigger(event) {
this.dispatch({
type: 'PLUGIN_DIALOG_SET',
open: true,
pluginName: PLUGIN_NAME
});
}
}
class Dialog extends React.PureComponent {
constructor() {
super();
this.state = {
query: '',
results: [],
selectedItemId: null,
keywords: [],
listType: BaseModel.TYPE_NOTE,
showHelp: false
};
this.styles_ = {};
this.inputRef = React.createRef();
this.itemListRef = React.createRef();
this.onKeyDown = this.onKeyDown.bind(this);
this.input_onChange = this.input_onChange.bind(this);
this.input_onKeyDown = this.input_onKeyDown.bind(this);
this.listItemRenderer = this.listItemRenderer.bind(this);
this.listItem_onClick = this.listItem_onClick.bind(this);
this.helpButton_onClick = this.helpButton_onClick.bind(this);
}
style() {
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
const theme = themeStyle(this.props.theme);
this.styles_[this.props.theme] = {
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 },
help: Object.assign({}, theme.textStyle, { marginBottom: 10 }),
inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' },
helpIcon: { flex: 0, width: 16, height: 16, marginLeft: 10 },
helpButton: { color: theme.color, textDecoration: 'none' }
};
const rowTextStyle = {
fontSize: theme.fontSize,
color: theme.color,
fontFamily: theme.fontFamily,
whiteSpace: 'nowrap',
opacity: 0.7,
userSelect: 'none'
};
const rowTitleStyle = Object.assign({}, rowTextStyle, {
fontSize: rowTextStyle.fontSize * 1.4,
marginBottom: 5,
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;
return this.styles_[this.props.theme];
}
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
if (this.listUpdateIID_) clearTimeout(this.listUpdateIID_);
document.removeEventListener('keydown', this.onKeyDown);
}
onKeyDown(event) {
if (event.keyCode === 27) {
// ESCAPE
this.props.dispatch({
pluginName: PLUGIN_NAME,
type: 'PLUGIN_DIALOG_SET',
open: false
});
}
}
helpButton_onClick(event) {
this.setState({ showHelp: !this.state.showHelp });
}
input_onChange(event) {
this.setState({ query: event.target.value });
this.scheduleListUpdate();
}
scheduleListUpdate() {
if (this.listUpdateIID_) return;
this.listUpdateIID_ = setTimeout(async () => {
await this.updateList();
this.listUpdateIID_ = null;
}, 10);
}
makeSearchQuery(query) {
const splitted = query.split(' ');
const output = [];
for (let i = 0; i < splitted.length; i++) {
const s = splitted[i].trim();
if (!s) continue;
output.push('title:' + s + '*');
}
return output.join(' ');
}
keywords(searchQuery) {
const parsedQuery = SearchEngine.instance().parseQuery(searchQuery);
return SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
async updateList() {
if (!this.state.query) {
this.setState({ results: [], keywords: [] });
} else {
let results = [];
let listType = null;
let searchQuery = '';
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 });
} else if (this.state.query.indexOf('@') === 0) {
// FOLDERS
listType = BaseModel.TYPE_FOLDER;
searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*';
results = await Folder.search({ titlePattern: searchQuery });
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 ? path : '/' });
}
} else {
// NOTES
listType = BaseModel.TYPE_NOTE;
searchQuery = this.makeSearchQuery(this.state.query);
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);
results[i] = Object.assign({}, row, { path: path });
}
}
let selectedItemId = null;
const itemIndex = this.selectedItemIndex(results, this.state.selectedItemId);
if (itemIndex > 0) {
selectedItemId = this.state.selectedItemId;
} else if (results.length > 0) {
selectedItemId = results[0].id;
}
this.setState({
listType: listType,
results: results,
keywords: this.keywords(searchQuery),
selectedItemId: selectedItemId
});
}
}
gotoItem(item) {
this.props.dispatch({
pluginName: PLUGIN_NAME,
type: 'PLUGIN_DIALOG_SET',
open: false
});
if (this.state.listType === BaseModel.TYPE_NOTE) {
this.props.dispatch({
type: "FOLDER_AND_NOTE_SELECT",
folderId: item.parent_id,
noteId: item.id
});
} else if (this.state.listType === BaseModel.TYPE_TAG) {
this.props.dispatch({
type: "TAG_SELECT",
id: item.id
});
} else if (this.state.listType === BaseModel.TYPE_FOLDER) {
this.props.dispatch({
type: "FOLDER_SELECT",
id: item.id
});
}
}
listItem_onClick(event) {
const itemId = event.currentTarget.getAttribute('data-id');
const parentId = event.currentTarget.getAttribute('data-parent-id');
this.gotoItem({
id: itemId,
parent_id: parentId
});
}
listItemRenderer(item) {
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 pathComp = !item.path ? null : React.createElement(
'div',
{ style: style.rowPath },
item.path
);
return React.createElement(
'div',
{ key: item.id, style: rowStyle, onClick: this.listItem_onClick, 'data-id': item.id, 'data-parent-id': item.parent_id },
React.createElement('div', { style: style.rowTitle, dangerouslySetInnerHTML: { __html: titleHtml } }),
pathComp
);
}
selectedItemIndex(results, itemId) {
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 (r.id === itemId) return i;
}
return -1;
}
selectedItem() {
const index = this.selectedItemIndex();
if (index < 0) return null;
return this.state.results[index];
}
input_onKeyDown(event) {
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 = this.state.results[index].id;
this.itemListRef.current.makeItemIndexVisible(index);
this.setState({ selectedItemId: newId });
}
if (keyCode === 13) {
// ENTER
event.preventDefault();
const item = this.selectedItem();
if (!item) return;
this.gotoItem(item);
}
}
renderList() {
const style = {
marginTop: 5,
height: Math.min(itemHeight * this.state.results.length, 7 * itemHeight)
};
return React.createElement(ItemList, {
ref: this.itemListRef,
itemHeight: itemHeight,
items: this.state.results,
style: style,
itemRenderer: this.listItemRenderer
});
}
render() {
const theme = themeStyle(this.props.theme);
const style = this.style();
const helpComp = !this.state.showHelp ? null : React.createElement(
'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.')
);
return React.createElement(
'div',
{ style: theme.dialogModalLayer },
React.createElement(
'div',
{ style: style.dialogBox },
helpComp,
React.createElement(
'div',
{ style: style.inputHelpWrapper },
React.createElement('input', { autoFocus: true, type: 'text', style: style.input, ref: this.inputRef, value: this.state.query, onChange: this.input_onChange, onKeyDown: this.input_onKeyDown }),
React.createElement(
'a',
{ href: '#', style: style.helpButton, onClick: this.helpButton_onClick },
React.createElement('i', { style: style.helpIcon, className: "fa fa-question-circle" })
)
),
this.renderList()
)
);
}
}
const mapStateToProps = state => {
return {
folders: state.folders,
theme: state.settings.theme
};
};
GotoAnything.Dialog = connect(mapStateToProps)(Dialog);
GotoAnything.manifest = {
name: PLUGIN_NAME,
menuItems: [{
name: 'main',
parent: 'tools',
label: _('Goto Anything...'),
accelerator: 'CommandOrControl+P',
screens: ['Main']
}]
};
module.exports = GotoAnything;

View File

@ -42,6 +42,10 @@ globalStyle.headerStyle = {
globalStyle.inputStyle = { globalStyle.inputStyle = {
border: '1px solid', border: '1px solid',
height: 24,
paddingLeft: 5,
paddingRight: 5,
boxSizing: 'border-box',
}; };
globalStyle.containerStyle = { globalStyle.containerStyle = {
@ -67,6 +71,7 @@ const lightStyle = {
colorError: "red", colorError: "red",
colorWarn: "#9A5B00", colorWarn: "#9A5B00",
colorFaded: "#777777", // For less important text colorFaded: "#777777", // For less important text
colorBright: "#000000", // For important text
dividerColor: "#dddddd", dividerColor: "#dddddd",
selectedColor: '#e5e5e5', selectedColor: '#e5e5e5',
urlColor: '#155BDA', urlColor: '#155BDA',
@ -103,6 +108,7 @@ const darkStyle = {
colorError: "red", colorError: "red",
colorWarn: "#9A5B00", colorWarn: "#9A5B00",
colorFaded: "#777777", // For less important text colorFaded: "#777777", // For less important text
colorBright: "#ffffff", // For important text
dividerColor: '#555555', dividerColor: '#555555',
selectedColor: '#333333', selectedColor: '#333333',
urlColor: '#4E87EE', urlColor: '#4E87EE',
@ -198,6 +204,28 @@ function addExtraStyles(style) {
} }
); );
style.dialogModalLayer = {
zIndex: 9999,
display: 'flex',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'flex-start',
justifyContent: 'center',
};
style.dialogBox = {
backgroundColor: style.backgroundColor,
padding: 16,
boxShadow: '6px 6px 20px rgba(0,0,0,0.5)',
marginTop: 20,
}
style.dialogTitle = Object.assign({}, style.h1Style, { marginBottom: '1.2em' });
return style; return style;
} }

View File

@ -7,7 +7,7 @@ const { Database } = require('lib/database.js');
const { _ } = require('lib/locale.js'); const { _ } = require('lib/locale.js');
const moment = require('moment'); const moment = require('moment');
const BaseItem = require('lib/models/BaseItem.js'); const BaseItem = require('lib/models/BaseItem.js');
const lodash = require('lodash'); const { substrWithEllipsis } = require('lib/string-utils.js');
class Folder extends BaseItem { class Folder extends BaseItem {
@ -217,6 +217,34 @@ class Folder extends BaseItem {
return getNestedChildren(all, ''); return getNestedChildren(all, '');
} }
static folderPath(folders, folderId) {
const idToFolders = {};
for (let i = 0; i < folders.length; i++) {
idToFolders[folders[i].id] = folders[i];
}
const path = [];
while (folderId) {
const folder = idToFolders[folderId];
if (!folder) break; // Shouldn't happen
path.push(folder);
folderId = folder.parent_id;
}
path.reverse();
return path;
}
static folderPathString(folders, folderId) {
const path = this.folderPath(folders, folderId);
const output = [];
for (let i = 0; i < path.length; i++) {
output.push(substrWithEllipsis(path[i].title, 0, 16));
}
return output.join(' / ');
}
static buildTree(folders) { static buildTree(folders) {
const idToFolders = {}; const idToFolders = {};
for (let i = 0; i < folders.length; i++) { for (let i = 0; i < folders.length; i++) {

View File

@ -90,9 +90,19 @@ class Tag extends BaseItem {
return !!r; return !!r;
} }
static tagsWithNotesSql_() {
return 'select distinct tags.id from tags left join note_tags nt on nt.tag_id = tags.id left join notes on notes.id = nt.note_id where notes.id IS NOT NULL';
}
static async allWithNotes() { static async allWithNotes() {
const tagIdSql = 'select distinct tags.id from tags left join note_tags nt on nt.tag_id = tags.id left join notes on notes.id = nt.note_id where notes.id IS NOT NULL'; return await Tag.modelSelectAll('SELECT * FROM tags WHERE id IN (' + this.tagsWithNotesSql_() + ')');
return await Tag.modelSelectAll('SELECT * FROM tags WHERE id IN (' + tagIdSql + ')'); }
static async searchAllWithNotes(options) {
if (!options) options = {};
if (!options.conditions) options.conditions = [];
options.conditions.push('id IN (' + this.tagsWithNotesSql_() + ')');
return this.search(options);
} }
static async tagsByNoteId(noteId) { static async tagsByNoteId(noteId) {

View File

@ -48,6 +48,7 @@ const defaultState = {
toFetchCount: 0, toFetchCount: 0,
}, },
historyNotes: [], historyNotes: [],
plugins: {},
}; };
const stateUtils = {}; const stateUtils = {};
@ -691,6 +692,17 @@ const reducer = (state = defaultState, action) => {
newState.selectedNoteTags = action.items; newState.selectedNoteTags = action.items;
break; break;
case 'PLUGIN_DIALOG_SET':
if (!action.pluginName) throw new Error('action.pluginName not specified');
newState = Object.assign({}, state);
const newPlugins = Object.assign({}, newState.plugins);
const newPlugin = newState.plugins[action.pluginName] ? Object.assign({}, newState.plugins[action.pluginName]) : {};
if ('open' in action) newPlugin.dialogOpen = action.open;
newPlugins[action.pluginName] = newPlugin;
newState.plugins = newPlugins;
break;
} }
} catch (error) { } catch (error) {
error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action); error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action);

View File

@ -0,0 +1,105 @@
const { Logger } = require('lib/logger.js');
class PluginManager {
constructor() {
this.plugins_ = {};
this.logger_ = new Logger();
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new PluginManager();
return this.instance_;
}
register(classes) {
if (!Array.isArray(classes)) classes = [classes];
for (let i = 0; i < classes.length; i++) {
const PluginClass = classes[i];
if (this.plugins_[PluginClass.manifest.name]) throw new Error('Already registered: ' + PluginClass.manifest.name);
this.plugins_[PluginClass.manifest.name] = {
Class: PluginClass,
instance: null,
};
}
}
pluginInstance_(name) {
const p = this.plugins_[name];
if (p.instance) return p.instance;
p.instance = new p.Class();
p.instance.dispatch = (action) => this.dispatch_(action);
return p.instance;
}
pluginClass_(name) {
return this.plugins_[name].Class;
}
onPluginMenuItemTrigger_(event) {
const p = this.pluginInstance_(event.pluginName);
p.onTrigger({
itemName: event.itemName,
});
}
pluginDialogToShow(pluginStates) {
for (const name in pluginStates) {
const p = pluginStates[name];
if (!p.dialogOpen) continue;
const Class = this.pluginClass_(name);
if (!Class.Dialog) continue;
return {
Dialog: Class.Dialog,
props: this.dialogProps_(name),
}
}
return null;
}
dialogProps_(name) {
return {
dispatch: (action) => this.dispatch_(action),
plugin: this.pluginInstance_(name),
};
}
menuItems() {
let output = [];
for (const name in this.plugins_) {
const menuItems = this.plugins_[name].Class.manifest.menuItems;
if (!menuItems) continue;
for (const item of menuItems) {
item.click = () => {
this.onPluginMenuItemTrigger_({
pluginName: name,
itemName: item.name,
});
}
}
output = output.concat(menuItems);
}
return output;
}
}
module.exports = PluginManager;

View File

@ -267,7 +267,7 @@ class SearchEngine {
if (c === ':' && !inQuote) { if (c === ':' && !inQuote) {
currentCol = currentTerm; currentCol = currentTerm;
terms[currentCol] = []; if (!terms[currentCol]) terms[currentCol] = [];
currentTerm = ''; currentTerm = '';
continue; continue;
} }
@ -368,7 +368,7 @@ class SearchEngine {
return this.basicSearch(query); return this.basicSearch(query);
} else { } else {
const parsedQuery = this.parseQuery(query); const parsedQuery = this.parseQuery(query);
const sql = 'SELECT notes_fts.id, notes_fts.title, offsets(notes_fts) AS offsets, notes.user_updated_time, notes.is_todo, notes.todo_completed FROM notes_fts LEFT JOIN notes ON notes_fts.id = notes.id WHERE notes_fts MATCH ?' const sql = 'SELECT notes_fts.id, notes_fts.title, offsets(notes_fts) AS offsets, 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 { try {
const rows = await this.db().selectAll(sql, [query]); const rows = await this.db().selectAll(sql, [query]);
this.orderResults_(rows, parsedQuery); this.orderResults_(rows, parsedQuery);