2020-11-05 18:58:23 +02:00
import { State } from '../reducer' ;
2024-04-06 19:08:16 +02:00
import eventManager , { EventListenerCallback , EventName } from '../eventManager' ;
2020-11-05 18:58:23 +02:00
import BaseService from './BaseService' ;
import shim from '../shim' ;
2020-10-18 22:52:10 +02:00
import WhenClause from './WhenClause' ;
2024-07-26 13:35:50 +02:00
import type { WhenClauseContext } from './commands/stateToWhenClauseContext' ;
2020-10-09 19:35:46 +02:00
2020-11-12 21:13:28 +02:00
type LabelFunction = ( ) = > string ;
2020-10-18 22:52:10 +02:00
type EnabledCondition = string ;
2020-07-03 23:32:39 +02:00
2020-10-18 22:52:10 +02:00
export interface CommandContext {
// The state may also be of type "AppState" (used by the desktop app), which inherits from "State" (used by all apps)
2020-11-12 21:29:22 +02:00
state : State ;
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-11-12 21:29:22 +02:00
dispatch : Function ;
2020-10-18 22:52:10 +02:00
}
2020-10-09 19:35:46 +02:00
2020-10-18 22:52:10 +02:00
export interface CommandRuntime {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:29:22 +02:00
execute ( context : CommandContext , . . . args : any [ ] ) : Promise < any | void > ;
2020-10-18 22:52:10 +02:00
enabledCondition? : EnabledCondition ;
2020-07-03 23:32:39 +02:00
// Used for the (optional) toolbar button title
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:29:22 +02:00
mapStateToTitle ? ( state : any ) : string ;
2024-11-08 17:32:05 +02:00
// Used to break ties when commands are registered by different components.
getPriority ? ( state : State ) : number ;
2020-07-03 23:32:39 +02:00
}
export interface CommandDeclaration {
2020-11-12 21:29:22 +02:00
name : string ;
2020-09-13 18:21:11 +02:00
2020-07-03 23:32:39 +02:00
// Used for the menu item label, and toolbar button tooltip
2020-11-12 21:29:22 +02:00
label? : LabelFunction | string ;
2020-09-13 18:21:11 +02:00
2020-10-18 22:52:10 +02:00
// Command description - if none is provided, the label will be used as description
2020-11-12 21:29:22 +02:00
description? : string ;
2020-10-18 22:52:10 +02:00
2020-09-13 18:21:11 +02:00
// This is a bit of a hack because some labels don't make much sense in isolation. For example,
// the commmand to focus the note list is called just "Note list". This makes sense within the menu
// but not so much within the keymap config screen, where the parent item is not displayed. Because
// of this we have this "parentLabel()" property to clarify the meaning of the certain items.
// For example, the focusElementNoteList will have these two properties:
// label() => _('Note list'),
// parentLabel() => _('Focus'),
// Which will be displayed as "Focus: Note list" in the keymap config screen.
2020-11-12 21:29:22 +02:00
parentLabel? : LabelFunction | string ;
2020-10-09 19:35:46 +02:00
// All free Font Awesome icons are available: https://fontawesome.com/icons?d=gallery&m=free
2020-11-12 21:29:22 +02:00
iconName? : string ;
2020-10-09 19:35:46 +02:00
2020-07-03 23:32:39 +02:00
// Same as `role` key in Electron MenuItem:
// https://www.electronjs.org/docs/api/menu-item#new-menuitemoptions
// Note that due to a bug in Electron, menu items with a role cannot
// be disabled.
2020-11-12 21:29:22 +02:00
role? : string ;
2020-07-03 23:32:39 +02:00
}
export interface Command {
2020-11-12 21:29:22 +02:00
declaration : CommandDeclaration ;
2024-11-08 17:32:05 +02:00
runtime? : CommandRuntime | CommandRuntime [ ] ;
2020-07-03 23:32:39 +02:00
}
2024-04-06 19:08:16 +02:00
interface CommandSpec {
declaration : CommandDeclaration ;
runtime : ( ) = > CommandRuntime ;
}
2024-11-08 17:32:05 +02:00
export interface ComponentCommandSpec < ComponentType > {
2024-04-06 19:08:16 +02:00
declaration : CommandDeclaration ;
runtime : ( component : ComponentType ) = > CommandRuntime ;
}
2020-07-03 23:32:39 +02:00
interface Commands {
2020-11-12 21:13:28 +02:00
[ key : string ] : Command ;
2020-07-03 23:32:39 +02:00
}
interface ReduxStore {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
dispatch ( action : any ) : void ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
getState ( ) : any ;
2020-07-03 23:32:39 +02:00
}
interface Utils {
store : ReduxStore ;
}
2020-11-12 21:13:28 +02:00
export const utils : Utils = {
2020-07-03 23:32:39 +02:00
store : {
dispatch : ( ) = > { } ,
getState : ( ) = > { } ,
} ,
} ;
interface CommandByNameOptions {
2020-11-12 21:29:22 +02:00
mustExist? : boolean ;
runtimeMustBeRegistered? : boolean ;
2020-07-03 23:32:39 +02:00
}
2020-10-18 22:52:10 +02:00
export interface SearchResult {
2020-11-12 21:29:22 +02:00
commandName : string ;
title : string ;
2020-07-03 23:32:39 +02:00
}
2024-11-08 17:32:05 +02:00
export interface RegisteredRuntime {
deregister : ( ) = > void ;
}
2020-07-03 23:32:39 +02:00
export default class CommandService extends BaseService {
2020-11-12 21:13:28 +02:00
private static instance_ : CommandService ;
2020-07-03 23:32:39 +02:00
2020-11-12 21:13:28 +02:00
public static instance ( ) : CommandService {
2020-07-03 23:32:39 +02:00
if ( this . instance_ ) return this . instance_ ;
this . instance_ = new CommandService ( ) ;
return this . instance_ ;
}
2020-11-12 21:13:28 +02:00
private commands_ : Commands = { } ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2024-11-08 17:32:05 +02:00
private store_ : ReduxStore ;
2020-11-12 21:13:28 +02:00
private devMode_ : boolean ;
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-11-13 19:09:28 +02:00
private stateToWhenClauseContext_ : Function ;
2020-07-03 23:32:39 +02:00
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
2020-11-13 19:09:28 +02:00
public initialize ( store : any , devMode : boolean , stateToWhenClauseContext : Function ) {
2020-07-03 23:32:39 +02:00
utils . store = store ;
2020-10-18 22:52:10 +02:00
this . store_ = store ;
this . devMode_ = devMode ;
2020-11-13 19:09:28 +02:00
this . stateToWhenClauseContext_ = stateToWhenClauseContext ;
2020-07-03 23:32:39 +02:00
}
2024-05-28 12:24:20 +02:00
public on < Name extends EventName > ( eventName : Name , callback : EventListenerCallback < Name > ) {
2020-07-03 23:32:39 +02:00
eventManager . on ( eventName , callback ) ;
}
2024-05-28 12:24:20 +02:00
public off < Name extends EventName > ( eventName : Name , callback : EventListenerCallback < Name > ) {
2020-07-03 23:32:39 +02:00
eventManager . off ( eventName , callback ) ;
}
2023-06-30 10:11:26 +02:00
public searchCommands ( query : string , returnAllWhenEmpty : boolean , excludeWithoutLabel = true ) : SearchResult [ ] {
2020-10-18 22:52:10 +02:00
query = query . toLowerCase ( ) ;
const output = [ ] ;
2021-10-05 19:09:09 +02:00
const whenClauseContext = this . currentWhenClauseContext ( ) ;
2020-10-18 22:52:10 +02:00
for ( const commandName of this . commandNames ( ) ) {
const label = this . label ( commandName , true ) ;
if ( ! label && excludeWithoutLabel ) continue ;
2021-10-05 19:09:09 +02:00
if ( ! this . isEnabled ( commandName , whenClauseContext ) ) continue ;
2020-10-18 22:52:10 +02:00
const title = label ? ` ${ label } ( ${ commandName } ) ` : commandName ;
if ( ( returnAllWhenEmpty && ! query ) || title . toLowerCase ( ) . includes ( query ) ) {
output . push ( {
commandName : commandName ,
title : title ,
} ) ;
}
}
2020-11-12 21:13:28 +02:00
output . sort ( ( a : SearchResult , b : SearchResult ) = > {
2020-10-18 22:52:10 +02:00
return a . title . toLowerCase ( ) < b . title . toLowerCase ( ) ? - 1 : + 1 ;
} ) ;
return output ;
}
2023-06-30 10:11:26 +02:00
public commandNames ( publicOnly = false ) {
2020-10-31 14:25:12 +02:00
if ( publicOnly ) {
const output = [ ] ;
for ( const name in this . commands_ ) {
if ( ! this . isPublic ( name ) ) continue ;
output . push ( name ) ;
}
return output ;
} else {
return Object . keys ( this . commands_ ) ;
}
2020-10-18 22:52:10 +02:00
}
2020-11-12 21:13:28 +02:00
public commandByName ( name : string , options : CommandByNameOptions = null ) : Command {
2020-07-03 23:32:39 +02:00
options = {
mustExist : true ,
runtimeMustBeRegistered : false ,
2020-09-06 14:00:25 +02:00
. . . options ,
2020-07-03 23:32:39 +02:00
} ;
const command = this . commands_ [ name ] ;
if ( ! command ) {
if ( options . mustExist ) throw new Error ( ` Command not found: ${ name } . Make sure the declaration has been registered. ` ) ;
return null ;
}
if ( options . runtimeMustBeRegistered && ! command . runtime ) throw new Error ( ` Runtime is not registered for command ${ name } ` ) ;
return command ;
}
2020-11-12 21:13:28 +02:00
public registerDeclaration ( declaration : CommandDeclaration ) {
2020-07-03 23:32:39 +02:00
declaration = { . . . declaration } ;
2020-10-09 19:35:46 +02:00
if ( ! declaration . label ) declaration . label = '' ;
2023-09-19 12:29:19 +02:00
if ( ! declaration . iconName ) declaration . iconName = 'fas fa-cog' ;
2020-07-03 23:32:39 +02:00
this . commands_ [ declaration . name ] = {
declaration : declaration ,
} ;
}
2024-11-08 17:32:05 +02:00
public registerRuntime ( commandName : string , runtime : CommandRuntime , allowMultiple = false ) : RegisteredRuntime {
2020-07-03 23:32:39 +02:00
if ( typeof commandName !== 'string' ) throw new Error ( ` Command name must be a string. Got: ${ JSON . stringify ( commandName ) } ` ) ;
const command = this . commandByName ( commandName ) ;
2023-06-01 13:02:36 +02:00
runtime = { . . . runtime } ;
2020-10-18 22:52:10 +02:00
if ( ! runtime . enabledCondition ) runtime . enabledCondition = 'true' ;
2024-11-08 17:32:05 +02:00
if ( ! allowMultiple ) {
command . runtime = runtime ;
} else {
if ( ! Array . isArray ( command . runtime ) ) {
command . runtime = command . runtime ? [ command . runtime ] : [ ] ;
}
command . runtime . push ( runtime ) ;
}
return {
// Like .deregisterRuntime, but deletes only the current runtime if there are multiple runtimes
// for the same command.
deregister : ( ) = > {
const command = this . commandByName ( commandName ) ;
if ( Array . isArray ( command . runtime ) ) {
command . runtime = command . runtime . filter ( r = > {
return r !== runtime ;
} ) ;
if ( command . runtime . length === 0 ) {
delete command . runtime ;
}
} else if ( command . runtime ) {
delete command . runtime ;
}
} ,
} ;
2020-07-03 23:32:39 +02:00
}
2024-04-06 19:08:16 +02:00
public registerCommands ( commands : CommandSpec [ ] ) {
2022-04-14 17:50:42 +02:00
for ( const command of commands ) {
CommandService . instance ( ) . registerRuntime ( command . declaration . name , command . runtime ( ) ) ;
}
}
2024-04-06 19:08:16 +02:00
public unregisterCommands ( commands : CommandSpec [ ] ) {
2022-04-14 17:50:42 +02:00
for ( const command of commands ) {
CommandService . instance ( ) . unregisterRuntime ( command . declaration . name ) ;
}
}
2024-11-08 17:32:05 +02:00
public componentRegisterCommands < ComponentType > ( component : ComponentType , commands : ComponentCommandSpec < ComponentType > [ ] , allowMultiple? : boolean ) {
const runtimeHandles : RegisteredRuntime [ ] = [ ] ;
2020-07-03 23:32:39 +02:00
for ( const command of commands ) {
2024-11-08 17:32:05 +02:00
runtimeHandles . push (
CommandService . instance ( ) . registerRuntime ( command . declaration . name , command . runtime ( component ) , allowMultiple ) ,
) ;
2020-07-03 23:32:39 +02:00
}
2024-11-08 17:32:05 +02:00
return {
deregister : ( ) = > {
for ( const handle of runtimeHandles ) {
handle . deregister ( ) ;
}
} ,
} ;
2020-07-03 23:32:39 +02:00
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2024-04-06 19:08:16 +02:00
public componentUnregisterCommands ( commands : ComponentCommandSpec < any > [ ] ) {
2020-07-03 23:32:39 +02:00
for ( const command of commands ) {
CommandService . instance ( ) . unregisterRuntime ( command . declaration . name ) ;
}
}
2020-11-12 21:13:28 +02:00
public unregisterRuntime ( commandName : string ) {
2020-07-03 23:32:39 +02:00
const command = this . commandByName ( commandName , { mustExist : false } ) ;
if ( ! command || ! command . runtime ) return ;
delete command . runtime ;
}
2020-11-12 21:13:28 +02:00
private createContext ( ) : CommandContext {
2020-10-25 19:22:59 +02:00
return {
state : this.store_.getState ( ) ,
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
dispatch : ( action : any ) = > {
2020-10-25 19:22:59 +02:00
this . store_ . dispatch ( action ) ;
} ,
} ;
}
2024-11-08 17:32:05 +02:00
private getRuntime ( command : Command ) {
if ( ! Array . isArray ( command . runtime ) ) return command . runtime ;
if ( ! command . runtime . length ) return null ;
let bestRuntime = null ;
let bestRuntimeScore = - 1 ;
for ( const runtime of command . runtime ) {
const score = runtime . getPriority ? . ( this . store_ . getState ( ) ) ? ? 0 ;
if ( score >= bestRuntimeScore ) {
bestRuntime = runtime ;
bestRuntimeScore = score ;
}
}
return bestRuntime ;
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
public async execute ( commandName : string , . . . args : any [ ] ) : Promise < any | void > {
2020-07-03 23:32:39 +02:00
const command = this . commandByName ( commandName ) ;
2020-12-23 19:25:26 +02:00
// Some commands such as "showModalMessage" can be executed many
// times per seconds, so we should only display this message in
// debug mode.
2021-01-08 19:49:53 +02:00
if ( commandName !== 'showModalMessage' ) this . logger ( ) . debug ( 'CommandService::execute:' , commandName , args ) ;
2024-11-08 17:32:05 +02:00
const runtime = this . getRuntime ( command ) ;
if ( ! runtime ) throw new Error ( ` Cannot execute a command without a runtime: ${ commandName } ` ) ;
return runtime . execute ( this . createContext ( ) , . . . args ) ;
2020-07-03 23:32:39 +02:00
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
public scheduleExecute ( commandName : string , args : any ) {
2020-10-09 19:35:46 +02:00
shim . setTimeout ( ( ) = > {
2020-11-25 16:40:25 +02:00
void this . execute ( commandName , args ) ;
2020-07-03 23:32:39 +02:00
} , 10 ) ;
}
2024-07-26 13:35:50 +02:00
public currentWhenClauseContext ( ) : WhenClauseContext {
2020-11-13 19:09:28 +02:00
return this . stateToWhenClauseContext_ ( this . store_ . getState ( ) ) ;
2020-07-03 23:32:39 +02:00
}
2020-11-12 21:13:28 +02:00
public isPublic ( commandName : string ) {
2020-10-31 14:25:12 +02:00
return ! ! this . label ( commandName ) ;
}
2020-10-18 22:52:10 +02:00
// When looping on commands and checking their enabled state, the whenClauseContext
// should be specified (created using currentWhenClauseContext) to avoid having
// to re-create it on each call.
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
public isEnabled ( commandName : string , whenClauseContext : any = null ) : boolean {
2020-07-03 23:32:39 +02:00
const command = this . commandByName ( commandName ) ;
2024-11-08 17:32:05 +02:00
if ( ! command ) return false ;
const runtime = this . getRuntime ( command ) ;
if ( ! runtime ) return false ;
2020-10-18 22:52:10 +02:00
if ( ! whenClauseContext ) whenClauseContext = this . currentWhenClauseContext ( ) ;
2024-11-08 17:32:05 +02:00
const exp = new WhenClause ( runtime . enabledCondition , this . devMode_ ) ;
2020-10-18 22:52:10 +02:00
return exp . evaluate ( whenClauseContext ) ;
2020-07-03 23:32:39 +02:00
}
2020-10-18 22:52:10 +02:00
// The title is dynamic and derived from the state, which is why the state is passed
// as an argument. Title can be used for example to display the alarm date on the
// "set alarm" toolbar button.
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
public title ( commandName : string , state : any = null ) : string {
2020-10-09 19:35:46 +02:00
const command = this . commandByName ( commandName ) ;
2024-11-08 17:32:05 +02:00
if ( ! command ) return null ;
const runtime = this . getRuntime ( command ) ;
if ( ! runtime ) return null ;
2020-10-18 22:52:10 +02:00
state = state || this . store_ . getState ( ) ;
2024-11-08 17:32:05 +02:00
if ( runtime . mapStateToTitle ) {
return runtime . mapStateToTitle ( state ) ;
2020-10-18 22:52:10 +02:00
} else {
return '' ;
}
2020-10-09 19:35:46 +02:00
}
2023-07-21 21:49:49 +02:00
public iconName ( commandName : string ) : string {
2020-09-15 15:01:07 +02:00
const command = this . commandByName ( commandName ) ;
if ( ! command ) throw new Error ( ` No such command: ${ commandName } ` ) ;
2023-07-21 21:49:49 +02:00
2020-09-15 15:01:07 +02:00
return command . declaration . iconName ;
}
2023-06-30 10:11:26 +02:00
public label ( commandName : string , fullLabel = false ) : string {
2020-09-06 14:00:25 +02:00
const command = this . commandByName ( commandName ) ;
if ( ! command ) throw new Error ( ` Command: ${ commandName } is not declared ` ) ;
2020-09-13 18:21:11 +02:00
const output = [ ] ;
2020-10-09 19:35:46 +02:00
2020-11-12 21:13:28 +02:00
const parentLabel = ( d : CommandDeclaration ) : string = > {
2020-10-09 19:35:46 +02:00
if ( ! d . parentLabel ) return '' ;
if ( typeof d . parentLabel === 'function' ) return d . parentLabel ( ) ;
return d . parentLabel as string ;
} ;
if ( fullLabel && parentLabel ( command . declaration ) ) output . push ( parentLabel ( command . declaration ) ) ;
output . push ( typeof command . declaration . label === 'function' ? command . declaration . label ( ) : command . declaration . label ) ;
2020-09-13 18:21:11 +02:00
return output . join ( ': ' ) ;
2020-09-06 14:00:25 +02:00
}
2020-11-12 21:13:28 +02:00
public description ( commandName : string ) : string {
2020-10-18 22:52:10 +02:00
const command = this . commandByName ( commandName ) ;
if ( command . declaration . description ) return command . declaration . description ;
return this . label ( commandName , true ) ;
2020-09-06 14:00:25 +02:00
}
2020-11-12 21:13:28 +02:00
public exists ( commandName : string ) : boolean {
2020-10-18 22:52:10 +02:00
const command = this . commandByName ( commandName , { mustExist : false } ) ;
return ! ! command ;
2020-07-03 23:32:39 +02:00
}
}