diff --git a/.eslintignore b/.eslintignore index 91cc4695e..92ffaf7e6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1106,6 +1106,9 @@ packages/lib/services/UndoRedoService.js.map packages/lib/services/WhenClause.d.ts packages/lib/services/WhenClause.js packages/lib/services/WhenClause.js.map +packages/lib/services/WhenClause.test.d.ts +packages/lib/services/WhenClause.test.js +packages/lib/services/WhenClause.test.js.map packages/lib/services/commands/MenuUtils.d.ts packages/lib/services/commands/MenuUtils.js packages/lib/services/commands/MenuUtils.js.map diff --git a/.eslintrc.js b/.eslintrc.js index 5f5815a49..c4f1cccbb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -76,7 +76,7 @@ module.exports = { // Warn only for now because fixing everything would take too much // refactoring, but new code should try to stick to it. - 'complexity': ['warn', { max: 10 }], + // 'complexity': ['warn', { max: 10 }], // Checks rules of Hooks 'react-hooks/rules-of-hooks': 'error', diff --git a/.gitignore b/.gitignore index b5a6d14c2..7dcfa505a 100644 --- a/.gitignore +++ b/.gitignore @@ -1092,6 +1092,9 @@ packages/lib/services/UndoRedoService.js.map packages/lib/services/WhenClause.d.ts packages/lib/services/WhenClause.js packages/lib/services/WhenClause.js.map +packages/lib/services/WhenClause.test.d.ts +packages/lib/services/WhenClause.test.js +packages/lib/services/WhenClause.test.js.map packages/lib/services/commands/MenuUtils.d.ts packages/lib/services/commands/MenuUtils.js packages/lib/services/commands/MenuUtils.js.map diff --git a/packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts b/packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts index e38bae689..bd0e3b26f 100644 --- a/packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts +++ b/packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts @@ -18,6 +18,6 @@ export const runtime = (comp: any): CommandRuntime => { }, }); }, - enabledCondition: 'folderIsShareRootAndOwnedByUser || !folderIsShared', + enabledCondition: 'joplinServerConnected && (folderIsShareRootAndOwnedByUser || !folderIsShared)', }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts b/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts index 0b384af99..73cb2e7b2 100644 --- a/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts +++ b/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts @@ -18,5 +18,6 @@ export const runtime = (comp: any): CommandRuntime => { }, }); }, + enabledCondition: 'joplinServerConnected && oneNoteSelected', }; }; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 5bbdbce16..9c359dc4c 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -695,11 +695,18 @@ function useMenu(props: Props) { }, ], }, + folder: { + label: _('Note&book'), + submenu: [ + menuItemDic.showShareFolderDialog, + ], + }, note: { label: _('&Note'), submenu: [ menuItemDic.toggleExternalEditing, menuItemDic.setTags, + menuItemDic.showShareNoteDialog, separator(), menuItemDic.showNoteContentProperties, ], @@ -818,6 +825,7 @@ function useMenu(props: Props) { rootMenus.edit, rootMenus.view, rootMenus.go, + rootMenus.folder, rootMenus.note, rootMenus.tools, rootMenus.help, diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index c91d396df..8f0e3c78c 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -45,5 +45,7 @@ export default function() { 'editor.swapLineUp', 'editor.swapLineDown', 'toggleSafeMode', + 'showShareNoteDialog', + 'showShareFolderDialog', ]; } diff --git a/packages/lib/services/WhenClause.test.ts b/packages/lib/services/WhenClause.test.ts new file mode 100644 index 000000000..3416c8cb6 --- /dev/null +++ b/packages/lib/services/WhenClause.test.ts @@ -0,0 +1,39 @@ +import WhenClause from './WhenClause'; + +describe('WhenClause', function() { + + test('should work with simple condition', async function() { + const wc = new WhenClause('test1 && test2'); + + expect(wc.evaluate({ + test1: true, + test2: true, + })).toBe(true); + + expect(wc.evaluate({ + test1: true, + test2: false, + })).toBe(false); + }); + + test('should work with parenthesis', async function() { + const wc = new WhenClause('(test1 && test2) || test3 && (test4 && !test5)'); + + expect(wc.evaluate({ + test1: true, + test2: true, + test3: true, + test4: true, + test5: true, + })).toBe(true); + + expect(wc.evaluate({ + test1: false, + test2: true, + test3: false, + test4: false, + test5: true, + })).toBe(false); + }); + +}); diff --git a/packages/lib/services/WhenClause.ts b/packages/lib/services/WhenClause.ts index 3e0a32d2c..9d8f91571 100644 --- a/packages/lib/services/WhenClause.ts +++ b/packages/lib/services/WhenClause.ts @@ -1,17 +1,71 @@ -import { ContextKeyExpr, ContextKeyExpression } from './contextkey/contextkey'; +import { ContextKeyExpr, ContextKeyExpression, IContext } from './contextkey/contextkey'; + +// We would like to support expressions with brackets but VSCode When Clauses +// don't support this. To support this, we split the expressions with brackets +// into sub-expressions, which can then be parsed and executed separately by the +// When Clause library. +interface AdvancedExpression { + // (test1 && test2) || test3 + original: string; + // __sub_1 || test3 + compiledText: string; + // { __sub_1: "test1 && test2" } + subExpressions: any; +} + +function parseAdvancedExpression(advancedExpression: string): AdvancedExpression { + let subExpressionIndex = -1; + let subExpressions: string = ''; + let currentSubExpressionKey = ''; + const subContext: any = {}; + + let inBrackets = false; + for (let i = 0; i < advancedExpression.length; i++) { + const c = advancedExpression[i]; + + if (c === '(') { + if (inBrackets) throw new Error('Nested brackets not supported'); + inBrackets = true; + subExpressionIndex++; + currentSubExpressionKey = `__sub_${subExpressionIndex}`; + subContext[currentSubExpressionKey] = ''; + continue; + } + + if (c === ')') { + if (!inBrackets) throw new Error('Closing bracket without an opening one'); + inBrackets = false; + subExpressions += currentSubExpressionKey; + currentSubExpressionKey = ''; + continue; + } + + if (inBrackets) { + subContext[currentSubExpressionKey] += c; + } else { + subExpressions += c; + } + } + + return { + compiledText: subExpressions, + subExpressions: subContext, + original: advancedExpression, + }; +} export default class WhenClause { - private expression_: string; + private expression_: AdvancedExpression; private validate_: boolean; - private rules_: ContextKeyExpression = null; + private ruleCache_: Record = {}; - constructor(expression: string, validate: boolean) { - this.expression_ = expression; + public constructor(expression: string, validate: boolean = true) { + this.expression_ = parseAdvancedExpression(expression); this.validate_ = validate; } - private createContext(ctx: any) { + private createContext(ctx: any): IContext { return { getValue: (key: string) => { return ctx[key]; @@ -19,21 +73,28 @@ export default class WhenClause { }; } - private get rules(): ContextKeyExpression { - if (!this.rules_) { - this.rules_ = ContextKeyExpr.deserialize(this.expression_); - } - - return this.rules_; + private rules(exp: string): ContextKeyExpression { + if (this.ruleCache_[exp]) return this.ruleCache_[exp]; + this.ruleCache_[exp] = ContextKeyExpr.deserialize(exp); + return this.ruleCache_[exp]; } public evaluate(context: any): boolean { if (this.validate_) this.validate(context); - return this.rules.evaluate(this.createContext(context)); + + const subContext: any = {}; + + for (const k in this.expression_.subExpressions) { + const subExp = this.expression_.subExpressions[k]; + subContext[k] = this.rules(subExp).evaluate(this.createContext(context)); + } + + const fullContext = { ...context, ...subContext }; + return this.rules(this.expression_.compiledText).evaluate(this.createContext(fullContext)); } public validate(context: any) { - const keys = this.rules.keys(); + const keys = this.rules(this.expression_.original.replace(/[()]/g, ' ')).keys(); for (const key of keys) { if (!(key in context)) throw new Error(`No such key: ${key}`); } diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts index d79300598..fd7d1d40e 100644 --- a/packages/lib/services/commands/stateToWhenClauseContext.ts +++ b/packages/lib/services/commands/stateToWhenClauseContext.ts @@ -27,6 +27,7 @@ export interface WhenClauseContext { noteIsHtml: boolean; folderIsShareRootAndOwnedByUser: boolean; folderIsShared: boolean; + joplinServerConnected: boolean; } export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext { @@ -42,7 +43,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau // const commandNoteId = options.commandNoteId || selectedNoteId; // const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null; - const commandFolderId = options.commandFolderId; + const commandFolderId = options.commandFolderId || state.selectedFolderId; const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null; return { @@ -75,5 +76,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau // Current context folder folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false, folderIsShared: commandFolder ? !!commandFolder.share_id : false, + + joplinServerConnected: state.settings['sync.target'] === 9, }; } diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index ed817ba68..211d814bc 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -27,11 +27,12 @@ export interface Command { execute(...args: any[]): Promise; /** - * Defines whether the command should be enabled or disabled, which in turns affects - * the enabled state of any associated button or menu item. + * Defines whether the command should be enabled or disabled, which in turns + * affects the enabled state of any associated button or menu item. * - * The condition should be expressed as a "when-clause" (as in Visual Studio Code). It's a simple boolean expression that evaluates to - * `true` or `false`. It supports the following operators: + * The condition should be expressed as a "when-clause" (as in Visual Studio + * Code). It's a simple boolean expression that evaluates to `true` or + * `false`. It supports the following operators: * * Operator | Symbol | Example * -- | -- | -- @@ -40,7 +41,17 @@ export interface Command { * Or | \|\| | "noteIsTodo \|\| noteTodoCompleted" * And | && | "oneNoteSelected && !inConflictFolder" * - * Currently the supported context variables aren't documented, but you can [find the list here](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts). + * Joplin, unlike VSCode, also supports parenthesis, which allows creating + * more complex expressions such as `cond1 || (cond2 && cond3)`. Only one + * level of parenthesis is possible (nested ones aren't supported). + * + * Currently the supported context variables aren't documented, but you can + * find the list below: + * + * - [Global When + * Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts). + * - [Desktop app When + * Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts). * * Note: Commands are enabled by default unless you use this property. */