├── .gitignore ├── src ├── Resources │ ├── docs │ │ ├── contao-trigger-ex1.png │ │ ├── installing.md │ │ └── extending.md │ ├── contao │ │ ├── languages │ │ │ ├── de │ │ │ │ ├── default.php │ │ │ │ ├── tl_nc_notification.php │ │ │ │ ├── tl_eblick_trigger_log.php │ │ │ │ └── tl_eblick_trigger.php │ │ │ └── en │ │ │ │ ├── default.php │ │ │ │ ├── tl_nc_notification.php │ │ │ │ ├── tl_eblick_trigger_log.php │ │ │ │ └── tl_eblick_trigger.php │ │ ├── config │ │ │ └── config.php │ │ └── dca │ │ │ ├── tl_eblick_trigger_log.php │ │ │ └── tl_eblick_trigger.php │ ├── public │ │ ├── img │ │ │ ├── simulate.svg │ │ │ ├── reset.svg │ │ │ ├── log.svg │ │ │ ├── running.svg │ │ │ ├── waiting.svg │ │ │ ├── error.svg │ │ │ ├── paused.svg │ │ │ └── automation.svg │ │ └── css │ │ │ └── backend.css │ └── config │ │ ├── services.yml │ │ └── listener.yml ├── Execution │ ├── ExecutionException.php │ ├── ExecutionContextFactory.php │ ├── ExecutionLog.php │ └── ExecutionContext.php ├── EventListener │ ├── DataContainer │ │ ├── Common.php │ │ ├── ExecutionLog.php │ │ ├── NotificationAction.php │ │ ├── TableCondition.php │ │ └── Trigger.php │ └── TriggerListener.php ├── DataContainer │ ├── Definition.php │ └── DataContainerComponentInterface.php ├── EBlickContaoTriggerBundle.php ├── Component │ ├── Action │ │ ├── ActionInterface.php │ │ └── NotificationAction.php │ ├── Condition │ │ ├── ConditionInterface.php │ │ ├── TimeCondition.php │ │ └── TableCondition.php │ └── ComponentManager.php ├── ContaoManager │ └── Plugin.php ├── DependencyInjection │ ├── EBlickContaoTriggerExtension.php │ └── Compiler │ │ └── AddComponentsCompilerPass.php └── ExpressionLanguage │ └── RowDataCompiler.php ├── tools └── ecs │ ├── composer.json │ ├── config │ └── default.php │ └── composer.lock ├── phpunit.xml.dist ├── tests ├── Execution │ ├── ExecutionContextFactoryTest.php │ ├── ExecutionContextTest.php │ └── ExecutionLogTest.php ├── EBlickContaoTriggerBundleTest.php ├── ContaoManager │ └── PluginTest.php ├── EventListener │ ├── TriggerListenerTest.php │ └── DataContainer │ │ ├── TableConditionTest.php │ │ └── NotificationActionTest.php ├── DependencyInjection │ ├── EBlickContaoTriggerExtensionTest.php │ └── Compiler │ │ └── AddComponentsCompilerPassTest.php ├── ExpressionLanguage │ └── RowDataCompilerTest.php └── Component │ ├── ComponentManagerTest.php │ ├── Condition │ ├── TableConditionTest.php │ └── TimeConditionTest.php │ └── Action │ └── NotificationActionTest.php ├── README.md ├── composer.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor 3 | /composer.lock 4 | /.phpunit.result.cache 5 | /tools/*/vendor 6 | -------------------------------------------------------------------------------- /src/Resources/docs/contao-trigger-ex1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eBlick/contao-trigger/HEAD/src/Resources/docs/contao-trigger-ex1.png -------------------------------------------------------------------------------- /tools/ecs/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "contao/easy-coding-standard": "^5.0" 4 | }, 5 | "config": { 6 | "allow-plugins": { 7 | "dealerdirect/phpcodesniffer-composer-installer": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Execution/ExecutionException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | ./tests/ 10 | 11 | 12 | 13 | 14 | ./src/ 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/DataContainer/Definition.php: -------------------------------------------------------------------------------- 1 | executionLog); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Resources/public/img/simulate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/EBlickContaoTriggerBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass( 22 | new AddComponentsCompilerPass() 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Resources/public/img/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/Component/Action/ActionInterface.php: -------------------------------------------------------------------------------- 1 | createMock(ExecutionLog::class)); 23 | $parameters = new \stdClass(); 24 | $parameters->id = 4; 25 | 26 | $context = $factory->createExecutionContext($parameters, 1000); 27 | 28 | self::assertInstanceOf(ExecutionContext::class, $context); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/ExecutionLog.php: -------------------------------------------------------------------------------- 1 | [%s]', 23 | $GLOBALS['TL_LANG']['tl_eblick_trigger_log']['simulated'][0] 24 | ) : ''; 25 | 26 | return sprintf( 27 | '%s  (\'%s\' . \'%s\')%s', 28 | Date::parse(Config::get('datimFormat'), $row['tstamp']), 29 | $row['origin'], 30 | $row['originId'], 31 | $simulated 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tools/ecs/config/default.php: -------------------------------------------------------------------------------- 1 | sets([__DIR__.'/../vendor/contao/easy-coding-standard/config/contao.php']); 12 | $ecsConfig->parallel(); 13 | 14 | $ecsConfig->ruleWithConfiguration(HeaderCommentFixer::class, [ 15 | 'header' => "@copyright eBlick Medienberatung\n@license LGPL-3.0+\n@link https://github.com/eBlick/contao-trigger", 16 | ]); 17 | 18 | $ecsConfig->ruleWithConfiguration( 19 | PhpUnitTestCaseStaticMethodCallsFixer::class, 20 | ['call_type' => 'self'] 21 | ); 22 | 23 | $parameters = $ecsConfig->parameters(); 24 | $parameters->set(Option::CACHE_DIRECTORY, sys_get_temp_dir().'/ecs_cache'); 25 | }; 26 | -------------------------------------------------------------------------------- /tests/EBlickContaoTriggerBundleTest.php: -------------------------------------------------------------------------------- 1 | createMock(ContainerBuilder::class); 23 | $containerBuilder 24 | ->expects(self::once()) 25 | ->method('addCompilerPass') 26 | ->with(new AddComponentsCompilerPass()) 27 | ; 28 | 29 | $bundle = new EBlickContaoTriggerBundle(); 30 | $bundle->build($containerBuilder); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ContaoManager/Plugin.php: -------------------------------------------------------------------------------- 1 | setLoadAfter( 26 | [ 27 | ContaoCoreBundle::class, 28 | 'notification-center', 29 | ] 30 | ), 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/DependencyInjection/EBlickContaoTriggerExtension.php: -------------------------------------------------------------------------------- 1 | load($file); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Resources/public/img/log.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/ContaoManager/PluginTest.php: -------------------------------------------------------------------------------- 1 | getBundles($this->createMock(ParserInterface::class)); 26 | 27 | /** @var BundleConfig $config */ 28 | $config = $bundles[0]; 29 | 30 | self::assertCount(1, $bundles); 31 | self::assertInstanceOf(BundleConfig::class, $config); 32 | self::assertEquals(EBlickContaoTriggerBundle::class, $config->getName()); 33 | self::assertEquals([ContaoCoreBundle::class, 'notification-center'], $config->getLoadAfter()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Resources/docs/installing.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | #### Step 1: Download the Bundle 5 | 6 | Open a command console, enter your project directory and execute the 7 | following command to download the latest stable version of this bundle: 8 | 9 | ```console 10 | $ composer require eblick/contao-trigger 11 | ``` 12 | 13 | 14 | #### Step 2: Enable the Bundle 15 | 16 | **Skip this point if you are using a *Managed Edition* of Contao.** 17 | 18 | Enable the bundle by adding it to the list of registered bundles 19 | in the `app/AppKernel.php` file of your project: 20 | 21 | ```php 22 | null, 'myOtherValue' => null] 34 | */ 35 | public function getDataPrototype(int $triggerId): array; 36 | } 37 | -------------------------------------------------------------------------------- /src/ExpressionLanguage/RowDataCompiler.php: -------------------------------------------------------------------------------- 1 | compile($expression, $dataMapping); 34 | 35 | // evaluate in callback 36 | return 37 | static fn ($rowData): bool => true === @eval('return '.$compiledExpression.';'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Resources/public/img/running.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Resources/public/img/waiting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/AddComponentsCompilerPass.php: -------------------------------------------------------------------------------- 1 | container = $container; 26 | $this->componentManager = $container->getDefinition('eblick_contao_trigger.component.component_manager'); 27 | 28 | $this->addToManager('eblick_contao_trigger.condition', 'addCondition'); 29 | $this->addToManager('eblick_contao_trigger.action', 'addAction'); 30 | } 31 | 32 | private function addToManager(string $tagName, string $method): void 33 | { 34 | foreach ($this->container->findTaggedServiceIds($tagName) as $id => $tags) { 35 | foreach ($tags as $attributes) { 36 | $this->componentManager->addMethodCall($method, [new Reference($id), $attributes['alias']]); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Execution/ExecutionLog.php: -------------------------------------------------------------------------------- 1 | connection->executeQuery($query, $params)->fetchAllAssociative() as $result) { 34 | $logs[$result['originId']] = $result; 35 | } 36 | 37 | return $logs; 38 | } 39 | 40 | public function addLog(int $triggerId, int $originId, string $origin, bool $simulated): void 41 | { 42 | if (!$origin) { 43 | throw new \InvalidArgumentException(sprintf('Origin can\'t be empty in trigger %s!', $triggerId)); 44 | } 45 | 46 | $this->connection 47 | ->executeQuery( 48 | 'INSERT INTO tl_eblick_trigger_log SET pid=?, tstamp=?, originId=?, origin=?, simulated=?', 49 | [$triggerId, time(), $originId, $origin, $simulated] 50 | ) 51 | ; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Resources/public/img/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/Resources/public/img/paused.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/public/img/automation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | contao-trigger 2 | ============== 3 | This bundle adds an extensible **condition ⇒ action** framework to 4 | Contao OpenSource CMS. The condition checking is processed on a regular 5 | basis via a cron job. If one or more actions are executed a respective 6 | entry gets created in the trigger log. 7 | 8 | ![](src/Resources/docs/contao-trigger-ex1.png) 9 | 10 | Components 11 | ---------- 12 | 13 | By default the following components are available: 14 | 15 | - **Conditions** 16 | - **Table Records**: Executes an action at most once for each of a 17 | selected table's rows if: 18 | 19 | *A)* a *custom expression* based on the table's columns is met 20 | > category == 'things' and sum_total - coupon 2 > 100 21 | 22 | *B)* a field containing datetime information matches a given 23 | *time constraint* (e.g. 7 days later / 15 minutes before). When 24 | using the latter, the execution time can be overwritten (e.g. 3 25 | days in advance, but at 6pm). 26 | 27 | - **Point in Time**: Executes an action as soon as a given point in 28 | time is reached. This allows basic scheduling. 29 | 30 | - **Actions** 31 | - **Notification Action**: Allows to send a custom notification via 32 | `terminal42\notification-center` (must be installed individually). 33 | The available simple tokens are based on the selected condition 34 | and are displayed in the backend. 35 | 36 | 37 | 38 | Installation 39 | ------------ 40 | - [Installation / Setup](src/Resources/docs/installing.md) 41 | 42 | Extending the framework 43 | ----------------------- 44 | - [Adding conditions & actions](src/Resources/docs/extending.md) -------------------------------------------------------------------------------- /tests/EventListener/TriggerListenerTest.php: -------------------------------------------------------------------------------- 1 | createMock(Result::class); 27 | $result 28 | ->expects(self::once()) 29 | ->method('fetchAllAssociative') 30 | ->willReturn([]) 31 | ; 32 | 33 | $connection = $this->createMock(Connection::class); 34 | $connection 35 | ->expects(self::once()) 36 | ->method('executeQuery') 37 | ->with('SELECT * FROM tl_eblick_trigger WHERE enabled = 1 && error IS NULL') 38 | ->willReturn($result) 39 | ; 40 | 41 | $listener = new TriggerListener( 42 | $this->createMock(ComponentManager::class), 43 | $connection, 44 | $this->createMock(LoggerInterface::class), 45 | $this->createMock(ExecutionContextFactory::class), 46 | $this->createMock(RequestStack::class) 47 | ); 48 | 49 | \define('TL_MODE', 'FE'); 50 | $listener->onExecute(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Resources/contao/config/config.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'eblick_trigger' => [ 20 | 'tables' => ['tl_eblick_trigger', 'tl_eblick_trigger_log'], 21 | 'execute' => ['eblick_contao_trigger.listener.datacontainer.trigger', 'onExecute'], 22 | 'simulate' => ['eblick_contao_trigger.listener.datacontainer.trigger', 'onSimulate'], 23 | 'reset' => ['eblick_contao_trigger.listener.datacontainer.trigger', 'onReset'], 24 | ], 25 | ], 26 | ] 27 | ); 28 | 29 | // Generic Notification Center action 30 | $GLOBALS['NOTIFICATION_CENTER']['NOTIFICATION_TYPE']['eblick_trigger'] = [ 31 | // Type 32 | 'eblick_notification_action' => [ 33 | 'recipients' => ['admin_email', 'data_*'], 34 | 'email_subject' => ['data_*', 'trigger_id', 'trigger_title', 'trigger_startTime', 'admin_email'], 35 | 'email_text' => ['data_*', 'trigger_id', 'trigger_title', 'trigger_startTime', 'admin_email'], 36 | 'email_html' => ['data_*', 'trigger_id', 'trigger_title', 'trigger_startTime', 'admin_email'], 37 | 'email_sender_name' => ['data_*', 'admin_email'], 38 | 'email_sender_address' => ['data_*', 'admin_email'], 39 | 'email_recipient_cc' => ['data_*', 'admin_email'], 40 | 'email_recipient_bcc' => ['data_*', 'admin_email'], 41 | 'email_replyTo' => ['data_*', 'admin_email'], 42 | ], 43 | ]; 44 | -------------------------------------------------------------------------------- /tests/DependencyInjection/EBlickContaoTriggerExtensionTest.php: -------------------------------------------------------------------------------- 1 | load([], $container); 25 | 26 | $definitions = [ 27 | 'eblick_contao_trigger.execution.row_data_compiler', 28 | 'eblick_contao_trigger.execution.log', 29 | 'eblick_contao_trigger.execution.context_factory', 30 | 'eblick_contao_trigger.component.component_manager', 31 | 32 | 'eblick_contao_trigger.component.table_condition', 33 | 'eblick_contao_trigger.component.notification_action', 34 | 35 | 'eblick_contao_trigger.listener.trigger', 36 | 37 | 'eblick_contao_trigger.listener.datacontainer.trigger', 38 | 'eblick_contao_trigger.listener.datacontainer.trigger_log', 39 | 'eblick_contao_trigger.listener.datacontainer.table_condition', 40 | 'eblick_contao_trigger.listener.datacontainer.notification_action', 41 | ]; 42 | 43 | foreach ($definitions as $definition) { 44 | self::assertTrue($container->hasDefinition($definition)); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/ExpressionLanguage/RowDataCompilerTest.php: -------------------------------------------------------------------------------- 1 | compileRowExpression($expression, $columnNames); 27 | 28 | self::assertFalse($closure(['a' => 1, 'b' => 2, 'c' => 3])); // 1 + 2 == 5 - 3 29 | self::assertTrue($closure(['a' => 1, 'b' => 2, 'c' => 2])); // 1 + 2 == 5 - 2 30 | } 31 | 32 | public function testCompileRowExpressionWithSyntaxError(): void 33 | { 34 | $compiler = new RowDataCompiler(); 35 | 36 | $expression = 'a + b =x= (5 - c)'; 37 | $columnNames = ['a', 'b', 'c']; 38 | 39 | $this->expectException(SyntaxError::class); 40 | $compiler->compileRowExpression($expression, $columnNames); 41 | } 42 | 43 | public function testCompileRowExpressionWithMissingColumnNames(): void 44 | { 45 | $compiler = new RowDataCompiler(); 46 | 47 | $expression = 'a + b == (5 - c)'; 48 | $columnNames = ['a', 'b']; 49 | 50 | $this->expectException(SyntaxError::class); 51 | $compiler->compileRowExpression($expression, $columnNames); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _instanceof: 3 | Contao\CoreBundle\Framework\FrameworkAwareInterface: 4 | calls: 5 | - ['setFramework', ['@contao.framework']] 6 | 7 | Symfony\Component\DependencyInjection\ContainerAwareInterface: 8 | calls: 9 | - ['setContainer', ['@service_container']] 10 | 11 | # execution environment 12 | eblick_contao_trigger.execution.row_data_compiler: 13 | class: EBlick\ContaoTrigger\ExpressionLanguage\RowDataCompiler 14 | 15 | eblick_contao_trigger.execution.log: 16 | class: EBlick\ContaoTrigger\Execution\ExecutionLog 17 | arguments: 18 | - '@database_connection' 19 | 20 | eblick_contao_trigger.execution.context_factory: 21 | class: EBlick\ContaoTrigger\Execution\ExecutionContextFactory 22 | arguments: 23 | - '@eblick_contao_trigger.execution.log' 24 | 25 | # condition and action components 26 | eblick_contao_trigger.component.component_manager: 27 | class: EBlick\ContaoTrigger\Component\ComponentManager 28 | 29 | eblick_contao_trigger.component.table_condition: 30 | class: EBlick\ContaoTrigger\Component\Condition\TableCondition 31 | arguments: 32 | - '@database_connection' 33 | - '@eblick_contao_trigger.execution.row_data_compiler' 34 | - '@contao.framework' 35 | tags: 36 | - { name: eblick_contao_trigger.condition, alias: table } 37 | 38 | eblick_contao_trigger.component.time_condition: 39 | class: EBlick\ContaoTrigger\Component\Condition\TimeCondition 40 | arguments: 41 | - '@database_connection' 42 | tags: 43 | - { name: eblick_contao_trigger.condition, alias: time } 44 | 45 | eblick_contao_trigger.component.notification_action: 46 | class: EBlick\ContaoTrigger\Component\Action\NotificationAction 47 | arguments: 48 | - '@contao.framework' 49 | tags: 50 | - { name: eblick_contao_trigger.action, alias: notification } 51 | -------------------------------------------------------------------------------- /tests/EventListener/DataContainer/TableConditionTest.php: -------------------------------------------------------------------------------- 1 | createMock(Table::class); 26 | $table1 27 | ->expects(self::once()) 28 | ->method('getName') 29 | ->willReturn('tl_eblick_trigger') 30 | ; 31 | 32 | $table2 = $this->createMock(Table::class); 33 | $table2 34 | ->expects(self::once()) 35 | ->method('getName') 36 | ->willReturn('testTable2') 37 | ; 38 | 39 | $schemaManager = $this->createMock(AbstractSchemaManager::class); 40 | $schemaManager 41 | ->expects(self::once()) 42 | ->method('listTables') 43 | ->willReturn([$table1, $table2]) 44 | ; 45 | 46 | $connection = $this->createMock(Connection::class); 47 | $connection 48 | ->expects(self::once()) 49 | ->method('createSchemaManager') 50 | ->willReturn($schemaManager) 51 | ; 52 | 53 | $condition = new TableCondition( 54 | $connection, 55 | $this->createMock(RowDataCompiler::class), 56 | $this->createMock(ContaoFramework::class) 57 | ); 58 | 59 | self::assertEquals(['testTable2' => 'testTable2'], $condition->onGetTables()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Component/ComponentManager.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private array $conditions = []; 22 | 23 | /** 24 | * @var array 25 | */ 26 | private array $actions = []; 27 | 28 | /** 29 | * Register a condition. 30 | */ 31 | public function addCondition(ConditionInterface $condition, string $alias): void 32 | { 33 | $this->conditions[$alias] = $condition; 34 | } 35 | 36 | /** 37 | * Register an action. 38 | */ 39 | public function addAction(ActionInterface $action, string $name): void 40 | { 41 | $this->actions[$name] = $action; 42 | } 43 | 44 | /** 45 | * Returns an array of all registered condition names. 46 | * 47 | * @return list 48 | */ 49 | public function getConditionNames(): array 50 | { 51 | return array_keys($this->conditions); 52 | } 53 | 54 | /** 55 | * Get a certain condition. 56 | */ 57 | public function getCondition(string $name): ConditionInterface|null 58 | { 59 | return $this->conditions[$name] ?? null; 60 | } 61 | 62 | /** 63 | * Returns an array of all registered action names. 64 | * 65 | * @return list 66 | */ 67 | public function getActionNames(): array 68 | { 69 | return array_keys($this->actions); 70 | } 71 | 72 | /** 73 | * Get a certain action. 74 | */ 75 | public function getAction(string $name): ActionInterface|null 76 | { 77 | return $this->actions[$name] ?? null; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Resources/public/css/backend.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Trigger Framework Bundle for Contao Open Source CMS 3 | * 4 | * @copyright Copyright (c) 2018, eBlick Medienberatung 5 | * @license LGPL-3.0+ 6 | * @link https://github.com/eBlick/contao-trigger 7 | * 8 | * @author Moritz Vondano 9 | */ 10 | 11 | #tl_navigation .group-automation { 12 | background: url(../img/automation.svg) no-repeat 4px 1px; 13 | background-size: 15px 15px; 14 | } 15 | 16 | .w25 { 17 | width: calc(25% - 30px); 18 | float: left; 19 | height: 80px; 20 | min-width: 100px; 21 | } 22 | 23 | .w16 { 24 | width: calc(16.66% - 31px); 25 | float: left; 26 | height: 80px; 27 | min-width: 100px; 28 | } 29 | 30 | .wizard.w25 .tl_text, .wizard.w25 .tl_select, 31 | .wizard.w16 .tl_text, .wizard.w16 .tl_select { 32 | max-width: calc(100% - 24px); 33 | } 34 | 35 | .trigger-error { 36 | color: #cc3333; 37 | } 38 | 39 | .trigger-error span { 40 | display: block; 41 | background-color: #ffeded; 42 | margin: 5px; 43 | padding: 10px; 44 | border-radius: 5px; 45 | } 46 | 47 | .trigger-list .title { 48 | display: block; 49 | font-weight: bold; 50 | margin-bottom: 8px; 51 | } 52 | 53 | .trigger-list .icon { 54 | float: left; 55 | width: 25px; 56 | height: 25px; 57 | margin-right: 6px; 58 | background-size: 100%; 59 | } 60 | 61 | .trigger-list.trigger-state-paused .icon { 62 | background-image: url(../img/paused.svg); 63 | } 64 | 65 | .trigger-list.trigger-state-waiting .icon { 66 | background-image: url(../img/waiting.svg); 67 | } 68 | 69 | .trigger-list.trigger-state-running .icon { 70 | background-image: url(../img/running.svg); 71 | } 72 | 73 | .trigger-list.trigger-state-error .icon { 74 | background-image: url(../img/error.svg); 75 | } 76 | 77 | .trigger-list.trigger-state-error .title { 78 | color: #cc3333; 79 | } 80 | 81 | .trigger-list .type { 82 | margin-bottom: 4px; 83 | } 84 | 85 | .trigger-list .type span, .trigger-simulated { 86 | color: #999; 87 | } 88 | -------------------------------------------------------------------------------- /src/Execution/ExecutionContext.php: -------------------------------------------------------------------------------- 1 | triggerParameters; 28 | } 29 | 30 | /** 31 | * Returns the start time of execution as a timestamp. 32 | */ 33 | public function getStartTime(): int 34 | { 35 | return $this->startTime; 36 | } 37 | 38 | /** 39 | * Returns an array of log entries associated with this trigger with keys being the origin ids and values a 40 | * parameter object of all columns. 41 | */ 42 | public function getLog(string $origin = 'tl_eblick_trigger'): array 43 | { 44 | return $this->executionLog->getLog((int) $this->triggerParameters->id, $origin); 45 | } 46 | 47 | /** 48 | * Returns an array of log entries associated with this trigger with keys being the origin ids and values a 49 | * parameter object of all columns. 50 | */ 51 | public function getAllLogs(): array 52 | { 53 | return $this->executionLog->getLog((int) $this->triggerParameters->id); 54 | } 55 | 56 | /** 57 | * Adds a new log entry for this trigger. 58 | */ 59 | public function addLog(int $originId, string $origin = 'tl_eblick_trigger', bool $simulated = false): void 60 | { 61 | $this->executionLog->addLog((int) $this->triggerParameters->id, $originId, $origin, $simulated); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Resources/config/listener.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # trigger execution 3 | eblick_contao_trigger.listener.trigger: 4 | class: 'EBlick\ContaoTrigger\EventListener\TriggerListener' 5 | arguments: 6 | - '@eblick_contao_trigger.component.component_manager' 7 | - '@database_connection' 8 | - '@logger' 9 | - '@eblick_contao_trigger.execution.context_factory' 10 | - '@request_stack' 11 | public: true 12 | tags: 13 | - { name: 'contao.cronjob', interval: 'minutely', method: 'onExecute'} 14 | 15 | # data container 16 | eblick_contao_trigger.listener.datacontainer.common: 17 | class: 'EBlick\ContaoTrigger\EventListener\DataContainer\Common' 18 | tags: 19 | - { name: 'contao.callback', table: 'tl_eblick_trigger', target: 'config.onload', method: 'addBackendCss'} 20 | - { name: 'contao.callback', table: 'tl_eblick_trigger_log', target: 'config.onload', method: 'addBackendCss'} 21 | 22 | eblick_contao_trigger.listener.datacontainer.trigger: 23 | class: 'EBlick\ContaoTrigger\EventListener\DataContainer\Trigger' 24 | arguments: 25 | - '@eblick_contao_trigger.component.component_manager' 26 | - '@database_connection' 27 | - '@eblick_contao_trigger.listener.trigger' 28 | - '@contao.framework' 29 | tags: 30 | - { name: 'contao.hook', hook: 'loadDataContainer', method: 'onImportDefinitions'} 31 | public: true 32 | 33 | eblick_contao_trigger.listener.datacontainer.trigger_log: 34 | class: 'EBlick\ContaoTrigger\EventListener\DataContainer\ExecutionLog' 35 | public: true 36 | 37 | eblick_contao_trigger.listener.datacontainer.table_condition: 38 | class: 'EBlick\ContaoTrigger\EventListener\DataContainer\TableCondition' 39 | arguments: 40 | - '@database_connection' 41 | - '@eblick_contao_trigger.execution.row_data_compiler' 42 | - '@contao.framework' 43 | public: true 44 | 45 | eblick_contao_trigger.listener.datacontainer.notification_action: 46 | class: 'EBlick\ContaoTrigger\EventListener\DataContainer\NotificationAction' 47 | arguments: 48 | - '@eblick_contao_trigger.component.component_manager' 49 | - '@database_connection' 50 | public: true 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eblick/contao-trigger", 3 | "type": "contao-bundle", 4 | "description": "Time and condition based trigger framework for notifications and other things inside Contao Open Source CMS", 5 | "keywords": [ 6 | "contao", 7 | "trigger", 8 | "time", 9 | "table", 10 | "automation", 11 | "notifications" 12 | ], 13 | "license": "LGPL-3.0", 14 | "authors": [ 15 | { 16 | "name": "eBlick Medienberatung", 17 | "homepage": "https://eblick-medienberatung.de" 18 | }, 19 | { 20 | "name": "Moritz Vondano", 21 | "homepage": "https://github.com/m-vo", 22 | "role": "developer" 23 | } 24 | ], 25 | "require": { 26 | "php": ">=8.1", 27 | "contao/core-bundle": "^4.13 || ^5.1", 28 | "symfony/expression-language": "^5.4 || ^6.0", 29 | "symfony/stopwatch": "^5.4 || ^6.0", 30 | "symfony/lock": "^5.4 || ^6.0", 31 | "doctrine/dbal": "^3.3" 32 | }, 33 | "require-dev": { 34 | "bamarni/composer-bin-plugin": "^1.4", 35 | "contao/manager-plugin": "^2.0", 36 | "terminal42/notification_center": "^1.7", 37 | "phpunit/phpunit": "^8.4", 38 | "contao/test-case": "^4.2" 39 | }, 40 | "suggest": { 41 | "terminal42/notification_center": "^1.7" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "EBlick\\ContaoTrigger\\": "src/", 46 | "EBlick\\ContaoTrigger\\Test\\": "tests/" 47 | } 48 | }, 49 | "extra": { 50 | "contao-manager-plugin": "EBlick\\ContaoTrigger\\ContaoManager\\Plugin", 51 | "bamarni-bin": { 52 | "bin-links": false, 53 | "target-directory": "tools" 54 | } 55 | }, 56 | "scripts": { 57 | "cs": [ 58 | "tools/ecs/vendor/bin/ecs check src tests --config tools/ecs/config/default.php --fix --ansi" 59 | ], 60 | "tests": [ 61 | "vendor/bin/phpunit --colors=always" 62 | ] 63 | }, 64 | "config": { 65 | "allow-plugins": { 66 | "bamarni/composer-bin-plugin": true, 67 | "contao-components/installer": true, 68 | "contao/manager-plugin": true, 69 | "contao-community-alliance/composer-plugin": true, 70 | "php-http/discovery": false 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Component/ComponentManagerTest.php: -------------------------------------------------------------------------------- 1 | createMock(ConditionInterface::class); 25 | $manager->addCondition($condition, 'testCondition'); 26 | 27 | self::assertEquals($condition, $manager->getCondition('testCondition')); 28 | } 29 | 30 | public function testAddAndGetAction(): void 31 | { 32 | $manager = new ComponentManager(); 33 | 34 | $action = $this->createMock(ActionInterface::class); 35 | $manager->addAction($action, 'testAction'); 36 | 37 | self::assertEquals($action, $manager->getAction('testAction')); 38 | } 39 | 40 | public function testGetConditionNames(): void 41 | { 42 | $manager = new ComponentManager(); 43 | 44 | $condition1 = $this->createMock(ConditionInterface::class); 45 | $condition2 = $this->createMock(ConditionInterface::class); 46 | $manager->addCondition($condition1, 'testCondition1'); 47 | $manager->addCondition($condition2, 'testCondition2'); 48 | 49 | self::assertEquals(['testCondition1', 'testCondition2'], $manager->getConditionNames()); 50 | } 51 | 52 | public function testGetActionNames(): void 53 | { 54 | $manager = new ComponentManager(); 55 | 56 | $action1 = $this->createMock(ActionInterface::class); 57 | $action2 = $this->createMock(ActionInterface::class); 58 | $manager->addAction($action1, 'testAction1'); 59 | $manager->addAction($action2, 'testAction2'); 60 | 61 | self::assertEquals(['testAction1', 'testAction2'], $manager->getActionNames()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/NotificationAction.php: -------------------------------------------------------------------------------- 1 | connection 31 | ->executeQuery('SELECT condition_type FROM tl_eblick_trigger WHERE id = ?', [$dc->id]) 32 | ->fetchOne() 33 | ; 34 | 35 | if ($conditionType && $condition = $this->componentManager->getCondition($conditionType)) { 36 | $tokens .= ', '.implode( 37 | ', ', 38 | array_map( 39 | static fn ($v) => '##data_'.$v.'##', 40 | array_keys($condition->getDataPrototype((int) $dc->id)) 41 | ) 42 | ); 43 | } 44 | 45 | return sprintf( 46 | '

%s

%s
', 47 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_tokens'], 48 | $tokens 49 | ); 50 | } 51 | 52 | public function getNotificationChoices(): array 53 | { 54 | if (!class_exists(Notification::class)) { 55 | return []; 56 | } 57 | 58 | return $this->connection 59 | ->executeQuery( 60 | "SELECT id, title FROM tl_nc_notification WHERE type='eblick_notification_action' ORDER BY title" 61 | ) 62 | ->fetchAllKeyValue() 63 | ; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Compiler/AddComponentsCompilerPassTest.php: -------------------------------------------------------------------------------- 1 | createMock(Definition::class); 25 | $componentManager 26 | ->expects(self::exactly(2)) 27 | ->method('addMethodCall') 28 | ->withConsecutive( 29 | ['addCondition', [new Reference('testTag1'), 'testAlias1']], 30 | ['addAction', [new Reference('testTag2'), 'testAlias2']] 31 | ) 32 | ; 33 | 34 | $container = $this->createMock(ContainerBuilder::class); 35 | $container 36 | ->expects(self::once()) 37 | ->method('getDefinition') 38 | ->with('eblick_contao_trigger.component.component_manager') 39 | ->willReturn($componentManager) 40 | ; 41 | 42 | $container 43 | ->expects(self::exactly(2)) 44 | ->method('findTaggedServiceIds') 45 | ->withConsecutive(['eblick_contao_trigger.condition'], ['eblick_contao_trigger.action']) 46 | ->willReturnOnConsecutiveCalls( 47 | ['testTag1' => [['alias' => 'testAlias1']]], 48 | ['testTag2' => [['alias' => 'testAlias2']]] 49 | ) 50 | ; 51 | 52 | $compilerPass = new AddComponentsCompilerPass(); 53 | $compilerPass->process($container); 54 | 55 | self::assertTrue(method_exists(ComponentManager::class, 'addCondition')); 56 | self::assertTrue(method_exists(ComponentManager::class, 'addAction')); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Component/Condition/TimeCondition.php: -------------------------------------------------------------------------------- 1 | getLog())) { 28 | return; 29 | } 30 | 31 | $trigger = $context->getParameters(); 32 | 33 | $execute = $this->connection 34 | ->executeQuery( 35 | 'SELECT cnd_time_executionTime <> 0 && NOW() >= FROM_UNIXTIME(cnd_time_executionTime) FROM tl_eblick_trigger WHERE id=?', 36 | [$trigger->id] 37 | ) 38 | ->fetchOne() 39 | ; 40 | 41 | if ($execute) { 42 | $fireCallback( 43 | ['selectedTime' => $trigger->cnd_time_executionTime] 44 | ); 45 | } 46 | } 47 | 48 | public function getDataPrototype(int $triggerId): array 49 | { 50 | return array_fill_keys(['selectedTime'], null); 51 | } 52 | 53 | public function getDataContainerDefinition(): Definition 54 | { 55 | $palette = 'cnd_time_executionTime'; 56 | 57 | $fields = [ 58 | 'cnd_time_executionTime' => [ 59 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_time_executionTime'], 60 | 'exclude' => true, 61 | 'inputType' => 'text', 62 | 'eval' => [ 63 | 'mandatory' => true, 64 | 'rgxp' => 'datim', 65 | 'datepicker' => true, 66 | 'tl_class' => 'w25 wizard', 67 | ], 68 | 'sql' => 'INT(10) NULL', 69 | ], 70 | ]; 71 | 72 | return new Definition($fields, $palette); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Execution/ExecutionContextTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 22 | new ExecutionContext( 23 | new \stdClass(), 24 | 1234, 25 | $this->createMock(ExecutionLog::class) 26 | ); 27 | } 28 | 29 | public function testInstantiationWithId(): void 30 | { 31 | $parameters = new \stdClass(); 32 | $parameters->id = 12; 33 | 34 | $obj = new ExecutionContext( 35 | $parameters, 36 | 1234, 37 | $this->createMock(ExecutionLog::class) 38 | ); 39 | self::assertInstanceOf(ExecutionContext::class, $obj); 40 | } 41 | 42 | public function testGetParametersAndStartTime(): void 43 | { 44 | $parameters = new \stdClass(); 45 | $parameters->id = 12; 46 | $parameters->someValue = 'meow'; 47 | 48 | $context = new ExecutionContext( 49 | $parameters, 50 | 12345, 51 | $this->createMock(ExecutionLog::class) 52 | ); 53 | 54 | self::assertEquals($context->getParameters()->id, 12); 55 | self::assertEquals($context->getParameters()->someValue, 'meow'); 56 | self::assertEquals($context->getStartTime(), 12345); 57 | } 58 | 59 | public function testGetLog(): void 60 | { 61 | $parameters = new \stdClass(); 62 | $parameters->id = 5; 63 | 64 | $data = [2 => [['id' => 4, 'pid' => 5, 'tstamp' => 1234, 'origin' => 'tl_someTable']]]; 65 | $log = $this->createMock(ExecutionLog::class); 66 | $log 67 | ->expects(self::once()) 68 | ->method('getLog') 69 | ->with(5, 'tl_someTable') 70 | ->willReturn($data) 71 | ; 72 | 73 | $context = new ExecutionContext($parameters, 15000, $log); 74 | self::assertEquals($data, $context->getLog('tl_someTable')); 75 | } 76 | 77 | public function testAddLog(): void 78 | { 79 | $parameters = new \stdClass(); 80 | $parameters->id = 5; 81 | 82 | $log = $this->createMock(ExecutionLog::class); 83 | $log 84 | ->expects(self::once()) 85 | ->method('addLog') 86 | ->with(5, 61, 'tl_someTable', false) 87 | ; 88 | 89 | $context = new ExecutionContext($parameters, 15000, $log); 90 | $context->addLog(61, 'tl_someTable', false); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_eblick_trigger_log.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'dataContainer' => 'Table', 16 | 'ptable' => 'tl_eblick_trigger', 17 | 'enableVersioning' => false, 18 | 'notEditable' => true, 19 | 'closed' => true, 20 | 'sql' => [ 21 | 'keys' => [ 22 | 'id' => 'primary', 23 | 'pid,origin,originId' => 'index', 24 | ], 25 | ], 26 | ], 27 | 28 | // List 29 | 'list' => [ 30 | 'sorting' => [ 31 | 'mode' => 1, 32 | 'fields' => ['tstamp'], 33 | 'flag' => 5, 34 | 'panelLayout' => 'limit', 35 | ], 36 | 'label' => [ 37 | 'fields' => ['tstamp'], 38 | 'label_callback' => [ 39 | 'eblick_contao_trigger.listener.datacontainer.trigger_log', 40 | 'onGenerateLabel', 41 | ], 42 | ], 43 | 'global_operations' => [], 44 | 'operations' => [ 45 | 'show' => [ 46 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger_log']['show'], 47 | 'href' => 'act=show', 48 | 'icon' => 'show.svg', 49 | ], 50 | 'delete' => [ 51 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger_log']['delete'], 52 | 'href' => 'act=delete', 53 | 'icon' => 'delete.svg', 54 | 'attributes' => 'onclick="if(!confirm(\'' 55 | .($GLOBALS['TL_LANG']['tl_eblick_trigger_log']['deleteConfirm'] ?? null) 56 | .'\'))return false;Backend.getScrollOffset()"', 57 | ], 58 | ], 59 | ], 60 | 61 | // Fields 62 | 'fields' => [ 63 | 'id' => [ 64 | 'sql' => 'int(10) unsigned NOT NULL auto_increment', 65 | ], 66 | 'pid' => [ 67 | 'foreignKey' => 'tl_eblick_trigger.title', 68 | 'sql' => "int(10) unsigned NOT NULL default '0'", 69 | 'relation' => ['type' => 'belongsTo', 'load' => 'lazy'], 70 | ], 71 | 'tstamp' => [ 72 | 'sql' => "int(10) unsigned NOT NULL default '0'", 73 | ], 74 | 'origin' => [ 75 | 'sql' => "varchar(64) NOT NULL default 'tl_eblick_trigger'", 76 | ], 77 | 'originId' => [ 78 | 'sql' => "int(10) unsigned NOT NULL default '0'", 79 | ], 80 | 'simulated' => [ 81 | 'default' => false, 82 | 'inputType' => 'checkbox', // needed for details to show yes/no 83 | 'eval' => [ 84 | 'isBoolean' => true, 85 | ], 86 | 'sql' => "char(1) NOT NULL default '0'", 87 | ], 88 | ], 89 | ]; 90 | -------------------------------------------------------------------------------- /tests/Component/Condition/TableConditionTest.php: -------------------------------------------------------------------------------- 1 | createMock(Connection::class), 28 | $this->createMock(RowDataCompiler::class) 29 | ); 30 | 31 | $definition = $obj->getDataContainerDefinition(); 32 | self::assertInstanceOf(Definition::class, $definition); 33 | 34 | self::assertCount(2, $definition->selectors); 35 | self::assertCount(2, $definition->subPalettes); 36 | self::assertCount(8, $definition->fields); 37 | self::assertSame('cnd_table_src,cnd_table_timed,cnd_table_expression', $definition->palette); 38 | } 39 | 40 | public function testGetDataPrototype(): void 41 | { 42 | $column1 = $this->createMock(Column::class); 43 | $column1 44 | ->expects(self::once()) 45 | ->method('getName') 46 | ->willReturn('testCol1') 47 | ; 48 | 49 | $column2 = $this->createMock(Column::class); 50 | $column2 51 | ->expects(self::once()) 52 | ->method('getName') 53 | ->willReturn('testCol2') 54 | ; 55 | 56 | $column3 = $this->createMock(Column::class); 57 | $column3 58 | ->expects(self::once()) 59 | ->method('getName') 60 | ->willReturn('testCol3') 61 | ; 62 | 63 | $columns = [$column1, $column2, $column3]; 64 | 65 | $schemaManager = $this->createMock(AbstractSchemaManager::class); 66 | $schemaManager 67 | ->expects(self::once()) 68 | ->method('listTableColumns') 69 | ->with('testTable') 70 | ->willReturn($columns) 71 | ; 72 | 73 | $result = $this->createMock(Result::class); 74 | $result 75 | ->method('fetchOne') 76 | ->willReturn('testTable') 77 | ; 78 | 79 | $connection = $this->createMock(Connection::class); 80 | $connection 81 | ->expects(self::once()) 82 | ->method('executeQuery') 83 | ->with('SELECT cnd_table_src FROM tl_eblick_trigger WHERE id = ?', [123]) 84 | ->willReturn($result) 85 | ; 86 | 87 | $connection 88 | ->method('createSchemaManager') 89 | ->willReturn($schemaManager) 90 | ; 91 | 92 | $condition = new TableCondition( 93 | $connection, 94 | $this->createMock(RowDataCompiler::class) 95 | ); 96 | 97 | $result = $condition->getDataPrototype(123); 98 | 99 | self::assertEquals($result, ['testCol1' => null, 'testCol2' => null, 'testCol3' => null]); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/EventListener/DataContainer/NotificationActionTest.php: -------------------------------------------------------------------------------- 1 | createMock(Result::class); 28 | $result 29 | ->expects(self::once()) 30 | ->method('fetchOne') 31 | ->willReturn('testCondition') 32 | ; 33 | 34 | $connection = $this->createMock(Connection::class); 35 | $connection 36 | ->expects(self::once()) 37 | ->method('executeQuery') 38 | ->with('SELECT condition_type FROM tl_eblick_trigger WHERE id = ?', [9]) 39 | ->willReturn($result) 40 | ; 41 | 42 | $condition = $this->createMock(ConditionInterface::class); 43 | $condition 44 | ->expects(self::once()) 45 | ->method('getDataPrototype') 46 | ->with(9) 47 | ->willReturn(['testColumn1' => null, 'testColumn2' => null]) 48 | ; 49 | 50 | $componentManager = $this->createMock(ComponentManager::class); 51 | $componentManager 52 | ->expects(self::once()) 53 | ->method('getCondition') 54 | ->with('testCondition') 55 | ->willReturn($condition) 56 | ; 57 | 58 | $action = new NotificationAction($componentManager, $connection); 59 | 60 | $dc = $this->createMock(DataContainer::class); 61 | $dc 62 | ->method('__get') 63 | ->with('id') 64 | ->willReturn(9) 65 | ; 66 | 67 | self::assertStringContainsString( 68 | '##trigger_id##, ##trigger_title##, ##trigger_startTime##, ##data_testColumn1##, ##data_testColumn2##', 69 | $action->onGetTokenList($dc) 70 | ); 71 | } 72 | 73 | public function testGetNotificationChoices(): void 74 | { 75 | $data = [4 => 'firstTitle', 63 => 'secondTitle']; 76 | 77 | $result = $this->createMock(Result::class); 78 | $result 79 | ->expects(self::once()) 80 | ->method('fetchAllKeyValue') 81 | ->willReturn($data) 82 | ; 83 | 84 | $connection = $this->createMock(Connection::class); 85 | $connection 86 | ->expects(self::once()) 87 | ->method('executeQuery') 88 | ->with("SELECT id, title FROM tl_nc_notification WHERE type='eblick_notification_action' ORDER BY title") 89 | ->willReturn($result) 90 | ; 91 | 92 | $action = new NotificationAction( 93 | $this->createMock(ComponentManager::class), 94 | $connection 95 | ); 96 | 97 | self::assertEquals($data, $action->getNotificationChoices()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Component/Action/NotificationAction.php: -------------------------------------------------------------------------------- 1 | framework->initialize(); 30 | 31 | /** @var Model $notificationModel */ 32 | $notificationModel = $this->framework->getAdapter(Notification::class); 33 | 34 | if (!class_exists(Notification::class)) { 35 | throw new ExecutionException('Notification Center not found! This extension is needed in order to run this trigger.'); 36 | } 37 | 38 | $trigger = $context->getParameters(); 39 | 40 | /** @noinspection StaticInvocationViaThisInspection */ 41 | $objNotification = $notificationModel->findByPk($trigger->act_notification_entity); 42 | 43 | if (null !== $objNotification) { 44 | $processed = array_merge( 45 | [ 46 | 'trigger_id' => $trigger->id, 47 | 'trigger_title' => $trigger->title, 48 | 'trigger_startTime' => $context->getStartTime(), 49 | ], 50 | $this->prepareData($data) 51 | ); 52 | /** @var Notification $objNotification */ 53 | $objNotification->send($processed); 54 | 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | 61 | public function getDataContainerDefinition(): Definition 62 | { 63 | $palette = 'act_notification_entity,act_notification_tokenList'; 64 | 65 | $fields = [ 66 | 'act_notification_entity' => [ 67 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_entity'], 68 | 'exclude' => true, 69 | 'inputType' => 'select', 70 | 'options_callback' => [ 71 | 'eblick_contao_trigger.listener.datacontainer.notification_action', 72 | 'getNotificationChoices', 73 | ], 74 | 'eval' => [ 75 | 'mandatory' => true, 76 | 'includeBlankOption' => true, 77 | 'chosen' => true, 78 | 'tl_class' => 'w50', 79 | ], 80 | 'sql' => "int(10) unsigned NOT NULL default '0'", 81 | ], 82 | 'act_notification_tokenList' => [ 83 | 'exclude' => true, 84 | 'input_field_callback' => [ 85 | 'eblick_contao_trigger.listener.datacontainer.notification_action', 86 | 'onGetTokenList', 87 | ], 88 | ], 89 | ]; 90 | 91 | return new Definition($fields, $palette); 92 | } 93 | 94 | /** 95 | * Prefix keys with 'data_'. 96 | */ 97 | private function prepareData(array $rawData): array 98 | { 99 | $data = []; 100 | 101 | foreach ($rawData as $k => $v) { 102 | $data['data_'.$k] = $v; 103 | } 104 | 105 | return $data; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Component/Action/NotificationActionTest.php: -------------------------------------------------------------------------------- 1 | id = 6; 28 | $parameters->title = 'testTrigger1'; 29 | $parameters->act_notification_entity = 24; 30 | 31 | $context = new ExecutionContext( 32 | $parameters, 33 | 159800, 34 | $this->createMock(ExecutionLog::class) 35 | ); 36 | 37 | $data = []; 38 | 39 | $preparedData = [ 40 | 'trigger_id' => 6, 41 | 'trigger_title' => 'testTrigger1', 42 | 'trigger_startTime' => 159800, 43 | ]; 44 | 45 | $action = $this->getMockedAction($preparedData, 24); 46 | self::assertTrue($action->fire($context, $data)); 47 | } 48 | 49 | public function testFireWithCustomData(): void 50 | { 51 | $parameters = new \stdClass(); 52 | 53 | $parameters->id = 11; 54 | $parameters->title = 'testTrigger2'; 55 | $parameters->act_notification_entity = 24; 56 | 57 | $context = new ExecutionContext( 58 | $parameters, 59 | 123456, 60 | $this->createMock(ExecutionLog::class) 61 | ); 62 | 63 | $data = [ 64 | 'some_value' => 4, 65 | 'other_value' => 'yes', 66 | ]; 67 | 68 | $preparedData = [ 69 | 'trigger_id' => 11, 70 | 'trigger_title' => 'testTrigger2', 71 | 'trigger_startTime' => 123456, 72 | 'data_some_value' => 4, 73 | 'data_other_value' => 'yes', 74 | ]; 75 | 76 | $action = $this->getMockedAction($preparedData, 24); 77 | self::assertTrue($action->fire($context, $data)); 78 | } 79 | 80 | public function testGetDataContainerDefinition(): void 81 | { 82 | $obj = new NotificationAction($this->createMock(ContaoFramework::class)); 83 | 84 | $definition = $obj->getDataContainerDefinition(); 85 | self::assertInstanceOf(Definition::class, $definition); 86 | 87 | self::assertCount(0, $definition->selectors); 88 | self::assertCount(0, $definition->subPalettes); 89 | self::assertCount(2, $definition->fields); 90 | self::assertSame('act_notification_entity,act_notification_tokenList', $definition->palette); 91 | } 92 | 93 | private function getMockedAction($preparedData, $notificationId): NotificationAction 94 | { 95 | $notification = $this->mockAdapter(['send']); 96 | $notification 97 | ->expects(self::once()) 98 | ->method('send') 99 | ->with($preparedData) 100 | ->willReturn(true) 101 | ; 102 | 103 | $notificationAdapter = $this->mockAdapter(['findByPk']); 104 | $notificationAdapter 105 | ->expects(self::once()) 106 | ->method('findByPk') 107 | ->with($notificationId) 108 | ->willReturn($notification) 109 | ; 110 | 111 | $framework = $this->mockContaoFramework([Notification::class => $notificationAdapter]); 112 | 113 | return new NotificationAction($framework); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/Execution/ExecutionLogTest.php: -------------------------------------------------------------------------------- 1 | createMock(Result::class); 23 | $result 24 | ->expects(self::once()) 25 | ->method('fetchAllAssociative') 26 | ->willReturn([ 27 | ['id' => 1, 'pid' => 12, 'tstamp' => 1234, 'origin' => 'tl_someTable', 'originId' => 4, 'simulated' => ''], 28 | ['id' => 2, 'pid' => 12, 'tstamp' => 2345, 'origin' => 'tl_otherTable', 'originId' => 7, 'simulated' => ''], 29 | ]) 30 | ; 31 | 32 | $connection = $this->createMock(Connection::class); 33 | $connection 34 | ->expects(self::once()) 35 | ->method('executeQuery') 36 | ->with('SELECT * FROM tl_eblick_trigger_log WHERE pid=?', [12]) 37 | ->willReturn($result) 38 | ; 39 | 40 | $log = new ExecutionLog($connection); 41 | 42 | $expected = [ 43 | 4 => ['id' => 1, 'pid' => 12, 'tstamp' => 1234, 'origin' => 'tl_someTable', 'originId' => 4, 'simulated' => ''], 44 | 7 => ['id' => 2, 'pid' => 12, 'tstamp' => 2345, 'origin' => 'tl_otherTable', 'originId' => 7, 'simulated' => ''], 45 | ]; 46 | 47 | self::assertEquals($expected, $log->getLog(12)); 48 | } 49 | 50 | public function testGetLogWithOrigin(): void 51 | { 52 | $result = $this->createMock(Result::class); 53 | $result 54 | ->expects(self::once()) 55 | ->method('fetchAllAssociative') 56 | ->willReturn([ 57 | ['id' => 1, 'pid' => 12, 'tstamp' => 1234, 'origin' => 'tl_someTable', 'originId' => 4, 'simulated' => ''], 58 | ['id' => 2, 'pid' => 12, 'tstamp' => 2345, 'origin' => 'tl_someTable', 'originId' => 7, 'simulated' => ''], 59 | ]) 60 | ; 61 | 62 | $connection = $this->createMock(Connection::class); 63 | $connection 64 | ->expects(self::once()) 65 | ->method('executeQuery') 66 | ->with('SELECT * FROM tl_eblick_trigger_log WHERE pid=? AND origin =?', [12, 'tl_someTable']) 67 | ->willReturn($result) 68 | ; 69 | 70 | $log = new ExecutionLog($connection); 71 | 72 | $expected = [ 73 | 4 => ['id' => 1, 'pid' => 12, 'tstamp' => 1234, 'origin' => 'tl_someTable', 'originId' => 4, 'simulated' => ''], 74 | 7 => ['id' => 2, 'pid' => 12, 'tstamp' => 2345, 'origin' => 'tl_someTable', 'originId' => 7, 'simulated' => ''], 75 | ]; 76 | 77 | self::assertEquals($expected, $log->getLog(12, 'tl_someTable')); 78 | } 79 | 80 | public function testAddLogWithoutOriginFails(): void 81 | { 82 | $log = new ExecutionLog($this->createMock(Connection::class)); 83 | 84 | $this->expectException(\InvalidArgumentException::class); 85 | $log->addLog(12, 5, '', false); 86 | } 87 | 88 | public function testAddLogWithOrigin(): void 89 | { 90 | $connection = $this->createMock(Connection::class); 91 | $connection 92 | ->expects(self::once()) 93 | ->method('executeQuery') 94 | ->with( 95 | 'INSERT INTO tl_eblick_trigger_log SET pid=?, tstamp=?, originId=?, origin=?, simulated=?', 96 | self::anything() 97 | ) 98 | ; 99 | 100 | $log = new ExecutionLog($connection); 101 | $log->addLog(12, 5, 'tl_someTable', false); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/Component/Condition/TimeConditionTest.php: -------------------------------------------------------------------------------- 1 | assertSame(['selectedTime' => 12345], $data); 26 | 27 | throw new \Exception('callback called'); 28 | }; 29 | 30 | [$condition, $context] = $this->getTimeCondition('1'); 31 | 32 | $this->expectExceptionMessage('callback called'); 33 | 34 | /** @var TimeCondition $condition */ 35 | /** @var ExecutionContext $context */ 36 | $condition->evaluate($context, $callback); 37 | } 38 | 39 | public function testEvaluateWontFire(): void 40 | { 41 | $callback = static function ($data): void { 42 | // expect not to be called 43 | throw new \Exception('callback called'); 44 | }; 45 | 46 | [$condition, $context] = $this->getTimeCondition('0'); 47 | 48 | /** @var TimeCondition $condition */ 49 | /** @var ExecutionContext $context */ 50 | $condition->evaluate($context, $callback); 51 | } 52 | 53 | public function testGetDataContainerDefinition(): void 54 | { 55 | $obj = new TimeCondition( 56 | $this->createMock(Connection::class) 57 | ); 58 | 59 | $definition = $obj->getDataContainerDefinition(); 60 | self::assertInstanceOf(Definition::class, $definition); 61 | 62 | self::assertCount(0, $definition->selectors); 63 | self::assertCount(0, $definition->subPalettes); 64 | self::assertCount(1, $definition->fields); 65 | self::assertSame('cnd_time_executionTime', $definition->palette); 66 | } 67 | 68 | public function testGetDataPrototype(): void 69 | { 70 | $condition = new TimeCondition( 71 | $this->createMock(Connection::class) 72 | ); 73 | 74 | $result = $condition->getDataPrototype(123); 75 | self::assertEquals($result, ['selectedTime' => null]); 76 | } 77 | 78 | private function getTimeCondition($execute): array 79 | { 80 | $parameters = new \stdClass(); 81 | $parameters->id = 6; 82 | $parameters->cnd_time_executionTime = 12345; 83 | 84 | $context = $this->createMock(ExecutionContext::class); 85 | $context 86 | ->expects(self::once()) 87 | ->method('getLog') 88 | ->with() 89 | ->willReturn([]) 90 | ; 91 | $context 92 | ->expects(self::once()) 93 | ->method('getParameters') 94 | ->with() 95 | ->willReturn($parameters) 96 | ; 97 | 98 | $result = $this->createMock(Result::class); 99 | $result 100 | ->expects(self::once()) 101 | ->method('fetchOne') 102 | ->willReturn($execute) 103 | ; 104 | 105 | $connection = $this->createMock(Connection::class); 106 | $connection 107 | ->expects(self::once()) 108 | ->method('executeQuery') 109 | ->with( 110 | 'SELECT cnd_time_executionTime <> 0 && NOW() >= FROM_UNIXTIME(cnd_time_executionTime) FROM tl_eblick_trigger WHERE id=?', 111 | [6] 112 | ) 113 | ->willReturn($result) 114 | ; 115 | 116 | return [new TimeCondition($connection), $context]; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/en/tl_eblick_trigger.php: -------------------------------------------------------------------------------- 1 | Symfony Expression Language. The database columns are available as variables, e.g.: \'sum >= 4 and published\'']; 47 | 48 | // time condition 49 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['condition']['time'] = ['Point in Time']; 50 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_time_executionTime'] = ['Date/Time', 'Action will be executed, when this moment in time is reached.']; 51 | 52 | // notification action 53 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action']['notification'] = ['Notification']; 54 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_entity'] = ['Notification', 'Choose a trigger notification from the notification center.']; 55 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_tokens'] = 'Available tokens'; 56 | -------------------------------------------------------------------------------- /src/Resources/docs/extending.md: -------------------------------------------------------------------------------- 1 | Adding conditions & actions 2 | --------------------------- 3 | 4 | Adding your own conditions and actions is pretty straightforward. Each 5 | condition can fire multiple actions on evaluation. This allows to 6 | effectively process large bits of data that should be checked against 7 | the same condition (e.g. rows of a table). 8 | 9 | Each condition can add custom data to the execution flow. To do so you 10 | must specify how the data you're adding looks like via the function 11 | `getDataProtoype()`. Note that only a flat structure is supported, yet. 12 | 13 | If an action returns `true` a trigger log entry is made. Your condition 14 | must check for the log entries and decide if actions should be fired 15 | (again) or not. To do so you can use the functions inside the 16 | `ExecutionContext` that is passed on evaluation. Each log entry is 17 | associated with the trigger, an `origin` value (most likely the source 18 | table or `tl_eblick_trigger` by default) and a `originId` to identify 19 | individual records or iterations. 20 | 21 | Throw an `ExecutionException` (or any other appropriate exception) if 22 | you want or need to stop processing and give a reason. The trigger will 23 | then be in an error mode until resolved in the backend and the exception 24 | message will be shown. 25 | 26 | 27 | 28 | #### Adding a custom Condition 29 | 30 | - Create a new service class that implements the 31 | `EBlick\ContaoTrigger\Component\Condition\ConditionInterface`. 32 | ```php 33 | 10, 'bar' => 'abc'] 44 | $fireCallback($myCustomData); 45 | } 46 | 47 | public function getDataPrototype(int $triggerId) : array { 48 | return ['foo' => null, 'bar' => null] 49 | } 50 | 51 | } 52 | 53 | // ... 54 | ``` 55 | 56 | - See the interface's doc blocks for further information. 57 | - Tag your service: 58 | ```yml 59 | somewhere.custom.my_condition: 60 | class: SomeWhere\Custom\MyCondition 61 | tags: 62 | - { name: eblick_contao_trigger.condition, alias: mycondition } 63 | ``` 64 | 65 | - Make sure to add a language file for your condition's name: 66 | ```php 67 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['condition']['mycondition'] = ['My Condition']; 68 | ``` 69 | 70 | 71 | 72 | #### Adding a custom Action 73 | - Create a new service class that implements the 74 | `EBlick\ContaoTrigger\Component\Action\ActionInterface`. 75 | ```php 76 | 127 | [ 128 | // ... 129 | 'inputType' => 'text', 130 | 'sql' => "int(10) unsigned NOT NULL default '0'" 131 | ] 132 | ]; 133 | 134 | return new Definition($fields, $palette); 135 | } 136 | 137 | } 138 | 139 | // ... 140 | ``` 141 | 142 | - In a definition you can specify fields, palettes, selectors and 143 | sub palettes like you would in a regular Contao dca container. This 144 | definition will than get merged as a subgroup into the 145 | `tl_eblick_trigger` dca and displayed as a selectable option. To avoid 146 | collisions it is suggested to prefix your fields with `act_myaction_` 147 | for actions and `cnd_mycondition_` for conditions. 148 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/tl_eblick_trigger.php: -------------------------------------------------------------------------------- 1 | Symfony Expression Language. Die Tabellen-Spalten sind als Variablen verfügbar, z.B.: \'sum >= 4 and published\'']; 47 | 48 | // time condition 49 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['condition']['time'] = ['Zeitpunkt']; 50 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_time_executionTime'] = ['Datum/Uhrzeit', 'Aktion wird ausgelöst, sobald der Zeitpunkt eingetreten ist.']; 51 | 52 | // notification action 53 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action']['notification'] = ['Benachrichtigung via Notification Center']; 54 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_entity'] = ['Benachrichtigung', 'Wählen Sie eine zuvor angelegte Benachrichtigung des Typs `Trigger Benachrichtigung` aus dem Notification Center.']; 55 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action_notification_tokens'] = 'Verfügbare Simple-Tokens'; 56 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/TableCondition.php: -------------------------------------------------------------------------------- 1 | schemaManager = $this->connection->createSchemaManager(); 34 | } 35 | 36 | /** 37 | * Returns a list of available tables. 38 | */ 39 | public function onGetTables(): array 40 | { 41 | $tables = array_map( 42 | static fn (Table $table): string => $table->getName(), 43 | $this->schemaManager->listTables() 44 | ); 45 | 46 | // exclude tables 47 | $excludedTables = [ 48 | // trigger framework 49 | 'tl_eblick_trigger', 50 | 'tl_eblick_trigger_log', 51 | 52 | // contao core 53 | 'tl_cron', 54 | 'tl_log', 55 | 'tl_remember_me', 56 | 'tl_search', 57 | 'tl_search_index', 58 | 'tl_undo', 59 | 'tl_version', 60 | ]; 61 | 62 | $tables = array_diff( 63 | array_values($tables), 64 | $excludedTables 65 | ); 66 | 67 | // key equals value 68 | return array_combine($tables, $tables); 69 | } 70 | 71 | /** 72 | * Returns a list of table columns that can contain time information. 73 | */ 74 | public function onGetTimeColumns(DataContainer $dc): array 75 | { 76 | $table = $this->connection 77 | ->executeQuery('SELECT cnd_table_src FROM tl_eblick_trigger WHERE id = ?', [$dc->id]) 78 | ->fetchOne() 79 | ; 80 | 81 | if (!$table || !$this->schemaManager->tablesExist([$table])) { 82 | return []; 83 | } 84 | 85 | $columns = []; 86 | 87 | // try to resolve column names 88 | $this->framework->initialize(); 89 | 90 | /** @var Controller $controller */ 91 | $controller = $this->framework->getAdapter(Controller::class); 92 | /** @noinspection StaticInvocationViaThisInspection */ 93 | $controller->loadLanguageFile($table); 94 | 95 | foreach ($this->schemaManager->listTableColumns($table) as $column) { 96 | if ($this->canBeDateTimeColumn($column)) { 97 | $columns[$column->getName()] = $this->buildFieldLabel($table, $column->getName()); 98 | } 99 | } 100 | 101 | return $columns; 102 | } 103 | 104 | public function onValidateExpression($var, DataContainer $dc): string 105 | { 106 | if (!$var) { 107 | return $var; 108 | } 109 | 110 | $columns = []; 111 | 112 | foreach ($this->schemaManager->listTableColumns($dc->activeRecord->cnd_table_src) as $column) { 113 | $columns[] = $column->getName(); 114 | } 115 | 116 | // throws syntax error if invalid 117 | $this->rowDataCompiler->compileRowExpression($var, $columns); 118 | 119 | return $var; 120 | } 121 | 122 | private function canBeDateTimeColumn(Column $column): bool 123 | { 124 | $type = $column->getType(); 125 | 126 | return match (true) { 127 | $type instanceof StringType => 10 === $column->getLength(), 128 | $type instanceof IntegerType => !\in_array($column->getName(), ['id', 'pid'], true) 129 | && (!$column->getLength() || $column->getLength() >= 10), 130 | $type instanceof DateTimeType, $type instanceof DateType, $type instanceof TimeType => true, 131 | default => false, 132 | }; 133 | } 134 | 135 | private function buildFieldLabel(string $table, string $field): string 136 | { 137 | if ( 138 | !\array_key_exists($table, $GLOBALS['TL_LANG']) 139 | || !\array_key_exists($field, $GLOBALS['TL_LANG'][$table]) 140 | || !$GLOBALS['TL_LANG'][$table][$field] 141 | ) { 142 | return $field; 143 | } 144 | $label = \is_array( 145 | $GLOBALS['TL_LANG'][$table][$field] 146 | ) ? $GLOBALS['TL_LANG'][$table][$field][0] : $GLOBALS['TL_LANG'][$table][$field]; 147 | 148 | return sprintf('%s (%s)', $label, $field); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/EventListener/TriggerListener.php: -------------------------------------------------------------------------------- 1 | executionTimer = new Stopwatch(); 31 | } 32 | 33 | /** 34 | * Run all triggers - this should be called periodically to make time constraints work. 35 | */ 36 | public function onExecute(): void 37 | { 38 | $triggers = $this->connection 39 | ->executeQuery( 40 | 'SELECT * FROM tl_eblick_trigger '. 41 | 'WHERE enabled = 1 && error IS NULL' 42 | ) 43 | ->fetchAllAssociative() 44 | ; 45 | 46 | if (!$triggers) { 47 | return; 48 | } 49 | 50 | $factory = new LockFactory(new FlockStore()); 51 | $lock = $factory->createLock('trigger-execution', 60); 52 | 53 | $lock->acquire(); 54 | 55 | // execute triggers 56 | try { 57 | foreach ($triggers as $trigger) { 58 | $this->execute((object) $trigger); 59 | $lock->refresh(); 60 | } 61 | } finally { 62 | $lock->release(); 63 | } 64 | } 65 | 66 | /** 67 | * Simulate a trigger and write logs without executing actions. 68 | */ 69 | public function onSimulate(int $triggerId): void 70 | { 71 | $trigger = $this->connection 72 | ->executeQuery( 73 | 'SELECT * FROM tl_eblick_trigger WHERE id = ?', 74 | [$triggerId] 75 | ) 76 | ->fetchAssociative() 77 | ; 78 | 79 | $this->execute((object) $trigger, true); 80 | } 81 | 82 | /** 83 | * Execute a single trigger. 84 | */ 85 | private function execute(\stdClass $trigger, bool $simulated = false): void 86 | { 87 | $condition = $this->componentManager->getCondition($trigger->condition_type); 88 | $action = $this->componentManager->getAction($trigger->action_type); 89 | 90 | if (!$condition || !$action) { 91 | return; 92 | } 93 | 94 | // setup execution data environment 95 | $this->executionTimer->reset(); 96 | $this->executionTimer->start('trigger-'.$trigger->id); 97 | $startTime = time(); 98 | 99 | $executionContext = $this->executionContextFactory->createExecutionContext($trigger, $startTime); 100 | $dataPrototype = $condition->getDataPrototype((int) $trigger->id); 101 | 102 | $fireCallback = 103 | function (array $data = [], int $originId = 0, string $origin = 'tl_eblick_trigger') use ($simulated, $action, $executionContext, $dataPrototype): void { 104 | // fire action or skip if simulating 105 | if ($simulated || $action->fire($executionContext, $this->filterData($dataPrototype, $data))) { 106 | $executionContext->addLog($originId, $origin, $simulated); 107 | } 108 | }; 109 | 110 | // condition evaluation 111 | try { 112 | $condition->evaluate($executionContext, $fireCallback); 113 | } catch (\Exception $e) { 114 | $this->connection->executeQuery( 115 | 'UPDATE tl_eblick_trigger SET error = ? WHERE id = ?', 116 | [$e->getMessage(), $trigger->id] 117 | ); 118 | 119 | if ($e instanceof ExecutionException) { 120 | $this->logger->warning( 121 | sprintf('An error occurred during execution of trigger %s.', $trigger->id), 122 | ['exception' => $e, 'contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR)] 123 | ); 124 | } else { 125 | $this->logger->critical( 126 | sprintf('An unexpected exception occurred during execution of trigger %s.', $trigger->id), 127 | ['exception' => $e, 'contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR)] 128 | ); 129 | } 130 | } 131 | 132 | // update trigger meta data 133 | $stopwatchEvent = $this->executionTimer->stop('trigger-'.$trigger->id); 134 | $this->connection->executeQuery( 135 | 'UPDATE tl_eblick_trigger SET lastRun = ?, lastDuration = ? WHERE id = ?', 136 | [$executionContext->getStartTime(), (int) $stopwatchEvent->getDuration(), $trigger->id] 137 | ); 138 | } 139 | 140 | /** 141 | * Constrain processing data to what has been set in the respective prototype. 142 | */ 143 | private function filterData(array $dataPrototype, array $data): array 144 | { 145 | // filter by keys (currently does not support nesting) 146 | return array_filter( 147 | $data, 148 | static fn ($v) => \array_key_exists($v, $dataPrototype), 149 | ARRAY_FILTER_USE_KEY 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/Trigger.php: -------------------------------------------------------------------------------- 1 | componentManager->getConditionNames() as $conditionName) { 43 | $condition = $this->componentManager->getCondition($conditionName); 44 | 45 | if ($condition instanceof DataContainerComponentInterface) { 46 | $this->importComponent( 47 | 'condition', 48 | $conditionName, 49 | $condition->getDataContainerDefinition() 50 | ); 51 | } 52 | } 53 | 54 | foreach ($this->componentManager->getActionNames() as $actionName) { 55 | $action = $this->componentManager->getAction($actionName); 56 | 57 | if ($action instanceof DataContainerComponentInterface) { 58 | $this->importComponent( 59 | 'action', 60 | $actionName, 61 | $action->getDataContainerDefinition() 62 | ); 63 | } 64 | } 65 | } 66 | 67 | public function onGetError(DataContainer $dc): string 68 | { 69 | if (!$dc->activeRecord->error) { 70 | return ''; 71 | } 72 | 73 | return sprintf( 74 | '

%s

%s

%s
', 75 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['error'][0], 76 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['error'][1], 77 | nl2br($dc->activeRecord->error) 78 | ); 79 | } 80 | 81 | public function onResetError(DataContainer $dc): void 82 | { 83 | $this->connection->executeQuery( 84 | 'UPDATE tl_eblick_trigger SET error = NULL WHERE id =?', 85 | [$dc->id] 86 | ); 87 | } 88 | 89 | public function onGenerateLabel(array $row): string 90 | { 91 | if ($row['error']) { 92 | $state = 'error'; 93 | } elseif ($row['enabled']) { 94 | $state = $row['lastRun'] ? 'running' : 'waiting'; 95 | } else { 96 | $state = 'paused'; 97 | } 98 | 99 | $this->framework->initialize(); 100 | $datimFormat = $this->framework->getAdapter(Config::class)->get('datimFormat'); 101 | 102 | $lastRun = $row['lastRun'] ? 103 | Date::parse($datimFormat, $row['lastRun']).' ('.($row['lastDuration'] / 1000).'s)' : '[…]'; 104 | 105 | $numRuns = (int) $this->connection 106 | ->executeQuery('SELECT COUNT(*) FROM tl_eblick_trigger_log WHERE pid =?', [$row['id']]) 107 | ->fetchOne() 108 | ; 109 | 110 | return sprintf( 111 | '
%s'. 112 | '
%s → %s (%s)
%s
', 113 | $state, 114 | $row['title'], 115 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['condition'][$row['condition_type']][0], 116 | $GLOBALS['TL_LANG']['tl_eblick_trigger']['action'][$row['action_type']][0], 117 | $numRuns, 118 | $lastRun 119 | ); 120 | } 121 | 122 | public function onShowSimulateButton(array $row, string $href, string|null $label, string $title, string $icon, string $attributes): string 123 | { 124 | // only show for disabled triggers 125 | if ($row['enabled']) { 126 | return ''; 127 | } 128 | 129 | return sprintf( 130 | '%s ', 131 | Backend::addToUrl($href.'&id='.$row['id']), 132 | StringUtil::specialchars($title), 133 | $attributes, 134 | Image::getHtml($icon, $label) 135 | ); 136 | } 137 | 138 | public function onExecute(): void 139 | { 140 | $this->triggerSystem->onExecute(); 141 | $this->redirectBack(); 142 | } 143 | 144 | public function onSimulate(DataContainer $dc): void 145 | { 146 | $this->triggerSystem->onSimulate((int) $dc->id); 147 | $this->redirectBack(); 148 | } 149 | 150 | public function onReset(DataContainer $dc): void 151 | { 152 | $this->connection->executeQuery( 153 | 'DELETE FROM tl_eblick_trigger_log WHERE pid =?', 154 | [$dc->id] 155 | ); 156 | $this->connection->executeQuery( 157 | 'UPDATE tl_eblick_trigger SET lastDuration = 0, lastRun = 0 WHERE id =?', 158 | [$dc->id] 159 | ); 160 | 161 | $this->redirectBack(); 162 | } 163 | 164 | /** 165 | * Merge datacontainer properties as a sub component. 166 | */ 167 | private function importComponent(string $componentType, string $componentName, Definition $definition): void 168 | { 169 | $node = &$GLOBALS['TL_DCA']['tl_eblick_trigger']; 170 | 171 | // add component to respective component selector 172 | $node['fields'][$componentType.'_type']['options'][] = $componentName; 173 | 174 | // add palettes (as a sub palette), fields, selectors and sub palettes 175 | $node['subpalettes'][$componentType.'_type_'.$componentName] = $definition->palette; 176 | 177 | foreach ($definition->fields as $fieldName => $field) { 178 | $node['fields'][$fieldName] = $field; 179 | } 180 | 181 | foreach ($definition->selectors as $selector) { 182 | $node['palettes']['__selector__'][] = $selector; 183 | } 184 | 185 | foreach ($definition->subPalettes as $subPaletteName => $subPalette) { 186 | $node['subpalettes'][$subPaletteName] = $subPalette; 187 | } 188 | } 189 | 190 | /** 191 | * Redirect to current listing after action. 192 | */ 193 | private function redirectBack(): void 194 | { 195 | $this->framework->initialize(); 196 | 197 | /** @var Controller $controller */ 198 | $controller = $this->framework->getAdapter(Controller::class); 199 | /** @noinspection StaticInvocationViaThisInspection */ 200 | $controller->redirect($controller->addToUrl(null, true, ['key'])); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_eblick_trigger.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'dataContainer' => 'Table', 16 | 'ctable' => ['tl_eblick_trigger_log'], 17 | 'switchToEdit' => true, 18 | 'enableVersioning' => true, 19 | 'sql' => [ 20 | 'keys' => [ 21 | 'id' => 'primary', 22 | 'enabled' => 'index', 23 | 'condition_type' => 'index', 24 | 'action_type' => 'index', 25 | ], 26 | ], 27 | 'onsubmit_callback' => [ 28 | [ 29 | 'eblick_contao_trigger.listener.datacontainer.trigger', 30 | 'onResetError', 31 | ], 32 | ], 33 | ], 34 | 35 | // List 36 | 'list' => [ 37 | 'sorting' => [ 38 | 'mode' => 2, 39 | 'fields' => ['title'], 40 | 'flag' => 1, 41 | 'panelLayout' => 'sort,search,limit', 42 | ], 43 | 'label' => [ 44 | 'fields' => ['title'], 45 | 'label_callback' => [ 46 | 'eblick_contao_trigger.listener.datacontainer.trigger', 47 | 'onGenerateLabel', 48 | ], 49 | ], 50 | 'global_operations' => [ 51 | 'execute' => [ 52 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['execute'], 53 | 'href' => 'key=execute', 54 | 'class' => 'header_icon', 55 | 'icon' => 'sync.svg', 56 | ], 57 | ], 58 | 'operations' => [ 59 | 'show' => [ 60 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['show'], 61 | 'href' => 'act=show', 62 | 'icon' => 'show.svg', 63 | ], 64 | 'edit' => [ 65 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['edit'], 66 | 'href' => 'act=edit', 67 | 'icon' => 'edit.svg', 68 | ], 69 | 'log' => [ 70 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['log'], 71 | 'href' => 'table=tl_eblick_trigger_log', 72 | 'icon' => 'bundles/eblickcontaotrigger/img/log.svg', 73 | ], 74 | 'simulate' => [ 75 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['simulate'], 76 | 'href' => 'key=simulate', 77 | 'icon' => 'bundles/eblickcontaotrigger/img/simulate.svg', 78 | 'button_callback' => [ 79 | 'eblick_contao_trigger.listener.datacontainer.trigger', 80 | 'onShowSimulateButton', 81 | ], 82 | 'attributes' => 'onclick="if(!confirm(\'' 83 | .($GLOBALS['TL_LANG']['tl_eblick_trigger']['simulateConfirm'] ?? null) 84 | .'\'))return false;Backend.getScrollOffset()"', 85 | ], 86 | 'reset' => [ 87 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['reset'], 88 | 'href' => 'key=reset', 89 | 'icon' => 'bundles/eblickcontaotrigger/img/reset.svg', 90 | 'attributes' => 'onclick="if(!confirm(\'' 91 | .($GLOBALS['TL_LANG']['tl_eblick_trigger']['resetConfirm'] ?? null) 92 | .'\'))return false;Backend.getScrollOffset()"', 93 | ], 94 | 'delete' => [ 95 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['delete'], 96 | 'href' => 'act=delete', 97 | 'icon' => 'delete.svg', 98 | 'attributes' => 'onclick="if(!confirm(\''.($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? null) 99 | .'\'))return false;Backend.getScrollOffset()"', 100 | ], 101 | ], 102 | ], 103 | 104 | // Select 105 | 'select' => [ 106 | 'buttons_callback' => [], 107 | ], 108 | 109 | // Edit 110 | 'edit' => [ 111 | 'buttons_callback' => [], 112 | ], 113 | 114 | // Palettes 115 | 'palettes' => [ 116 | '__selector__' => ['condition_type', 'action_type'], 117 | 'default' => '{meta_legend},error,title;'. 118 | '{condition_legend},condition_type;'. 119 | '{action_legend},action_type;'. 120 | '{system_legend},enabled;', 121 | ], 122 | 123 | // Subpalettes 124 | 'subpalettes' => [ 125 | 'condition_type_table' => 'condition_table_srcField', 126 | ], 127 | 128 | // Fields 129 | 'fields' => [ 130 | 'id' => [ 131 | 'sql' => 'int(10) unsigned NOT NULL auto_increment', 132 | ], 133 | 'tstamp' => [ 134 | 'sql' => "int(10) unsigned NOT NULL default '0'", 135 | ], 136 | 'title' => [ 137 | 'exclude' => true, 138 | 'inputType' => 'text', 139 | 'eval' => [ 140 | 'mandatory' => true, 141 | 'maxlength' => 255, 142 | ], 143 | 'sql' => "varchar(255) NOT NULL default ''", 144 | ], 145 | 'error' => [ 146 | 'exclude' => true, 147 | 'input_field_callback' => [ 148 | 'eblick_contao_trigger.listener.datacontainer.trigger', 149 | 'onGetError', 150 | ], 151 | 'sql' => 'TEXT NULL default NULL', 152 | ], 153 | 'enabled' => [ 154 | 'exclude' => true, 155 | 'default' => false, 156 | 'inputType' => 'checkbox', 157 | 'eval' => [ 158 | 'isBoolean' => true, 159 | ], 160 | 'sql' => "char(1) NOT NULL default '0'", 161 | ], 162 | 'condition_type' => [ 163 | 'exclude' => true, 164 | 'inputType' => 'select', 165 | 'reference' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['condition'], 166 | 'eval' => [ 167 | 'mandatory' => true, 168 | 'includeBlankOption' => true, 169 | 'submitOnChange' => true, 170 | 'tl_class' => 'w50', 171 | ], 172 | 'sql' => "varchar(64) NOT NULL default ''", 173 | ], 174 | 'action_type' => [ 175 | 'exclude' => true, 176 | 'inputType' => 'select', 177 | 'reference' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['action'], 178 | 'eval' => [ 179 | 'mandatory' => true, 180 | 'includeBlankOption' => true, 181 | 'submitOnChange' => true, 182 | 'tl_class' => 'w50', 183 | ], 184 | 'sql' => "varchar(64) NOT NULL default ''", 185 | ], 186 | 'lastRun' => [ 187 | 'eval' => ['rgxp' => 'datim'], 188 | 'exclude' => true, 189 | 'sql' => "int(10) unsigned NOT NULL default '0'", 190 | ], 191 | 'lastDuration' => [ 192 | 'sql' => "int(10) unsigned NOT NULL default '0'", 193 | 'exclude' => true, 194 | ], 195 | ], 196 | ]; 197 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | 167 | 168 | ____________________________________________________________________________ 169 | 170 | 171 | Icons used in the backend made by 172 | 173 | - `Pixelmeetup` 174 | - `Roundicons` 175 | - `Google` 176 | 177 | from `Flaticon` . 178 | -------------------------------------------------------------------------------- /src/Component/Condition/TableCondition.php: -------------------------------------------------------------------------------- 1 | schemaManager = $this->connection->createSchemaManager(); 33 | } 34 | 35 | public function evaluate(ExecutionContext $context, \Closure $fireCallback): void 36 | { 37 | $trigger = $context->getParameters(); 38 | 39 | // build query 40 | $log = $context->getLog($trigger->cnd_table_src); 41 | [$query, $params, $types] = $this->buildQuery($trigger, $log); 42 | 43 | // create expression callback 44 | $expressionCallback = $this->buildExpressionCallback($trigger->cnd_table_expression, $trigger->cnd_table_src); 45 | 46 | // execute query 47 | try { 48 | $affectedEntities = $this->connection 49 | ->executeQuery($query, $params, $types) 50 | ->fetchAllAssociative() 51 | ; 52 | } catch (Exception $e) { 53 | throw new ExecutionException($e->getMessage(), 0, $e); 54 | } 55 | 56 | foreach ($affectedEntities as $entity) { 57 | // check expression 58 | if ($expressionCallback && !$expressionCallback($entity)) { 59 | continue; 60 | } 61 | 62 | // fire action 63 | $fireCallback($entity, (int) $entity['id'], $trigger->cnd_table_src); 64 | } 65 | } 66 | 67 | public function getDataPrototype(int $triggerId): array 68 | { 69 | $srcTable = $this->connection 70 | ->executeQuery('SELECT cnd_table_src FROM tl_eblick_trigger WHERE id = ?', [$triggerId]) 71 | ->fetchOne() 72 | ; 73 | 74 | return 75 | array_fill_keys( 76 | $this->getColumnNames($srcTable), 77 | null 78 | ); 79 | } 80 | 81 | public function getDataContainerDefinition(): Definition 82 | { 83 | $palette = 'cnd_table_src,cnd_table_timed,cnd_table_expression'; 84 | 85 | $selectors = [ 86 | 'cnd_table_timed', 87 | 'cnd_table_overwriteExecutionTime', 88 | ]; 89 | 90 | $subPalettes = [ 91 | 'cnd_table_timed' => 'cnd_table_timeColumn,cnd_table_timeOffset,cnd_table_timeOffsetUnit,cnd_table_overwriteExecutionTime', 92 | 'cnd_table_overwriteExecutionTime' => 'cnd_table_executionTime', 93 | ]; 94 | 95 | $fields = [ 96 | 'cnd_table_src' => [ 97 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_src'], 98 | 'exclude' => true, 99 | 'inputType' => 'select', 100 | 'options_callback' => [ 101 | 'eblick_contao_trigger.listener.datacontainer.table_condition', 102 | 'onGetTables', 103 | ], 104 | 'eval' => [ 105 | 'mandatory' => true, 106 | 'includeBlankOption' => true, 107 | 'submitOnChange' => true, 108 | 'tl_class' => 'w50', 109 | ], 110 | 'sql' => "varchar(64) NOT NULL default ''", 111 | ], 112 | 'cnd_table_timed' => [ 113 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_timed'], 114 | 'exclude' => true, 115 | 'inputType' => 'checkbox', 116 | 'eval' => ['submitOnChange' => true, 'tl_class' => 'clr m12'], 117 | 'sql' => "char(1) NOT NULL default ''", 118 | ], 119 | 'cnd_table_timeColumn' => [ 120 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_timeColumn'], 121 | 'exclude' => true, 122 | 'inputType' => 'select', 123 | 'options_callback' => [ 124 | 'eblick_contao_trigger.listener.datacontainer.table_condition', 125 | 'onGetTimeColumns', 126 | ], 127 | 'eval' => [ 128 | 'mandatory' => true, 129 | 'includeBlankOption' => true, 130 | 'tl_class' => 'w50', 131 | ], 132 | 'sql' => "varchar(64) NOT NULL default ''", 133 | ], 134 | 'cnd_table_timeOffset' => [ 135 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_timeOffset'], 136 | 'exclude' => true, 137 | 'inputType' => 'text', 138 | 'eval' => [ 139 | 'rgxp' => 'digit', 140 | 'nospace' => true, 141 | 'mandatory' => true, 142 | 'decodeEntities' => true, 143 | 'tl_class' => 'w16', 144 | ], 145 | 'sql' => 'int(10) NULL', 146 | ], 147 | 'cnd_table_timeOffsetUnit' => [ 148 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_timeOffsetUnit'], 149 | 'exclude' => true, 150 | 'inputType' => 'select', 151 | 'options' => ['MINUTE', 'HOUR', 'DAY'], 152 | 'reference' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_timeOffsetUnit'], 153 | 'eval' => [ 154 | 'mandatory' => true, 155 | 'tl_class' => 'w16', 156 | ], 157 | 'sql' => 'varchar(6) NULL', 158 | ], 159 | 'cnd_table_overwriteExecutionTime' => [ 160 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_overwriteExecutionTime'], 161 | 'exclude' => true, 162 | 'inputType' => 'checkbox', 163 | 'eval' => ['submitOnChange' => true, 'tl_class' => 'clr m12'], 164 | 'sql' => "char(1) NOT NULL default ''", 165 | ], 166 | 'cnd_table_executionTime' => [ 167 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_executionTime'], 168 | 'exclude' => true, 169 | 'inputType' => 'text', 170 | 'eval' => [ 171 | 'mandatory' => true, 172 | 'rgxp' => 'time', 173 | 'datepicker' => true, 174 | 'tl_class' => 'w25 wizard', 175 | ], 176 | 'sql' => 'INT(10) NULL', 177 | ], 178 | 'cnd_table_expression' => [ 179 | 'label' => &$GLOBALS['TL_LANG']['tl_eblick_trigger']['cnd_table_expression'], 180 | 'exclude' => true, 181 | 'inputType' => 'text', 182 | 'eval' => [ 183 | 'decodeEntities' => true, 184 | ], 185 | 'save_callback' => [ 186 | [ 187 | 'eblick_contao_trigger.listener.datacontainer.table_condition', 188 | 'onValidateExpression', 189 | ], 190 | ], 191 | 'sql' => "varchar(255) NOT NULL default ''", 192 | ], 193 | ]; 194 | 195 | return new Definition($fields, $palette, $selectors, $subPalettes); 196 | } 197 | 198 | /** 199 | * Get column names of source table. 200 | */ 201 | private function getColumnNames(string $srcTable): array 202 | { 203 | return array_map( 204 | static fn (Column $column): string => $column->getName(), 205 | $this->schemaManager->listTableColumns($srcTable) 206 | ); 207 | } 208 | 209 | /** 210 | * Build SQL query to select entities of the selected source table. 211 | */ 212 | private function buildQuery(\stdClass $trigger, array $log): array 213 | { 214 | $logIds = !empty($log) ? array_keys($log) : [-1]; 215 | 216 | $query = 'SELECT * FROM '.$this->connection->quoteIdentifier($trigger->cnd_table_src).' WHERE TRUE'; 217 | $params = []; 218 | $types = []; 219 | 220 | // log condition 221 | if (!empty($log)) { 222 | $query .= ' AND id NOT IN (?)'; 223 | $params[] = $logIds; 224 | $types[] = ArrayParameterType::INTEGER; 225 | } 226 | 227 | // time condition 228 | if ($trigger->cnd_table_timed) { 229 | // injection prevention 230 | if ( 231 | !\in_array( 232 | $timeUnit = $trigger->cnd_table_timeOffsetUnit, 233 | ['MINUTE', 'HOUR', 'DAY'], 234 | true 235 | ) 236 | ) { 237 | throw new ExecutionException(sprintf('Invalid time offset "%s"!', $trigger->cnd_table_timeOffsetUnit)); 238 | } 239 | $timeColumn = $this->connection->quoteIdentifier( 240 | $trigger->cnd_table_timeColumn 241 | ); 242 | 243 | // offset condition 244 | $timeComparisonSql = 245 | 'DATE_ADD('. 246 | // allow datetime/date/timestamp fields with fallback to Contao's string timestamps 247 | 'IFNULL(DATE_ADD('.$timeColumn.', INTERVAL 0 SECOND), FROM_UNIXTIME('.$timeColumn.')),'. 248 | 'INTERVAL ? '.$timeUnit. 249 | ')'; 250 | 251 | $params[] = $trigger->cnd_table_timeOffset; 252 | $types[] = ParameterType::INTEGER; 253 | 254 | // overwrite time portion: extract date + concatenate time 255 | if ($trigger->cnd_table_overwriteExecutionTime) { 256 | $timeComparisonSql = 'CONCAT(DATE('.$timeComparisonSql.'), ?)'; 257 | 258 | $params[] = ' '.date('H:m:s', (int) $trigger->cnd_table_executionTime); 259 | $types[] = ParameterType::STRING; 260 | } 261 | 262 | // compose 263 | $query .= sprintf(' AND NOW() >= %s', $timeComparisonSql); 264 | } 265 | 266 | return [$query, $params, $types]; 267 | } 268 | 269 | private function buildExpressionCallback(string $expression, string $srcTable): \Closure|null 270 | { 271 | if (!$expression) { 272 | return null; 273 | } 274 | 275 | try { 276 | $expressionCallback = $this->rowDataCompiler->compileRowExpression( 277 | $expression, 278 | $this->getColumnNames($srcTable) 279 | ); 280 | } catch (SyntaxError $e) { 281 | throw new ExecutionException($e->getMessage(), 0, $e); 282 | } 283 | 284 | return $expressionCallback; 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /tools/ecs/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "dfbce4e9ae46a0d37dd10d37b3deb4a2", 8 | "packages": [ 9 | { 10 | "name": "contao/easy-coding-standard", 11 | "version": "5.4.2", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/contao/easy-coding-standard.git", 15 | "reference": "55d47f7b08e60199b32e9e9cbaffd5d1bd2bb620" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/contao/easy-coding-standard/zipball/55d47f7b08e60199b32e9e9cbaffd5d1bd2bb620", 20 | "reference": "55d47f7b08e60199b32e9e9cbaffd5d1bd2bb620", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": "^7.4 || ^8.0", 25 | "slevomat/coding-standard": "^7.0 || ^8.0", 26 | "symplify/easy-coding-standard": "^10.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^9.5" 30 | }, 31 | "type": "library", 32 | "autoload": { 33 | "psr-4": { 34 | "Contao\\EasyCodingStandard\\": "src/" 35 | } 36 | }, 37 | "notification-url": "https://packagist.org/downloads/", 38 | "license": [ 39 | "LGPL-3.0-or-later" 40 | ], 41 | "authors": [ 42 | { 43 | "name": "Leo Feyer", 44 | "homepage": "https://github.com/leofeyer" 45 | } 46 | ], 47 | "description": "EasyCodingStandard configurations for Contao", 48 | "support": { 49 | "issues": "https://github.com/contao/easy-coding-standard/issues", 50 | "source": "https://github.com/contao/easy-coding-standard/tree/5.4.2" 51 | }, 52 | "funding": [ 53 | { 54 | "url": "https://to.contao.org/donate", 55 | "type": "custom" 56 | } 57 | ], 58 | "time": "2023-01-06T10:46:16+00:00" 59 | }, 60 | { 61 | "name": "dealerdirect/phpcodesniffer-composer-installer", 62 | "version": "v1.0.0", 63 | "source": { 64 | "type": "git", 65 | "url": "https://github.com/PHPCSStandards/composer-installer.git", 66 | "reference": "4be43904336affa5c2f70744a348312336afd0da" 67 | }, 68 | "dist": { 69 | "type": "zip", 70 | "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", 71 | "reference": "4be43904336affa5c2f70744a348312336afd0da", 72 | "shasum": "" 73 | }, 74 | "require": { 75 | "composer-plugin-api": "^1.0 || ^2.0", 76 | "php": ">=5.4", 77 | "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" 78 | }, 79 | "require-dev": { 80 | "composer/composer": "*", 81 | "ext-json": "*", 82 | "ext-zip": "*", 83 | "php-parallel-lint/php-parallel-lint": "^1.3.1", 84 | "phpcompatibility/php-compatibility": "^9.0", 85 | "yoast/phpunit-polyfills": "^1.0" 86 | }, 87 | "type": "composer-plugin", 88 | "extra": { 89 | "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" 90 | }, 91 | "autoload": { 92 | "psr-4": { 93 | "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" 94 | } 95 | }, 96 | "notification-url": "https://packagist.org/downloads/", 97 | "license": [ 98 | "MIT" 99 | ], 100 | "authors": [ 101 | { 102 | "name": "Franck Nijhof", 103 | "email": "franck.nijhof@dealerdirect.com", 104 | "homepage": "http://www.frenck.nl", 105 | "role": "Developer / IT Manager" 106 | }, 107 | { 108 | "name": "Contributors", 109 | "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" 110 | } 111 | ], 112 | "description": "PHP_CodeSniffer Standards Composer Installer Plugin", 113 | "homepage": "http://www.dealerdirect.com", 114 | "keywords": [ 115 | "PHPCodeSniffer", 116 | "PHP_CodeSniffer", 117 | "code quality", 118 | "codesniffer", 119 | "composer", 120 | "installer", 121 | "phpcbf", 122 | "phpcs", 123 | "plugin", 124 | "qa", 125 | "quality", 126 | "standard", 127 | "standards", 128 | "style guide", 129 | "stylecheck", 130 | "tests" 131 | ], 132 | "support": { 133 | "issues": "https://github.com/PHPCSStandards/composer-installer/issues", 134 | "source": "https://github.com/PHPCSStandards/composer-installer" 135 | }, 136 | "time": "2023-01-05T11:28:13+00:00" 137 | }, 138 | { 139 | "name": "phpstan/phpdoc-parser", 140 | "version": "1.15.3", 141 | "source": { 142 | "type": "git", 143 | "url": "https://github.com/phpstan/phpdoc-parser.git", 144 | "reference": "61800f71a5526081d1b5633766aa88341f1ade76" 145 | }, 146 | "dist": { 147 | "type": "zip", 148 | "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/61800f71a5526081d1b5633766aa88341f1ade76", 149 | "reference": "61800f71a5526081d1b5633766aa88341f1ade76", 150 | "shasum": "" 151 | }, 152 | "require": { 153 | "php": "^7.2 || ^8.0" 154 | }, 155 | "require-dev": { 156 | "php-parallel-lint/php-parallel-lint": "^1.2", 157 | "phpstan/extension-installer": "^1.0", 158 | "phpstan/phpstan": "^1.5", 159 | "phpstan/phpstan-phpunit": "^1.1", 160 | "phpstan/phpstan-strict-rules": "^1.0", 161 | "phpunit/phpunit": "^9.5", 162 | "symfony/process": "^5.2" 163 | }, 164 | "type": "library", 165 | "autoload": { 166 | "psr-4": { 167 | "PHPStan\\PhpDocParser\\": [ 168 | "src/" 169 | ] 170 | } 171 | }, 172 | "notification-url": "https://packagist.org/downloads/", 173 | "license": [ 174 | "MIT" 175 | ], 176 | "description": "PHPDoc parser with support for nullable, intersection and generic types", 177 | "support": { 178 | "issues": "https://github.com/phpstan/phpdoc-parser/issues", 179 | "source": "https://github.com/phpstan/phpdoc-parser/tree/1.15.3" 180 | }, 181 | "time": "2022-12-20T20:56:55+00:00" 182 | }, 183 | { 184 | "name": "slevomat/coding-standard", 185 | "version": "8.8.0", 186 | "source": { 187 | "type": "git", 188 | "url": "https://github.com/slevomat/coding-standard.git", 189 | "reference": "59e25146a4ef0a7b194c5bc55b32dd414345db89" 190 | }, 191 | "dist": { 192 | "type": "zip", 193 | "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/59e25146a4ef0a7b194c5bc55b32dd414345db89", 194 | "reference": "59e25146a4ef0a7b194c5bc55b32dd414345db89", 195 | "shasum": "" 196 | }, 197 | "require": { 198 | "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", 199 | "php": "^7.2 || ^8.0", 200 | "phpstan/phpdoc-parser": ">=1.15.2 <1.16.0", 201 | "squizlabs/php_codesniffer": "^3.7.1" 202 | }, 203 | "require-dev": { 204 | "phing/phing": "2.17.4", 205 | "php-parallel-lint/php-parallel-lint": "1.3.2", 206 | "phpstan/phpstan": "1.4.10|1.9.6", 207 | "phpstan/phpstan-deprecation-rules": "1.1.1", 208 | "phpstan/phpstan-phpunit": "1.0.0|1.3.3", 209 | "phpstan/phpstan-strict-rules": "1.4.4", 210 | "phpunit/phpunit": "7.5.20|8.5.21|9.5.27" 211 | }, 212 | "type": "phpcodesniffer-standard", 213 | "extra": { 214 | "branch-alias": { 215 | "dev-master": "8.x-dev" 216 | } 217 | }, 218 | "autoload": { 219 | "psr-4": { 220 | "SlevomatCodingStandard\\": "SlevomatCodingStandard" 221 | } 222 | }, 223 | "notification-url": "https://packagist.org/downloads/", 224 | "license": [ 225 | "MIT" 226 | ], 227 | "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", 228 | "keywords": [ 229 | "dev", 230 | "phpcs" 231 | ], 232 | "support": { 233 | "issues": "https://github.com/slevomat/coding-standard/issues", 234 | "source": "https://github.com/slevomat/coding-standard/tree/8.8.0" 235 | }, 236 | "funding": [ 237 | { 238 | "url": "https://github.com/kukulich", 239 | "type": "github" 240 | }, 241 | { 242 | "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", 243 | "type": "tidelift" 244 | } 245 | ], 246 | "time": "2023-01-09T10:46:13+00:00" 247 | }, 248 | { 249 | "name": "squizlabs/php_codesniffer", 250 | "version": "3.7.2", 251 | "source": { 252 | "type": "git", 253 | "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", 254 | "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" 255 | }, 256 | "dist": { 257 | "type": "zip", 258 | "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", 259 | "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", 260 | "shasum": "" 261 | }, 262 | "require": { 263 | "ext-simplexml": "*", 264 | "ext-tokenizer": "*", 265 | "ext-xmlwriter": "*", 266 | "php": ">=5.4.0" 267 | }, 268 | "require-dev": { 269 | "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" 270 | }, 271 | "bin": [ 272 | "bin/phpcs", 273 | "bin/phpcbf" 274 | ], 275 | "type": "library", 276 | "extra": { 277 | "branch-alias": { 278 | "dev-master": "3.x-dev" 279 | } 280 | }, 281 | "notification-url": "https://packagist.org/downloads/", 282 | "license": [ 283 | "BSD-3-Clause" 284 | ], 285 | "authors": [ 286 | { 287 | "name": "Greg Sherwood", 288 | "role": "lead" 289 | } 290 | ], 291 | "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", 292 | "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", 293 | "keywords": [ 294 | "phpcs", 295 | "standards", 296 | "static analysis" 297 | ], 298 | "support": { 299 | "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", 300 | "source": "https://github.com/squizlabs/PHP_CodeSniffer", 301 | "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" 302 | }, 303 | "time": "2023-02-22T23:07:41+00:00" 304 | }, 305 | { 306 | "name": "symplify/easy-coding-standard", 307 | "version": "10.3.3", 308 | "source": { 309 | "type": "git", 310 | "url": "https://github.com/symplify/easy-coding-standard.git", 311 | "reference": "c93878b3c052321231519b6540e227380f90be17" 312 | }, 313 | "dist": { 314 | "type": "zip", 315 | "url": "https://api.github.com/repos/symplify/easy-coding-standard/zipball/c93878b3c052321231519b6540e227380f90be17", 316 | "reference": "c93878b3c052321231519b6540e227380f90be17", 317 | "shasum": "" 318 | }, 319 | "require": { 320 | "php": ">=7.2" 321 | }, 322 | "conflict": { 323 | "friendsofphp/php-cs-fixer": "<3.0", 324 | "squizlabs/php_codesniffer": "<3.6" 325 | }, 326 | "bin": [ 327 | "bin/ecs" 328 | ], 329 | "type": "library", 330 | "extra": { 331 | "branch-alias": { 332 | "dev-main": "10.3-dev" 333 | } 334 | }, 335 | "autoload": { 336 | "files": [ 337 | "bootstrap.php" 338 | ] 339 | }, 340 | "notification-url": "https://packagist.org/downloads/", 341 | "license": [ 342 | "MIT" 343 | ], 344 | "description": "Prefixed scoped version of ECS package", 345 | "support": { 346 | "source": "https://github.com/symplify/easy-coding-standard/tree/10.3.3" 347 | }, 348 | "funding": [ 349 | { 350 | "url": "https://www.paypal.me/rectorphp", 351 | "type": "custom" 352 | }, 353 | { 354 | "url": "https://github.com/tomasvotruba", 355 | "type": "github" 356 | } 357 | ], 358 | "time": "2022-06-13T14:03:37+00:00" 359 | } 360 | ], 361 | "packages-dev": [], 362 | "aliases": [], 363 | "minimum-stability": "stable", 364 | "stability-flags": [], 365 | "prefer-stable": false, 366 | "prefer-lowest": false, 367 | "platform": [], 368 | "platform-dev": [], 369 | "plugin-api-version": "2.3.0" 370 | } 371 | --------------------------------------------------------------------------------