commit 666a54805aa9f449f526eabfaa8c9e4380ad31e2 Author: ppound Date: Mon Apr 26 11:31:33 2021 -0300 first commit diff --git a/DEVELOPER.txt b/DEVELOPER.txt new file mode 100644 index 0000000..d5d0f47 --- /dev/null +++ b/DEVELOPER.txt @@ -0,0 +1,26 @@ +--------------------------------------------------- +Some notes for developers working on the rules code +--------------------------------------------------- + +Terminology & Overview +----------------------- + * Rules plugins extend the "rules language". Thus conditions and actions are + implemented with a plugin, but also loops or ORs are plugins. Each plugin is + declared to be used in the condition or the action part - specified by the + interface. + * The action and condition plugin are a so called "AbstractPlugin" which means + they have to be implemented by modules to be actually usable. In fact the + callbacks provided by the action or condition implementation are + incorporated in the plugin object using faces. That way an action or + condition element behaves exactly like any other plugin instance. + * Any rule element is an instance of a RulesPlugin. + * A rules configuration consists of multiple rule elements while one is the + root element, which may be a 'rule' but also any other plugin. + * Each rules configuration may be persistently saved to the db using the + entity CRUD API. Using the API on contained rule elements is working too and + results in the whole configuration being updated. + * Rules provides per plugin UI components, what makes the UI parts re-usable + outside of the rule admin module too. In fact the rules admin module is + pretty small, as it just relies on the provided UI of the components. + * The UI is incorporated using the faces object extension mechanism, see + rules_rules_plugin_info() for an overview of the used UI extenders. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..ee5360e --- /dev/null +++ b/README.txt @@ -0,0 +1,91 @@ + +-------------------------------------------------------------------------------- + Rules +-------------------------------------------------------------------------------- + +Maintainers: + * Wolfgang Ziegler (fago), nuppla@zites.net + +The Rules module allows site administrators to define conditionally executed +actions based on occurring events (ECA-rules). + +Project homepage: https://www.drupal.org/project/rules + + +Installation +------------ + +*Before* starting, make sure that you have read at least the introduction - so +you know at least the basic concepts. You can find it here: + + https://www.drupal.org/node/298480 + + * Rules depends on the Entity API module, download and install it from + https://www.drupal.org/project/entity + * Copy the whole rules directory to your modules directory + (e.g. DRUPAL_ROOT/sites/all/modules) and activate the Rules and Rules UI + modules. + * The administrative user interface can be found at admin/config/workflow/rules + + +Documentation +------------- +* Check out the general docs at https://www.drupal.org/node/298476 +* Check out the developer targeted docs at https://www.drupal.org/node/878718 + + +Rules Scheduler +--------------- + + * If you enable the Rules scheduler module, you get new actions that allow you + to schedule the execution of Rules components. + * Make sure that you have configured cron for your drupal installation as cron + is used for scheduling the Rules components. For help see + https://www.drupal.org/cron + * If the Views module (https://www.drupal.org/project/views) is installed, the + module displays the list of scheduled tasks in the UI. + + +Upgrade from Rules 6.x-1.x to Rules 7.x-2.x +-------------------------------------------- + + * In order to upgrade Rules from 6.x-1.x to 7.x-2.x just run "update.php". This + is going to make sure Rules 2.x is properly installed, but it will leave your + Rules 1.x configurations untouched. Thus, your rules won't be upgraded yet. + * To convert your Rules 1.x configurations to Rules 2.x go to + 'admin/config/workflow/rules/upgrade'. + * At this page, you may choose the Rules 1.x rules and rule sets to upgrade + and whether the converted configurations should be immediately saved to + your database or whether the configuration export should be generated. + * Note that for importing an export the export needs to pass the + configuration integrity check, what might be troublesome if the + conversion was not 100% successful. In that case, try choosing the + immediate saving method and correct the configuration after conversion. + * A rule configuration might require multiple modules to be in place and + upgraded to work properly. E.g. if you used an action provided + by a third party module, make sure the module is in place and upgraded + before you convert the rule. + * If all required modules are installed and have been upgraded but the rule + conversion still fails, the cause might be that a module has not yet + upgraded its Rules integration or does not implement the Rules conversion + functionality. In that case, file an issue for the module that provided + the action or condition causing the conversion to fail. + * Note that any rule configurations containing token replacements or PHP + input evaluations might need some manual corrections in order to stay + working. This is, as some used token replacements might not be available + in Drupal 7 any more and the PHP code might need to be updated in order + to be compatible with Drupal 7. + * Once the upgrade was successful, you may delete the left over Rules 1.x + configurations by going to 'admin/config/workflow/rules/upgrade/clear'. + * The Rules Scheduler module also comes with an upgrade routine that is + invoked as usual via "update.php". Its actions can be upgraded via the usual + Rules upgrade tool, see above. + However, there is currently no support for upgrading already scheduled + tasks. That means, all previously on Drupal 6 scheduled tasks won't apply + for Drupal 7. The Drupal 6 tasks are preserved in the database as long as + you do not clear your Rules 1.x configuration though. + * The Rules Forms module has not been updated to Drupal 7 and there are no + plans to do so, as unfortunately the module's design does not allow for + automatic configuration updates. + Thus, a possible future Rules 2.x Forms module is likely to work + different, e.g. by working only for entity forms on the field level. diff --git a/includes/faces.inc b/includes/faces.inc new file mode 100644 index 0000000..a1fc93b --- /dev/null +++ b/includes/faces.inc @@ -0,0 +1,323 @@ +object = $object; + } + + /** + * Returns the extended object. + */ + public function getExtendable() { + return $this->object; + } + + /** + * Makes protected properties of the extendable accessible. + */ + protected function &property($name) { + $var =& $this->object->property($name); + return $var; + } + + /** + * Invokes any method on the extended object, including protected methods. + * + * @param string $name + * The method name. + * @param array $args + * An array of arguments to pass to the method. + */ + protected function call($name, array $args = array()) { + return $this->object->call($name, $args); + } + + } +} + + +if (!class_exists('FacesExtendable', FALSE)) { + + /** + * An extendable base class. + */ + abstract class FacesExtendable { + + protected $facesMethods = array(); + protected $faces = array(); + protected $facesIncludes = array(); + protected $facesClassInstances = array(); + static protected $facesIncluded = array(); + + /** + * Wraps calls to module_load_include() to prevent multiple inclusions. + * + * @see module_load_include() + */ + protected static function load_include($args) { + $args += array('type' => 'inc', 'module' => '', 'name' => NULL); + $key = implode(':', $args); + if (!isset(self::$facesIncluded[$key])) { + self::$facesIncluded[$key] = TRUE; + module_load_include($args['type'], $args['module'], $args['name']); + } + } + + /** + * Magic method: Invoke the dynamically implemented methods. + */ + public function __call($name, $arguments = array()) { + if (isset($this->facesMethods[$name])) { + $method = $this->facesMethods[$name]; + // Include code, if necessary. + if (isset($this->facesIncludes[$name])) { + self::load_include($this->facesIncludes[$name]); + $this->facesIncludes[$name] = NULL; + } + if (isset($method[0])) { + // We always pass the object reference and the name of the method. + $arguments[] = $this; + $arguments[] = $name; + return call_user_func_array($method[0], $arguments); + } + // Call the method on the extender object, but don't use extender() + // for performance reasons. + if (!isset($this->facesClassInstances[$method[1]])) { + $this->facesClassInstances[$method[1]] = new $method[1]($this); + } + return call_user_func_array(array($this->facesClassInstances[$method[1]], $name), $arguments); + } + $class = check_plain(get_class($this)); + throw new FacesExtendableException("There is no method $name for this instance of the class $class."); + } + + /** + * Returns the extender object for the given class. + * + * May be used to explicitly invoke a specific extender, e.g. a function + * overriding a method may use that to explicitly invoke the original + * extender. + */ + public function extender($class) { + if (!isset($this->facesClassInstances[$class])) { + $this->facesClassInstances[$class] = new $class($this); + } + return $this->facesClassInstances[$class]; + } + + /** + * Returns whether the object can face as the given interface. + * + * Returns whether the object can face as the given interface, thus it + * returns TRUE if this object has been extended by an appropriate + * implementation. + * + * @param $interface + * (optional) An interface to test for. If it's omitted, all interfaces + * that the object can be faced as are returned. + * + * @return bool + * Whether the object can face as the interface or an array of interface + * names. + */ + public function facesAs($interface = NULL) { + if (!isset($interface)) { + return array_values($this->faces); + } + return in_array($interface, $this->faces) || $this instanceof $interface; + } + + /** + * Extend the object by a class to implement the given interfaces. + * + * @param $interface + * The interface name or an array of interface names. + * @param $className + * The extender class, which has to implement the FacesExtenderInterface. + * @param array $includes + * An optional array describing the file to include before invoking the + * class. The array entries known are 'type', 'module', and 'name' + * matching the parameters of module_load_include(). Only 'module' is + * required as 'type' defaults to 'inc' and 'name' to NULL. + */ + public function extendByClass($interface, $className, array $includes = array()) { + $parents = class_implements($className); + if (!in_array('FacesExtenderInterface', $parents)) { + throw new FacesExtendableException("The class " . check_plain($className) . " doesn't implement the FacesExtenderInterface."); + } + $interfaces = is_array($interface) ? $interface : array($interface); + + foreach ($interfaces as $interface) { + if (!in_array($interface, $parents)) { + throw new FacesExtendableException("The class " . check_plain($className) . " doesn't implement the interface " . check_plain($interface) . "."); + } + $this->faces[$interface] = $interface; + $this->faces += class_implements($interface); + $face_methods = get_class_methods($interface); + $this->addIncludes($face_methods, $includes); + foreach ($face_methods as $method) { + $this->facesMethods[$method] = array(1 => $className); + } + } + } + + /** + * Extend the object by the given functions to implement the given + * interface. There has to be an implementation function for each method of + * the interface. + * + * @param $interface + * The interface name or FALSE to extend the object without a given + * interface. + * @param array $callbacks + * An array, where the keys are methods of the given interface and the + * values the callback functions to use. + * @param array $includes + * An optional array to describe files to include before invoking the + * callbacks. You may pass a single array describing one include for all + * callbacks or an array of arrays, keyed by the method names. Look at the + * extendByClass() $include parameter for more details about how to + * describe a single file. + */ + public function extend($interface, array $callbacks = array(), array $includes = array()) { + $face_methods = $interface ? get_class_methods($interface) : array_keys($callbacks); + if ($interface) { + if (array_diff($face_methods, array_keys($callbacks))) { + throw new FacesExtendableException("Missing methods for implementing the interface " . check_plain($interface) . "."); + } + $this->faces[$interface] = $interface; + $this->faces += class_implements($interface); + } + $this->addIncludes($face_methods, $includes); + foreach ($face_methods as $method) { + $this->facesMethods[$method] = array(0 => $callbacks[$method]); + } + } + + /** + * Override the implementation of an extended method. + * + * @param array $callbacks + * An array of methods of the interface, that should be overridden, where + * the keys are methods to override and the values the callback functions + * to use. + * @param array $includes + * An optional array to describe files to include before invoking the + * callbacks. You may pass a single array describing one include for all + * callbacks or an array of arrays, keyed by the method names. Look at the + * extendByClass() $include parameter for more details about how to + * describe a single file. + */ + public function override(array $callbacks = array(), array $includes = array()) { + if (array_diff_key($callbacks, $this->facesMethods)) { + throw new FacesExtendableException("A not implemented method is to be overridden."); + } + $this->addIncludes(array_keys($callbacks), $includes); + foreach ($callbacks as $method => $callback) { + $this->facesMethods[$method] = array(0 => $callback); + } + } + + /** + * Adds in include files for the given methods while removing any old files. + * + * If a single include file is described, it's added for all methods. + */ + protected function addIncludes($methods, $includes) { + $includes = isset($includes['module']) && is_string($includes['module']) ? array_fill_keys($methods, $includes) : $includes; + $this->facesIncludes = $includes + array_diff_key($this->facesIncludes, array_flip($methods)); + } + + /** + * Only serialize what is really necessary. + */ + public function __sleep() { + return array('facesMethods', 'faces', 'facesIncludes'); + } + + /** + * Destroys all references to created instances. + * + * Destroys all references to created instances so that PHP's garbage + * collection can do its work. This is needed as PHP's gc has troubles with + * circular references until PHP < 5.3. + */ + public function destroy() { + // Avoid circular references. + $this->facesClassInstances = array(); + } + + /** + * Makes protected properties accessible. + */ + public function &property($name) { + if (property_exists($this, $name)) { + return $this->$name; + } + } + + /** + * Invokes any method. + * + * This also allows to pass arguments by reference, so it may be used to + * pass arguments by reference to dynamically extended methods. + * + * @param string $name + * The method name. + * @param array $args + * An array of arguments to pass to the method. + */ + public function call($name, array $args = array()) { + if (method_exists($this, $name)) { + return call_user_func_array(array($this, $name), $args); + } + return $this->__call($name, $args); + } + + } + +} diff --git a/includes/rules.core.inc b/includes/rules.core.inc new file mode 100644 index 0000000..28100a6 --- /dev/null +++ b/includes/rules.core.inc @@ -0,0 +1,2933 @@ + 'rules'); + return parent::create($values); + } + + /** + * Overridden. + * + * @see DrupalDefaultEntityController::attachLoad() + */ + protected function attachLoad(&$queried_entities, $revision_id = FALSE) { + // Retrieve stdClass records and store them as rules objects in 'data'. + $ids = array_keys($queried_entities); + $result = db_select('rules_tags') + ->fields('rules_tags', array('id', 'tag')) + ->condition('id', $ids, 'IN') + ->execute(); + foreach ($result as $row) { + $tags[$row->id][] = $row->tag; + } + $result = db_select('rules_dependencies') + ->fields('rules_dependencies', array('id', 'module')) + ->condition('id', $ids, 'IN') + ->execute(); + foreach ($result as $row) { + $modules[$row->id][] = $row->module; + } + + $entities = array(); + foreach ($queried_entities as $record) { + $entity = $record->data; + // Set the values of the other columns. + foreach ($this->entityInfo['schema_fields_sql']['base table'] as $field) { + $entity->$field = $record->$field; + } + unset($entity->data, $entity->plugin); + // Add any tags or dependencies. + $entity->dependencies = isset($modules[$entity->id]) ? $modules[$entity->id] : array(); + $entity->tags = isset($tags[$entity->id]) ? $tags[$entity->id] : array(); + $entities[$entity->id] = $entity; + } + $queried_entities = $entities; + parent::attachLoad($queried_entities, $revision_id); + } + + /** + * Override to support having events and tags as conditions. + * + * @see EntityAPIController::applyConditions() + * @see rules_query_rules_config_load_multiple_alter() + */ + protected function applyConditions($entities, $conditions = array()) { + if (isset($conditions['event']) || isset($conditions['plugin'])) { + foreach ($entities as $key => $entity) { + if (isset($conditions['event']) && (!($entity instanceof RulesTriggerableInterface) || !in_array($conditions['event'], $entity->events()))) { + unset($entities[$key]); + } + if (isset($conditions['plugin']) && !is_array($conditions['plugin'])) { + $conditions['plugin'] = array($conditions['plugin']); + } + if (isset($conditions['plugin']) && !in_array($entity->plugin(), $conditions['plugin'])) { + unset($entities[$key]); + } + } + unset($conditions['event'], $conditions['plugin']); + } + if (!empty($conditions['tags'])) { + foreach ($entities as $key => $entity) { + foreach ($conditions['tags'] as $tag) { + if (in_array($tag, $entity->tags)) { + continue 2; + } + } + unset($entities[$key]); + } + unset($conditions['tags']); + } + return parent::applyConditions($entities, $conditions); + } + + /** + * Overridden to work with Rules' custom export format. + * + * @param string $export + * A serialized string in JSON format as produced by the + * RulesPlugin::export() method, or the PHP export as usual PHP array. + * @param string $error_msg + * The error message. + */ + public function import($export, &$error_msg = '') { + $export = is_array($export) ? $export : drupal_json_decode($export); + if (!is_array($export)) { + $error_msg = t('Unable to parse the pasted export.'); + return FALSE; + } + // The key is the configuration name and the value the actual export. + $name = key($export); + $export = current($export); + if (!isset($export['PLUGIN'])) { + $error_msg = t('Export misses plugin information.'); + return FALSE; + } + // Create an empty configuration, re-set basic keys and import. + $config = rules_plugin_factory($export['PLUGIN']); + $config->name = $name; + foreach (array('label', 'active', 'weight', 'tags', 'access_exposed', 'owner') as $key) { + if (isset($export[strtoupper($key)])) { + $config->$key = $export[strtoupper($key)]; + } + } + if (!empty($export['REQUIRES'])) { + foreach ($export['REQUIRES'] as $module) { + if (!module_exists($module)) { + $error_msg = t('Missing the required module %module.', array('%module' => $module)); + return FALSE; + } + } + $config->dependencies = $export['REQUIRES']; + } + $config->import($export); + return $config; + } + + public function save($rules_config, DatabaseTransaction $transaction = NULL) { + $transaction = isset($transaction) ? $transaction : db_transaction(); + + // Load the stored entity, if any. + if (!isset($rules_config->original) && $rules_config->{$this->idKey}) { + $rules_config->original = entity_load_unchanged($this->entityType, $rules_config->{$this->idKey}); + } + $original = isset($rules_config->original) ? $rules_config->original : NULL; + + $return = parent::save($rules_config, $transaction); + $this->storeTags($rules_config); + if ($rules_config instanceof RulesTriggerableInterface) { + $this->storeEvents($rules_config); + } + $this->storeDependencies($rules_config); + + // See if there are any events that have been removed. + if ($original && $rules_config->plugin == 'reaction rule') { + foreach (array_diff($original->events(), $rules_config->events()) as $event_name) { + // Check if the event handler implements the event dispatcher interface. + $handler = rules_get_event_handler($event_name, $rules_config->getEventSettings($event_name)); + if (!$handler instanceof RulesEventDispatcherInterface) { + continue; + } + + // Only stop an event dispatcher if there are no rules for it left. + if (!rules_config_load_multiple(FALSE, array('event' => $event_name, 'plugin' => 'reaction rule', 'active' => TRUE)) && $handler->isWatching()) { + $handler->stopWatching(); + } + } + } + + return $return; + } + + /** + * Save tagging information to the rules_tags table. + */ + protected function storeTags($rules_config) { + db_delete('rules_tags') + ->condition('id', $rules_config->id) + ->execute(); + if (!empty($rules_config->tags)) { + foreach ($rules_config->tags as $tag) { + db_insert('rules_tags') + ->fields(array('id', 'tag'), array($rules_config->id, $tag)) + ->execute(); + } + } + } + + /** + * Save event information to the rules_trigger table. + */ + protected function storeEvents(RulesTriggerableInterface $rules_config) { + db_delete('rules_trigger') + ->condition('id', $rules_config->id) + ->execute(); + foreach ($rules_config->events() as $event) { + db_insert('rules_trigger') + ->fields(array( + 'id' => $rules_config->id, + 'event' => $event, + )) + ->execute(); + } + } + + protected function storeDependencies($rules_config) { + db_delete('rules_dependencies') + ->condition('id', $rules_config->id) + ->execute(); + if (!empty($rules_config->dependencies)) { + foreach ($rules_config->dependencies as $dependency) { + db_insert('rules_dependencies') + ->fields(array( + 'id' => $rules_config->id, + 'module' => $dependency, + )) + ->execute(); + } + } + } + + /** + * Overridden to support tags and events in $conditions. + * + * @see EntityAPIControllerExportable::buildQuery() + */ + protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) { + $query = parent::buildQuery($ids, $conditions, $revision_id); + $query_conditions =& $query->conditions(); + foreach ($query_conditions as &$condition) { + // One entry in $query_conditions is a string with key '#conjunction'. + // @see QueryConditionInterface::conditions() + if (is_array($condition)) { + // Support using 'tags' => array('tag1', 'tag2') as condition. + if ($condition['field'] == 'base.tags') { + $query->join('rules_tags', 'rt', 'base.id = rt.id'); + $condition['field'] = 'rt.tag'; + } + // Support using 'event' => $name as condition. + if ($condition['field'] == 'base.event') { + $query->join('rules_trigger', 'tr', "base.id = tr.id"); + $condition['field'] = 'tr.event'; + // Use like operator to support % wildcards also. + $condition['operator'] = 'LIKE'; + } + } + } + return $query; + } + + /** + * Overridden to also delete tags and events. + * + * @see EntityAPIControllerExportable::delete() + */ + public function delete($ids, DatabaseTransaction $transaction = NULL) { + $transaction = isset($transaction) ? $transaction : db_transaction(); + // Use entity-load as ids may be the names as well as the ids. + $configs = $ids ? entity_load('rules_config', $ids) : array(); + if ($configs) { + foreach ($configs as $config) { + db_delete('rules_trigger') + ->condition('id', $config->id) + ->execute(); + db_delete('rules_tags') + ->condition('id', $config->id) + ->execute(); + db_delete('rules_dependencies') + ->condition('id', $config->id) + ->execute(); + } + } + $return = parent::delete($ids, $transaction); + + // Stop event dispatchers when deleting the last rule of an event set. + $processed = array(); + foreach ($configs as $config) { + if ($config->getPluginName() != 'reaction rule') { + continue; + } + + foreach ($config->events() as $event_name) { + // Only process each event once. + if (!empty($processed[$event_name])) { + continue; + } + $processed[$event_name] = TRUE; + + // Check if the event handler implements the event dispatcher interface. + $handler = rules_get_event_handler($event_name, $config->getEventSettings($event_name)); + if (!$handler instanceof RulesEventDispatcherInterface) { + continue; + } + + // Only stop an event dispatcher if there are no rules for it left. + if ($handler->isWatching() && !rules_config_load_multiple(FALSE, array('event' => $event_name, 'plugin' => 'reaction rule', 'active' => TRUE))) { + $handler->stopWatching(); + } + } + } + + return $return; + } + +} + +/** + * Base class for RulesExtendables. + * + * The RulesExtendable uses the rules cache to setup the defined extenders + * and overrides automatically. + * As soon faces is used the faces information is autoloaded using setUp(). + */ +abstract class RulesExtendable extends FacesExtendable { + + /** + * The name of the info definitions associated with info about this class. + * + * This would be defined abstract, if possible. Common rules hooks with class + * info are e.g. plugin_info and data_info. + */ + protected $hook; + + /** + * The name of the item this class represents in the info hook. + * + * @var string + */ + protected $itemName; + + protected $cache; + + /** + * @var array + */ + protected $itemInfo = array(); + + public function __construct() { + $this->setUp(); + } + + protected function setUp() { + // Keep a reference on the cache, so elements created during cache + // rebuilding end up with a complete cache in the end too. + $this->cache = &rules_get_cache(); + if (isset($this->cache[$this->hook][$this->itemName])) { + $this->itemInfo = &$this->cache[$this->hook][$this->itemName]; + } + // Set up the Faces Extenders. + if (!empty($this->itemInfo['faces_cache'])) { + list($this->facesMethods, $this->facesIncludes, $this->faces) = $this->itemInfo['faces_cache']; + } + } + + /** + * Forces the object to be setUp, this executes setUp() if not done yet. + */ + public function forceSetUp() { + if (!isset($this->cache) || (!empty($this->itemInfo['faces_cache']) && !$this->faces)) { + $this->setUp(); + } + } + + /** + * Magic method: Invoke the dynamically implemented methods. + */ + public function __call($name, $arguments = array()) { + $this->forceSetUp(); + return parent::__call($name, $arguments); + } + + public function facesAs($interface = NULL) { + $this->forceSetUp(); + return parent::facesAs($interface); + } + + /** + * Allows items to add something to the rules cache. + */ + public function rebuildCache(&$itemInfo, &$cache) { + // Speed up setting up items by caching the faces methods. + if (!empty($itemInfo['extenders'])) { + // Apply extenders and overrides. + $itemInfo += array('overrides' => array()); + foreach ($itemInfo['extenders'] as $face => $data) { + $data += array('file' => array()); + if (isset($data['class'])) { + $this->extendByClass($face, $data['class'], $data['file']); + } + elseif (isset($data['methods'])) { + $this->extend($face, $data['methods'], $data['file']); + } + } + foreach ($itemInfo['overrides'] as $data) { + $data += array('file' => array()); + $this->override($data['methods'], $data['file']); + } + $itemInfo['faces_cache'] = array($this->facesMethods, $this->facesIncludes, $this->faces); + // We don't need that any more. + unset($itemInfo['extenders'], $itemInfo['overrides']); + } + } + + /** + * Returns whether the a RuleExtendable supports the given interface. + * + * @param $itemInfo + * The info about the item as specified in the hook. + * @param $interface + * The interface to check for. + * + * @return bool + * Whether it supports the given interface. + */ + public static function itemFacesAs(&$itemInfo, $interface) { + return in_array($interface, class_implements($itemInfo['class'])) || isset($itemInfo['faces_cache'][2][$interface]); + } + +} + +/** + * Base class for rules plugins. + * + * We cannot inherit from EntityDB at the same time, so we implement our own + * entity related methods. Any CRUD related actions performed on contained + * plugins are applied and the root element representing the configuration is + * saved. + */ +abstract class RulesPlugin extends RulesExtendable { + + /** + * If this is a configuration saved to the db, the id of it. + */ + public $id = NULL; + public $weight = 0; + public $name = NULL; + + /** + * An array of settings for this element. + * + * @var array + */ + public $settings = array(); + + /** + * Info about this element. Usage depends on the plugin. + * + * @var array + */ + protected $info = array(); + + /** + * The parent element, if any. + * + * @var RulesContainerPlugin + */ + protected $parent = NULL; + + protected $cache = NULL; + + /** + * @var array + */ + protected $hook = 'plugin_info'; + + /** + * Identifies an element inside a configuration. + */ + protected $elementId = NULL; + + /** + * Static cache for availableVariables(). + */ + protected $availableVariables; + + /** + * Sets a new parent element. + */ + public function setParent(RulesContainerPlugin $parent) { + if ($this->parent == $parent) { + return; + } + if (isset($this->parent) && ($key = array_search($this, $this->parent->children)) !== FALSE) { + // Remove element from any previous parent. + unset($this->parent->children[$key]); + $this->parent->resetInternalCache(); + } + // Make sure the interface matches the type of the container. + if (($parent instanceof RulesActionContainer && $this instanceof RulesActionInterface) || + ($parent instanceof RulesConditionContainer && $this instanceof RulesConditionInterface)) { + + $this->parent = $parent; + $parent->children[] = $this; + $this->parent->resetInternalCache(); + } + else { + throw new RulesEvaluationException('The given container is incompatible with this element.', array(), $this, RulesLog::ERROR); + } + } + + /** + * Gets the root element of the configuration. + */ + public function root() { + $element = $this; + while (!$element->isRoot()) { + $element = $element->parent; + } + return $element; + } + + /** + * Returns whether the element is the root of the configuration. + */ + public function isRoot() { + return empty($this->parent) || isset($this->name); + } + + /** + * Returns the element's parent. + */ + public function parentElement() { + return $this->parent; + } + + /** + * Returns the element id, which identifies the element inside the config. + */ + public function elementId() { + if (!isset($this->elementId)) { + $this->elementMap()->index(); + } + return $this->elementId; + } + + /** + * Gets the element map helper object, which helps mapping elements to ids. + * + * @return RulesElementMap + */ + public function elementMap() { + $config = $this->root(); + if (empty($config->map)) { + $config->map = new RulesElementMap($config); + } + return $config->map; + } + + /** + * Iterate over all elements nested below the current element. + * + * This helper can be used to recursively iterate over all elements of a + * configuration. To iterate over the children only, just regularly iterate + * over the object. + * + * @param int $mode + * (optional) The iteration mode used. See + * RecursiveIteratorIterator::construct(). Defaults to SELF_FIRST. + * + * @return RecursiveIteratorIterator + */ + public function elements($mode = RecursiveIteratorIterator::SELF_FIRST) { + return new RecursiveIteratorIterator($this, $mode); + } + + /** + * Do a deep clone. + */ + public function __clone() { + // Make sure the element map is cleared. + // @see self::elementMap() + unset($this->map); + } + + /** + * Returns the depth of this element in the configuration. + */ + public function depth() { + $element = $this; + $i = 0; + while (!empty($element->parent)) { + $element = $element->parent; + $i++; + } + return $i; + } + + /** + * Execute the configuration. + * + * @param ... + * Arguments to pass to the configuration. + */ + public function execute() { + return $this->executeByArgs(func_get_args()); + } + + /** + * Execute the configuration by passing arguments in a single array. + */ + abstract public function executeByArgs($args = array()); + + /** + * Evaluate the element on a given rules evaluation state. + */ + abstract public function evaluate(RulesState $state); + + protected static function compare(RulesPlugin $a, RulesPlugin $b) { + if ($a->weight == $b->weight) { + return 0; + } + return ($a->weight < $b->weight) ? -1 : 1; + } + + /** + * Returns info about parameters needed by the plugin. + * + * Note that not necessarily all parameters are needed when executing the + * plugin, as values for the parameter might have been already configured via + * the element settings. + * + * @see self::parameterInfo() + */ + public function pluginParameterInfo() { + return isset($this->info['parameter']) ? $this->info['parameter'] : array(); + } + + /** + * Returns info about parameters needed for executing the configured plugin. + * + * @param bool $optional + * Whether optional parameters should be included. + * + * @see self::pluginParameterInfo() + */ + public function parameterInfo($optional = FALSE) { + // We have to filter out parameters that are already configured. + foreach ($this->pluginParameterInfo() as $name => $info) { + if (!isset($this->settings[$name . ':select']) && !isset($this->settings[$name]) && ($optional || (empty($info['optional']) && $info['type'] != 'hidden'))) { + $vars[$name] = $info; + } + } + return isset($vars) ? $vars : array(); + } + + /** + * Returns info about variables 'provided' by the plugin. + * + * Note that this method returns info about the provided variables as defined + * by the plugin. Thus this resembles the original info, which may be + * adapted via configuration. + * + * @see self::providesVariables() + */ + public function pluginProvidesVariables() { + return isset($this->info['provides']) ? $this->info['provides'] : array(); + } + + /** + * Returns info about all variables provided for later evaluated elements. + * + * @see self::pluginProvidesVariables() + */ + public function providesVariables() { + foreach ($this->pluginProvidesVariables() as $name => $info) { + $info['source name'] = $name; + $info['label'] = isset($this->settings[$name . ':label']) ? $this->settings[$name . ':label'] : $info['label']; + if (isset($this->settings[$name . ':var'])) { + $name = $this->settings[$name . ':var']; + } + $provides[$name] = $info; + } + return isset($provides) ? $provides : array(); + } + + /** + * Returns the info of the plugin. + */ + public function info() { + return $this->info; + } + + /** + * When converted to a string, just use the export format. + */ + public function __toString() { + return $this->isRoot() ? $this->export() : entity_var_json_export($this->export()); + } + + /** + * Gets variables to return once the configuration has been executed. + */ + protected function returnVariables(RulesState $state, $result = NULL) { + $var_info = $this->providesVariables(); + foreach ($var_info as $name => $info) { + try { + $vars[$name] = $this->getArgument($name, $info, $state); + } + catch (RulesEvaluationException $e) { + // Ignore not existing variables. + $vars[$name] = NULL; + } + $var_info[$name] += array('allow null' => TRUE); + } + return isset($vars) ? array_values(rules_unwrap_data($vars, $var_info)) : array(); + } + + /** + * Sets up the execution state for the given arguments. + */ + public function setUpState(array $args) { + $state = new RulesState(); + $vars = $this->setUpVariables(); + // Fix numerically indexed args to start with 0. + if (!isset($args[rules_array_key($vars)])) { + $args = array_values($args); + } + $offset = 0; + foreach (array_keys($vars) as $i => $name) { + $info = $vars[$name]; + if (!empty($info['handler']) || (isset($info['parameter']) && $info['parameter'] === FALSE)) { + $state->addVariable($name, NULL, $info); + // Count the variables that are not passed as parameters. + $offset++; + } + // Support numerically indexed arrays as well as named parameter style. + // The index is reduced to exclude non-parameter variables. + elseif (isset($args[$i - $offset])) { + $state->addVariable($name, $args[$i - $offset], $info); + } + elseif (isset($args[$name])) { + $state->addVariable($name, $args[$name], $info); + } + elseif (empty($info['optional']) && $info['type'] != 'hidden') { + throw new RulesEvaluationException('Argument %name is missing.', array('%name' => $name), $this, RulesLog::ERROR); + } + } + return $state; + } + + /** + * Returns info about all variables that have to be setup in the state. + */ + protected function setUpVariables() { + return $this->parameterInfo(TRUE); + } + + /** + * Returns info about variables available to be used as arguments for this element. + * + * As this is called very often, e.g. during integrity checks, we statically + * cache the results. + * + * @see RulesPlugin::resetInternalCache() + */ + public function availableVariables() { + if (!isset($this->availableVariables)) { + $this->availableVariables = !$this->isRoot() ? $this->parent->stateVariables($this) : RulesState::defaultVariables(); + } + return $this->availableVariables; + } + + /** + * Returns asserted additions to the available variable info. + * + * Any returned info is merged into the variable info, in case the execution + * flow passes the element. + * E.g. this is used to assert the content type of a node if the condition + * is met, such that the per-node type properties are available. + */ + protected function variableInfoAssertions() { + return array(); + } + + /** + * Gets the name of this plugin instance. + * + * The returned name should identify the code which drives this plugin. + */ + public function getPluginName() { + return $this->itemName; + } + + /** + * Calculates an array of required modules. + * + * You can use $this->dependencies to access dependencies for saved + * configurations. + */ + public function dependencies() { + $this->processSettings(); + $modules = isset($this->itemInfo['module']) && $this->itemInfo['module'] != 'rules' ? array($this->itemInfo['module'] => 1) : array(); + foreach ($this->pluginParameterInfo() as $name => $info) { + if (isset($this->settings[$name . ':process']) && $this->settings[$name . ':process'] instanceof RulesDataProcessor) { + $modules += array_flip($this->settings[$name . ':process']->dependencies()); + } + } + return array_keys($modules); + } + + /** + * Whether the currently logged in user has access to all configured elements. + * + * Note that this only checks whether the current user has permission to all + * configured elements, but not whether a user has access to configure Rule + * configurations in general. Use rules_config_access() for that. + * + * Use this to determine access permissions for configuring or triggering the + * execution of certain configurations independent of the Rules UI. + * + * @see rules_config_access() + */ + public function access() { + $this->processSettings(); + foreach ($this->pluginParameterInfo() as $name => $info) { + if (isset($this->settings[$name . ':select']) && $wrapper = $this->applyDataSelector($this->settings[$name . ':select'])) { + if ($wrapper->access('view') === FALSE) { + return FALSE; + } + } + // Incorporate access checks for data processors and input evaluators. + if (isset($this->settings[$name . ':process']) && $this->settings[$name . ':process'] instanceof RulesDataProcessor && !$this->settings[$name . ':process']->editAccess()) { + return FALSE; + } + } + return TRUE; + } + + /** + * Processes the settings e.g. to prepare input evaluators. + * + * Usually settings get processed automatically, however if $this->settings + * has been altered manually after element construction, it needs to be + * invoked explicitly with $force set to TRUE. + */ + public function processSettings($force = FALSE) { + // Process if not done yet. + if ($force || !empty($this->settings['#_needs_processing'])) { + $var_info = $this->availableVariables(); + foreach ($this->pluginParameterInfo() as $name => $info) { + // Prepare input evaluators. + if (isset($this->settings[$name])) { + $this->settings[$name . ':process'] = $this->settings[$name]; + RulesDataInputEvaluator::prepareSetting($this->settings[$name . ':process'], $info, $var_info); + } + // Prepare data processors. + elseif (isset($this->settings[$name . ':select']) && !empty($this->settings[$name . ':process'])) { + RulesDataProcessor::prepareSetting($this->settings[$name . ':process'], $info, $var_info); + } + // Clean up. + if (empty($this->settings[$name . ':process'])) { + unset($this->settings[$name . ':process']); + } + } + unset($this->settings['#_needs_processing']); + } + } + + /** + * Makes sure the plugin is configured right. + * + * "Configured right" means all needed variables are available in the + * element's scope and dependent modules are enabled. + * + * @return $this + * + * @throws RulesIntegrityException + * In case of a failed integrity check, a RulesIntegrityException exception + * is thrown. + */ + public function integrityCheck() { + // First process the settings if not done yet. + $this->processSettings(); + // Check dependencies using the pre-calculated dependencies stored in + // $this->dependencies. Fail back to calculation them on the fly, e.g. + // during creation. + $dependencies = empty($this->dependencies) ? $this->dependencies() : $this->dependencies; + foreach ($dependencies as $module) { + if (!module_exists($module)) { + throw new RulesDependencyException(t('Missing required module %name.', array('%name' => $module))); + } + } + // Check the parameter settings. + $this->checkParameterSettings(); + // Check variable names for provided variables to be valid. + foreach ($this->pluginProvidesVariables() as $name => $info) { + if (isset($this->settings[$name . ':var'])) { + $this->checkVarName($this->settings[$name . ':var']); + } + } + return $this; + } + + protected function checkVarName($name) { + if (!preg_match('/^[0-9a-zA-Z_]*$/', $name)) { + throw new RulesIntegrityException(t('%plugin: The variable name %name contains not allowed characters.', array('%plugin' => $this->getPluginName(), '%name' => $name)), $this); + } + } + + /** + * Checks whether parameters are correctly configured. + */ + protected function checkParameterSettings() { + foreach ($this->pluginParameterInfo() as $name => $info) { + if (isset($info['restriction']) && $info['restriction'] == 'selector' && isset($this->settings[$name])) { + throw new RulesIntegrityException(t("The parameter %name may only be configured using a selector.", array('%name' => $name)), array($this, 'parameter', $name)); + } + elseif (isset($info['restriction']) && $info['restriction'] == 'input' && isset($this->settings[$name . ':select'])) { + throw new RulesIntegrityException(t("The parameter %name may not be configured using a selector.", array('%name' => $name)), array($this, 'parameter', $name)); + } + elseif (!empty($this->settings[$name . ':select']) && !$this->applyDataSelector($this->settings[$name . ':select'])) { + throw new RulesIntegrityException(t("Data selector %selector for parameter %name is invalid.", array('%selector' => $this->settings[$name . ':select'], '%name' => $name)), array($this, 'parameter', $name)); + } + elseif ($arg_info = $this->getArgumentInfo($name)) { + // If we have enough metadata, check whether the types match. + if (!RulesData::typesMatch($arg_info, $info)) { + throw new RulesIntegrityException(t("The data type of the configured argument does not match the parameter's %name requirement.", array('%name' => $name)), array($this, 'parameter', $name)); + } + } + elseif (!$this->isRoot() && !isset($this->settings[$name]) && empty($info['optional']) && $info['type'] != 'hidden') { + throw new RulesIntegrityException(t('Missing configuration for parameter %name.', array('%name' => $name)), array($this, 'parameter', $name)); + } + // @todo Make sure used values are allowed. + // (key/value pairs + allowed values). + } + } + + /** + * Returns the argument for the parameter $name described with $info. + * + * Returns the argument as configured in the element settings for the + * parameter $name described with $info. + * + * @param string $name + * The name of the parameter for which to get the argument. + * @param $info + * Info about the parameter. + * @param RulesState $state + * The current evaluation state. + * @param string $langcode + * (optional) The language code used to get the argument value if the + * argument value should be translated. By default (NULL) the current + * interface language will be used. + * + * @return + * The argument, possibly wrapped. + * + * @throws RulesEvaluationException + * In case the argument cannot be retrieved an exception is thrown. + */ + protected function getArgument($name, $info, RulesState $state, $langcode = NULL) { + // Only apply the langcode if the parameter has been marked translatable. + if (empty($info['translatable'])) { + $langcode = LANGUAGE_NONE; + } + elseif (!isset($langcode)) { + $langcode = $GLOBALS['language']->language; + } + + if (!empty($this->settings[$name . ':select'])) { + $arg = $state->applyDataSelector($this->settings[$name . ':select'], $langcode); + } + elseif (isset($this->settings[$name])) { + $arg = rules_wrap_data($this->settings[$name], $info); + // We don't sanitize directly specified values. + $skip_sanitize = TRUE; + } + elseif ($state->varinfo($name)) { + $arg = $state->get($name); + } + elseif (empty($info['optional']) && $info['type'] != 'hidden') { + throw new RulesEvaluationException('Required parameter %name is missing.', array('%name' => $name), $this, RulesLog::ERROR); + } + else { + $arg = isset($info['default value']) ? $info['default value'] : NULL; + $skip_sanitize = TRUE; + $info['allow null'] = TRUE; + } + // Make sure the given value is set if required (default). + if (!isset($arg) && empty($info['allow null'])) { + throw new RulesEvaluationException('The provided argument for parameter %name is empty.', array('%name' => $name), $this); + } + + // Support passing already sanitized values. + if ($info['type'] == 'text' && !isset($skip_sanitize) && !empty($info['sanitize']) && !($arg instanceof EntityMetadataWrapper)) { + $arg = check_plain((string) $arg); + } + + // Apply any configured data processors. + if (!empty($this->settings[$name . ':process'])) { + // For processing, make sure the data is unwrapped now. + $return = rules_unwrap_data(array($arg), array($info)); + // @todo For Drupal 8: Refactor to add the name and language code as + // separate parameter to process(). + $info['#name'] = $name; + $info['#langcode'] = $langcode; + return isset($return[0]) ? $this->settings[$name . ':process']->process($return[0], $info, $state, $this) : NULL; + } + return $arg; + } + + /** + * Gets the right arguments for executing the element. + * + * @throws RulesEvaluationException + * If case an argument cannot be retrieved an exception is thrown. + */ + protected function getExecutionArguments(RulesState $state) { + $parameters = $this->pluginParameterInfo(); + // If there is language parameter, get its value first so it can be used + // for getting other translatable values. + $langcode = NULL; + if (isset($parameters['language'])) { + $lang_arg = $this->getArgument('language', $parameters['language'], $state); + $langcode = $lang_arg instanceof EntityMetadataWrapper ? $lang_arg->value() : $lang_arg; + } + // Now get all arguments. + foreach ($parameters as $name => $info) { + $args[$name] = $name == 'language' ? $lang_arg : $this->getArgument($name, $info, $state, $langcode); + } + // Append the settings and the execution state. Faces will append $this. + $args['settings'] = $this->settings; + $args['state'] = $state; + // Make the wrapped variables for the arguments available in the state. + $state->currentArguments = $args; + return rules_unwrap_data($args, $parameters); + } + + /** + * Applies the given data selector. + * + * Applies the given data selector by using the info about available + * variables. Thus it doesn't require an actual evaluation state. + * + * @param string $selector + * The selector string, e.g. "node:author:mail". + * + * @return EntityMetadataWrapper + * An empty wrapper for the given selector or FALSE if the selector couldn't + * be applied. + */ + public function applyDataSelector($selector) { + $parts = explode(':', str_replace('-', '_', $selector), 2); + if (($vars = $this->availableVariables()) && isset($vars[$parts[0]]['type'])) { + $wrapper = rules_wrap_data(NULL, $vars[$parts[0]], TRUE); + if (count($parts) > 1 && $wrapper instanceof EntityMetadataWrapper) { + try { + foreach (explode(':', $parts[1]) as $name) { + if ($wrapper instanceof EntityListWrapper || $wrapper instanceof EntityStructureWrapper) { + $wrapper = $wrapper->get($name); + } + else { + return FALSE; + } + } + } + // Return FALSE if there is no wrappper or we get an exception. + catch (EntityMetadataWrapperException $e) { + return FALSE; + } + } + } + return isset($wrapper) ? $wrapper : FALSE; + } + + /** + * Returns info about the configured argument. + * + * @return + * The determined info. If it's not known NULL is returned. + */ + public function getArgumentInfo($name) { + $vars = $this->availableVariables(); + if (!empty($this->settings[$name . ':select']) && !empty($vars[$this->settings[$name . ':select']])) { + return $vars[$this->settings[$name . ':select']]; + } + elseif (!empty($this->settings[$name . ':select'])) { + if ($wrapper = $this->applyDataSelector($this->settings[$name . ':select'])) { + return $wrapper->info(); + } + return; + } + elseif (isset($this->settings[$name . ':type'])) { + return array('type' => $this->settings[$name . ':type']); + } + elseif (!isset($this->settings[$name]) && isset($vars[$name])) { + return $vars[$name]; + } + } + + /** + * Saves the configuration to the database. + * + * The configuration is saved regardless whether this method is invoked on + * the rules configuration or a contained rule element. + */ + public function save($name = NULL, $module = 'rules') { + if (isset($this->parent)) { + $this->parent->sortChildren(); + return $this->parent->save($name, $module); + } + else { + // Update the dirty flag before saving. + // However, this operation depends on a fully built Rules-cache, so skip + // it when entities in code are imported to the database. + // @see _rules_rebuild_cache() + if (empty($this->is_rebuild)) { + rules_config_update_dirty_flag($this, FALSE); + // In case the config is not dirty, pre-calculate the dependencies for + // later checking. Note that this also triggers processing settings if + // necessary. + // @see rules_modules_enabled() + if (empty($this->dirty)) { + $this->dependencies = $this->dependencies(); + } + } + + $this->plugin = $this->itemName; + $this->name = isset($name) ? $name : $this->name; + // Module stores the module via which the rule is configured and is used + // for generating machine names with the right prefix. However, for + // default configurations 'module' points to the module providing the + // default configuration, so the module via which the rules is configured + // is stored in the "owner" property. + // @todo For Drupal 8 use "owner" for generating machine names also and + // module only for the modules providing default configurations. + $this->module = !isset($this->module) || $module != 'rules' ? $module : $this->module; + if (!isset($this->owner)) { + $this->owner = 'rules'; + } + $this->ensureNameExists(); + $this->data = $this; + $return = entity_get_controller('rules_config')->save($this); + unset($this->data); + + // Care about clearing necessary caches. + if (!empty($this->is_rebuild)) { + rules_clear_cache(); + } + else { + $plugin_info = $this->pluginInfo(); + if (!empty($plugin_info['component'])) { + // When component variables changes rebuild the complete cache so the + // changes to the provided action/condition take affect. + if (empty($this->original) || $this->componentVariables() != $this->original->componentVariables()) { + rules_clear_cache(); + } + // Clear components cached for evaluation. + cache_clear_all('comp_', 'cache_rules', TRUE); + } + elseif ($this->plugin == 'reaction rule') { + // Clear event sets cached for evaluation. + cache_clear_all('event_', 'cache_rules', TRUE); + // Clear event whitelist for rebuild. + cache_clear_all('rules_event_whitelist', 'cache_rules', TRUE); + } + drupal_static_reset('rules_get_cache'); + drupal_static_reset('rules_config_update_dirty_flag'); + } + + return $return; + } + } + + /** + * Ensure the configuration has a name. If not, generate one. + */ + protected function ensureNameExists() { + if (!isset($this->module)) { + $this->module = 'rules'; + } + if (!isset($this->name)) { + // Find a unique name for this configuration. + $this->name = $this->module . '_'; + for ($i = 0; $i < 8; $i++) { + // Alphanumeric name generation. + $rnd = mt_rand(97, 122); + $this->name .= chr($rnd); + } + } + } + + public function __sleep() { + // Keep the id always as we need it for the recursion prevention. + $array = drupal_map_assoc(array('parent', 'id', 'elementId', 'weight', 'settings')); + // Keep properties related to configurations if they are there. + $info = entity_get_info('rules_config'); + $fields = array_merge($info['schema_fields_sql']['base table'], array('recursion', 'tags')); + foreach ($fields as $key) { + if (isset($this->$key)) { + $array[$key] = $key; + } + } + return $array; + } + + /** + * Optimizes a rule configuration in order to speed up evaluation. + * + * Additional optimization methods may be inserted by an extender + * implementing the RulesOptimizationInterface. By default, there is no + * optimization extender. + * + * An optimization method may rearrange the internal structure of a + * configuration in order to speed up the evaluation. As the configuration may + * change optimized configurations should not be saved permanently, except + * when saving it temporary, for later execution only. + * + * @see RulesOptimizationInterface + */ + public function optimize() { + // Make sure settings are processed before configs are cached. + $this->processSettings(); + if ($this->facesAs('RulesOptimizationInterface')) { + $this->__call('optimize'); + } + } + + /** + * Deletes configuration from database. + * + * If invoked on a rules configuration it is deleted from database. If + * invoked on a contained rule element, it's removed from the configuration. + */ + public function delete() { + if (isset($this->parent)) { + foreach ($this->parent->children as $key => $child) { + if ($child === $this) { + unset($this->parent->children[$key]); + break; + } + } + } + elseif (isset($this->id)) { + entity_get_controller('rules_config')->delete(array($this->name)); + rules_clear_cache(); + } + } + + public function internalIdentifier() { + return isset($this->id) ? $this->id : NULL; + } + + /** + * Returns the config name. + */ + public function identifier() { + return isset($this->name) ? $this->name : NULL; + } + + public function entityInfo() { + return entity_get_info('rules_config'); + } + + public function entityType() { + return 'rules_config'; + } + + /** + * Checks if the configuration has a certain exportable status. + * + * @param $status + * A status constant, i.e. one of ENTITY_CUSTOM, ENTITY_IN_CODE, + * ENTITY_OVERRIDDEN or ENTITY_FIXED. + * + * @return bool + * TRUE if the configuration has the status, else FALSE. + * + * @see entity_has_status() + */ + public function hasStatus($status) { + return $this->isRoot() && isset($this->status) && ($this->status & $status) == $status; + } + + /** + * Removes circular object references so PHP garbage collector can work. + */ + public function destroy() { + parent::destroy(); + $this->parent = NULL; + } + + /** + * Seamlessly invokes the method implemented via faces. + * + * Frees the caller from having to think about references. + */ + public function form(&$form, &$form_state, array $options = array()) { + $this->__call('form', array(&$form, &$form_state, $options)); + } + + public function form_validate($form, &$form_state) { + $this->__call('form_validate', array($form, &$form_state)); + } + + public function form_submit($form, &$form_state) { + $this->__call('form_submit', array($form, &$form_state)); + } + + /** + * Returns the label of the element. + */ + public function label() { + if (!empty($this->label) && $this->label != t('unlabeled')) { + return $this->label; + } + $info = $this->info(); + return isset($info['label']) ? $info['label'] : (!empty($this->name) ? $this->name : t('unlabeled')); + } + + /** + * Returns the name of the element's plugin. + */ + public function plugin() { + return $this->itemName; + } + + /** + * Returns info about the element's plugin. + */ + public function pluginInfo() { + $this->forceSetUp(); + return $this->itemInfo; + } + + /** + * Applies the given export. + */ + public function import(array $export) { + $this->importSettings($export[strtoupper($this->plugin())]); + } + + protected function importSettings($export) { + // Import parameter settings. + $export += array('USING' => array(), 'PROVIDE' => array()); + foreach ($export['USING'] as $name => $param_export) { + $this->importParameterSetting($name, $param_export); + } + foreach ($export['PROVIDE'] as $name => $var_export) { + // The key of $var_export is the variable name, the value the label. + $this->settings[$name . ':var'] = rules_array_key($var_export); + $this->settings[$name . ':label'] = reset($var_export); + } + } + + protected function importParameterSetting($name, $export) { + if (is_array($export) && isset($export['select'])) { + $this->settings[$name . ':select'] = $export['select']; + if (count($export) > 1) { + // Add in processor settings. + unset($export['select']); + $this->settings[$name . ':process'] = $export; + } + } + // Convert back the [selector] strings being an array with one entry. + elseif (is_array($export) && count($export) == 1 && isset($export[0])) { + $this->settings[$name . ':select'] = $export[0]; + } + elseif (is_array($export) && isset($export['value'])) { + $this->settings[$name] = $export['value']; + } + else { + $this->settings[$name] = $export; + } + } + + /** + * Exports a rule configuration. + * + * @param string $prefix + * An optional prefix for each line. + * @param bool $php + * (optional) Set to TRUE to format the export using PHP arrays. By default + * JSON is used. + * + * @return + * The exported configuration. + * + * @see rules_import() + */ + public function export($prefix = '', $php = FALSE) { + $export = $this->exportToArray(); + return $this->isRoot() ? $this->returnExport($export, $prefix, $php) : $export; + } + + protected function exportToArray() { + $export[strtoupper($this->plugin())] = $this->exportSettings(); + return $export; + } + + protected function exportSettings() { + $export = array(); + if (!$this->isRoot()) { + foreach ($this->pluginParameterInfo() as $name => $info) { + if (($return = $this->exportParameterSetting($name, $info)) !== NULL) { + $export['USING'][$name] = $return; + } + } + foreach ($this->providesVariables() as $name => $info) { + if (!empty($info['source name'])) { + $export['PROVIDE'][$info['source name']][$name] = $info['label']; + } + } + } + return $export; + } + + protected function exportParameterSetting($name, $info) { + if (isset($this->settings[$name]) && (empty($info['optional']) || !isset($info['default value']) || $this->settings[$name] != $info['default value'])) { + // In case of an array-value wrap the value into another array, such that + // the value cannot be confused with an exported data selector. + return is_array($this->settings[$name]) ? array('value' => $this->settings[$name]) : $this->settings[$name]; + } + elseif (isset($this->settings[$name . ':select'])) { + if (isset($this->settings[$name . ':process']) && $processor = $this->settings[$name . ':process']) { + $export['select'] = $this->settings[$name . ':select']; + $export += $processor instanceof RulesDataProcessor ? $processor->getChainSettings() : $processor; + return $export; + } + // If there is no processor use a simple array to abbreviate this usual + // case. In JSON this turns to a nice [selector] string. + return array($this->settings[$name . ':select']); + } + } + + /** + * Finalizes the configuration export. + * + * Adds general attributes regarding the configuration and returns it in the + * right format for export. + * + * @param $export + * @param string $prefix + * An optional prefix for each line. + * @param bool $php + * (optional) Set to TRUE to format the export using PHP arrays. By default + * JSON is used. + */ + protected function returnExport($export, $prefix = '', $php = FALSE) { + $this->ensureNameExists(); + if (!empty($this->label) && $this->label != t('unlabeled')) { + $export_cfg[$this->name]['LABEL'] = $this->label; + } + $export_cfg[$this->name]['PLUGIN'] = $this->plugin(); + if (!empty($this->weight)) { + $export_cfg[$this->name]['WEIGHT'] = $this->weight; + } + if (isset($this->active) && !$this->active) { + $export_cfg[$this->name]['ACTIVE'] = FALSE; + } + if (!empty($this->owner)) { + $export_cfg[$this->name]['OWNER'] = $this->owner; + } + if (!empty($this->tags)) { + $export_cfg[$this->name]['TAGS'] = $this->tags; + } + if ($modules = $this->dependencies()) { + $export_cfg[$this->name]['REQUIRES'] = $modules; + } + if (!empty($this->access_exposed)) { + $export_cfg[$this->name]['ACCESS_EXPOSED'] = $this->access_exposed; + }; + $export_cfg[$this->name] += $export; + return $php ? entity_var_export($export_cfg, $prefix) : entity_var_json_export($export_cfg, $prefix); + } + + /** + * Resets any internal static caches. + * + * This function does not reset regular caches as retrieved via + * rules_get_cache(). Usually, it's invoked automatically when a Rules + * configuration is modified. + * + * Static caches are reset for the element and any elements down the tree. To + * clear static caches of the whole configuration, invoke the function at the + * root. + * + * @see RulesPlugin::availableVariables() + */ + public function resetInternalCache() { + $this->availableVariables = NULL; + } + +} + +/** + * Defines a common base class for so-called "Abstract Plugins" like actions. + * + * Modules have to provide the concrete plugin implementation. + */ +abstract class RulesAbstractPlugin extends RulesPlugin { + + protected $elementName; + protected $info = array('parameter' => array(), 'provides' => array()); + protected $infoLoaded = FALSE; + + /** + * @param string $name + * The plugin implementation's name. + * @param $settings + * (optional) Further information provided about the plugin. + * @throws RulesException + * If validation of the passed settings fails RulesExceptions are thrown. + */ + public function __construct($name = NULL, $settings = array()) { + $this->elementName = $name; + $this->settings = (array) $settings + array('#_needs_processing' => TRUE); + $this->setUp(); + } + + protected function setUp() { + parent::setUp(); + if (isset($this->cache[$this->itemName . '_info'][$this->elementName])) { + $this->info = $this->cache[$this->itemName . '_info'][$this->elementName]; + // Remember that the info has been correctly setup. + // @see self::forceSetup() + $this->infoLoaded = TRUE; + + // Register the defined class, if any. + if (isset($this->info['class'])) { + $this->faces['RulesPluginImplInterface'] = 'RulesPluginImplInterface'; + $face_methods = get_class_methods('RulesPluginImplInterface'); + $class_info = array(1 => $this->info['class']); + foreach ($face_methods as $method) { + $this->facesMethods[$method] = $class_info; + } + } + // Add in per-plugin implementation callbacks if any. + if (!empty($this->info['faces_cache'])) { + foreach ($this->info['faces_cache'] as $face => $data) { + list($methods, $file_names) = $data; + foreach ($methods as $method => $callback) { + $this->facesMethods[$method] = $callback; + } + foreach ((array) $file_names as $method => $name) { + $this->facesIncludes[$method] = array('module' => $this->info['module'], 'name' => $name); + } + } + // Invoke the info_alter callback, but only if it has been implemented. + if ($this->facesMethods['info_alter'] != $this->itemInfo['faces_cache'][0]['info_alter']) { + $this->__call('info_alter', array(&$this->info)); + } + } + } + elseif (!empty($this->itemInfo['faces_cache']) && function_exists($this->elementName)) { + // We don't have any info, so just add the name as execution callback. + $this->override(array('execute' => $this->elementName)); + } + } + + public function forceSetUp() { + if (!isset($this->cache) || (!empty($this->itemInfo['faces_cache']) && !$this->faces)) { + $this->setUp(); + } + // In case we have element specific information, which is not loaded yet, + // do so now. This might happen if the element has been initially loaded + // with an incomplete cache, i.e. during cache rebuilding. + elseif (!$this->infoLoaded && isset($this->cache[$this->itemName . '_info'][$this->elementName])) { + $this->setUp(); + } + } + + /** + * Returns the label of the element. + */ + public function label() { + $info = $this->info(); + return isset($info['label']) ? $info['label'] : t('@plugin "@name"', array('@name' => $this->elementName, '@plugin' => $this->plugin())); + } + + public function access() { + $info = $this->info(); + $this->loadBasicInclude(); + if (!empty($info['access callback']) && !call_user_func($info['access callback'], $this->itemName, $this->getElementName())) { + return FALSE; + } + return parent::access() && $this->__call('access'); + } + + public function integrityCheck() { + // Do the usual integrity check first so the implementation's validation + // handler can rely on that already. + parent::integrityCheck(); + // Make sure the element is known. + $this->forceSetUp(); + if (!isset($this->cache[$this->itemName . '_info'][$this->elementName])) { + throw new RulesIntegrityException(t('Unknown @plugin %name.', array('@plugin' => $this->plugin(), '%name' => $this->elementName))); + } + $this->validate(); + return $this; + } + + public function processSettings($force = FALSE) { + // Process if not done yet. + if ($force || !empty($this->settings['#_needs_processing'])) { + $this->resetInternalCache(); + // In case the element implements the info alteration callback, (re-)run + // the alteration so that any settings depending info alterations are + // applied. + if ($this->facesMethods && $this->facesMethods['info_alter'] != $this->itemInfo['faces_cache'][0]['info_alter']) { + $this->__call('info_alter', array(&$this->info)); + } + // First let the plugin implementation do processing, so data types of the + // parameters are fixed when we process the settings. + $this->process(); + parent::processSettings($force); + } + } + + public function pluginParameterInfo() { + // Ensure the info alter callback has been executed. + $this->forceSetup(); + return parent::pluginParameterInfo(); + } + + public function pluginProvidesVariables() { + // Ensure the info alter callback has been executed. + $this->forceSetup(); + return parent::pluginProvidesVariables(); + } + + public function info() { + // Ensure the info alter callback has been executed. + $this->forceSetup(); + return $this->info; + } + + protected function variableInfoAssertions() { + // Get the implementation's assertions and map them to the variable names. + if ($assertions = $this->__call('assertions')) { + foreach ($assertions as $param_name => $data) { + $name = isset($this->settings[$param_name . ':select']) ? $this->settings[$param_name . ':select'] : $param_name; + $return[$name] = $data; + } + return $return; + } + } + + public function import(array $export) { + // The key is the element name and the value the actual export. + $this->elementName = rules_array_key($export); + $export = reset($export); + + // After setting the element name, setup the element again so the right + // element info is loaded. + $this->setUp(); + + if (!isset($export['USING']) && !isset($export['PROVIDES']) && !empty($export)) { + // The export has been abbreviated to skip "USING". + $export = array('USING' => $export); + } + $this->importSettings($export); + } + + protected function exportToArray() { + $export = $this->exportSettings(); + if (!$this->providesVariables()) { + // Abbreviate the export making "USING" implicit. + $export = isset($export['USING']) ? $export['USING'] : array(); + } + return array($this->elementName => $export); + } + + public function dependencies() { + $modules = array_flip(parent::dependencies()); + $modules += array_flip((array) $this->__call('dependencies')); + return array_keys($modules + (!empty($this->info['module']) ? array($this->info['module'] => 1) : array())); + } + + public function executeByArgs($args = array()) { + $replacements = array('%label' => $this->label(), '@plugin' => $this->itemName); + rules_log('Executing @plugin %label.', $replacements, RulesLog::INFO, $this, TRUE); + $this->processSettings(); + // If there is no element info, just pass through the passed arguments. + // That way we support executing actions without any info at all. + if ($this->info()) { + $state = $this->setUpState($args); + module_invoke_all('rules_config_execute', $this); + + $result = $this->evaluate($state); + $return = $this->returnVariables($state, $result); + } + else { + rules_log('Unable to execute @plugin %label.', $replacements, RulesLog::ERROR, $this); + } + $state->cleanUp(); + rules_log('Finished executing of @plugin %label.', $replacements, RulesLog::INFO, $this, FALSE); + return $return; + } + + /** + * Execute the configured execution callback and log that. + */ + abstract protected function executeCallback(array $args, RulesState $state = NULL); + + public function evaluate(RulesState $state) { + $this->processSettings(); + try { + // Get vars as needed for execute and call it. + return $this->executeCallback($this->getExecutionArguments($state), $state); + } + catch (RulesEvaluationException $e) { + rules_log($e->msg, $e->args, $e->severity); + rules_log('Unable to evaluate %name.', array('%name' => $this->getPluginName()), RulesLog::WARN, $this); + } + // Catch wrapper exceptions that might occur due to failures loading an + // entity or similar. + catch (EntityMetadataWrapperException $e) { + rules_log('Unable to get a data value. Error: !error', array('!error' => $e->getMessage()), RulesLog::WARN); + rules_log('Unable to evaluate %name.', array('%name' => $this->getPluginName()), RulesLog::WARN, $this); + } + } + + public function __sleep() { + return parent::__sleep() + array('elementName' => 'elementName'); + } + + public function getPluginName() { + return $this->itemName . " " . $this->elementName; + } + + /** + * Gets the name of the configured action or condition. + */ + public function getElementName() { + return $this->elementName; + } + + /** + * Add in the data provided by the info hooks to the cache. + */ + public function rebuildCache(&$itemInfo, &$cache) { + parent::rebuildCache($itemInfo, $cache); + + // Include all declared files so we can find all implementations. + self::includeFiles(); + + // Get the plugin's own info data. + $cache[$this->itemName . '_info'] = rules_fetch_data($this->itemName . '_info'); + foreach ($cache[$this->itemName . '_info'] as $name => &$info) { + $info += array( + 'parameter' => isset($info['arguments']) ? $info['arguments'] : array(), + 'provides' => isset($info['new variables']) ? $info['new variables'] : array(), + 'base' => $name, + 'callbacks' => array(), + ); + unset($info['arguments'], $info['new variables']); + + if (function_exists($info['base'])) { + $info['callbacks'] += array('execute' => $info['base']); + } + + // We do not need to build a faces cache for RulesPluginHandlerInterface, + // which gets added in automatically as its a parent of + // RulesPluginImplInterface. + unset($this->faces['RulesPluginHandlerInterface']); + + // Build up the per-plugin implementation faces cache. + foreach ($this->faces as $interface) { + $methods = $file_names = array(); + $includes = self::getIncludeFiles($info['module']); + + foreach (get_class_methods($interface) as $method) { + if (isset($info['callbacks'][$method]) && ($function = $info['callbacks'][$method])) { + $methods[$method][0] = $function; + $file_names[$method] = $this->getFileName($function, $includes); + } + // Note that this skips RulesPluginImplInterface, which is not + // implemented by plugin handlers. + elseif (isset($info['class']) && is_subclass_of($info['class'], $interface)) { + $methods[$method][1] = $info['class']; + } + elseif (function_exists($function = $info['base'] . '_' . $method)) { + $methods[$method][0] = $function; + $file_names[$method] = $this->getFileName($function, $includes); + } + } + // Cache only the plugin implementation specific callbacks. + $info['faces_cache'][$interface] = array($methods, array_filter($file_names)); + } + // Filter out interfaces with no overridden methods. + $info['faces_cache'] = rules_filter_array($info['faces_cache'], 0, TRUE); + // We don't need that any more. + unset($info['callbacks'], $info['base']); + } + } + + /** + * Loads this module's .rules.inc file. + * + * Makes sure the providing modules' .rules.inc file is included, as diverse + * callbacks may reside in that file. + */ + protected function loadBasicInclude() { + static $included = array(); + + if (isset($this->info['module']) && !isset($included[$this->info['module']])) { + $module = $this->info['module']; + module_load_include('inc', $module, $module . '.rules'); + $included[$module] = TRUE; + } + } + + /** + * Makes sure all supported destinations are included. + */ + public static function includeFiles() { + static $included; + + if (!isset($included)) { + foreach (module_implements('rules_file_info') as $module) { + // rules.inc are already included thanks to the rules_hook_info() group. + foreach (self::getIncludeFiles($module, FALSE) as $name) { + module_load_include('inc', $module, $name); + } + } + $dirs = array(); + foreach (module_implements('rules_directory') as $module) { + // Include all files once, so the discovery can find them. + $result = module_invoke($module, 'rules_directory'); + if (!is_array($result)) { + $result = array($module => $result); + } + $dirs += $result; + } + foreach ($dirs as $module => $directory) { + $module_path = drupal_get_path('module', $module); + foreach (array('inc', 'php') as $extension) { + foreach (glob("$module_path/$directory/*.$extension") as $filename) { + include_once $filename; + } + } + } + $included = TRUE; + } + } + + /** + * Returns all include files for a module. + * + * @param string $module + * The module name. + * @param bool $all + * If FALSE, the $module.rules.inc file isn't added. + * + * @return string[] + * An array containing the names of all the include files for a module. + */ + protected static function getIncludeFiles($module, $all = TRUE) { + $files = (array) module_invoke($module, 'rules_file_info'); + // Automatically add "$module.rules_forms.inc" and "$module.rules.inc". + $files[] = $module . '.rules_forms'; + if ($all) { + $files[] = $module . '.rules'; + } + return $files; + } + + protected function getFileName($function, $includes) { + static $filenames; + if (!isset($filenames) || !array_key_exists($function, $filenames)) { + $filenames[$function] = NULL; + $reflector = new ReflectionFunction($function); + // On windows the path contains backslashes instead of slashes, fix that. + $file = str_replace('\\', '/', $reflector->getFileName()); + foreach ($includes as $include) { + $pos = strpos($file, $include . '.inc'); + // Test whether the file ends with the given filename.inc. + if ($pos !== FALSE && strlen($file) - $pos == strlen($include) + 4) { + $filenames[$function] = $include; + return $include; + } + } + } + return $filenames[$function]; + } + +} + +/** + * Interface for objects that can be used as actions. + */ +interface RulesActionInterface { + + /** + * @return + * As specified. + * + * @throws RulesEvaluationException + * Throws an exception if not all necessary arguments have been provided. + */ + public function execute(); + +} + +/** + * Interface for objects that can be used as conditions. + */ +interface RulesConditionInterface { + + /** + * @return bool + * + * @throws RulesEvaluationException + * Throws an exception if not all necessary arguments have been provided. + */ + public function execute(); + + /** + * Negate the result. + */ + public function negate($negate = TRUE); + + /** + * Returns whether the element is configured to negate the result. + */ + public function isNegated(); + +} + +/** + * Interface for objects that are triggerable. + */ +interface RulesTriggerableInterface { + + /** + * Returns the array of (configured) event names associated with this object. + */ + public function events(); + + /** + * Removes an event from the rule configuration. + * + * @param string $event_name + * The name of the (configured) event to remove. + * + * @return RulesTriggerableInterface + * The object instance itself, to allow chaining. + */ + public function removeEvent($event_name); + + /** + * Adds the specified event. + * + * @param string $event_name + * The base name of the event to add. + * @param array $settings + * (optional) The event settings. If there are no event settings, pass an + * empty array (default). + * + * @return RulesTriggerableInterface + */ + public function event($event_name, array $settings = array()); + + /** + * Gets the event settings associated with the given (configured) event. + * + * @param string $event_name + * The (configured) event's name. + * + * @return array|null + * The array of event settings, or NULL if there are no settings. + */ + public function getEventSettings($event_name); + +} + +/** + * Provides the base interface for implementing abstract plugins via classes. + */ +interface RulesPluginHandlerInterface { + + /** + * Validates $settings independent from a form submission. + * + * @throws RulesIntegrityException + * In case of validation errors, RulesIntegrityExceptions are thrown. + */ + public function validate(); + + /** + * Processes settings independent from a form submission. + * + * Processing results may be stored and accessed on execution time + * in $settings. + */ + public function process(); + + /** + * Allows altering of the element's action/condition info. + * + * Note that this method is also invoked on evaluation time, thus any costly + * operations should be avoided. + * + * @param $element_info + * A reference on the element's info as returned by RulesPlugin::info(). + */ + public function info_alter(&$element_info); + + /** + * Checks whether the user has access to configure this element. + * + * Note that this only covers access for already created elements. In order to + * control access for creating or using elements specify an 'access callback' + * in the element's info array. + * + * @see hook_rules_action_info() + */ + public function access(); + + /** + * Returns an array of required modules. + */ + public function dependencies(); + + /** + * Alters the generated configuration form of the element. + * + * Validation and processing of the settings should be untied from the form + * and implemented in validate() and process() wherever it makes sense. + * For the remaining cases where form tied validation and processing is needed + * make use of the form API #element_validate and #value_callback properties. + */ + public function form_alter(&$form, $form_state, $options); + + /** + * Returns an array of info assertions for the specified parameters. + * + * This allows conditions to assert additional metadata, such as info about + * the fields of a bundle. + * + * @see RulesPlugin::variableInfoAssertions() + */ + public function assertions(); + +} + +/** + * Interface for implementing conditions via classes. + * + * In addition to the interface an execute() and a static getInfo() method must + * be implemented. The static getInfo() method has to return the info as + * returned by hook_rules_condition_info() but including an additional 'name' + * key, specifying the plugin name. + * The execute method is the equivalent to the usual execution callback and + * gets the parameters passed as specified in the info array. + * + * See RulesNodeConditionType for an example and rules_discover_plugins() + * for information about class discovery. + */ +interface RulesConditionHandlerInterface extends RulesPluginHandlerInterface {} + +/** + * Interface for implementing actions via classes. + * + * In addition to the interface an execute() and a static getInfo() method must + * be implemented. The static getInfo() method has to return the info as + * returned by hook_rules_action_info() but including an additional 'name' key, + * specifying the plugin name. + * The execute method is the equivalent to the usual execution callback and + * gets the parameters passed as specified in the info array. + * + * See RulesNodeConditionType for an example and rules_discover_plugins() + * for information about class discovery. + */ +interface RulesActionHandlerInterface extends RulesPluginHandlerInterface {} + +/** + * Interface used for implementing an abstract plugin via Faces. + * + * Provides the interface used for implementing an abstract plugin by using + * the Faces extension mechanism. + */ +interface RulesPluginImplInterface extends RulesPluginHandlerInterface { + + /** + * Executes the action or condition making use of the parameters as specified. + */ + public function execute(); + +} + +/** + * Interface for optimizing evaluation. + * + * @see RulesContainerPlugin::optimize() + */ +interface RulesOptimizationInterface { + + /** + * Optimizes a rule configuration in order to speed up evaluation. + */ + public function optimize(); + +} + +/** + * Base class for implementing abstract plugins via classes. + */ +abstract class RulesPluginHandlerBase extends FacesExtender implements RulesPluginHandlerInterface { + + /** + * @var RulesAbstractPlugin + */ + protected $element; + + /** + * Overridden to provide $this->element to make the code more meaningful. + */ + public function __construct(FacesExtendable $object) { + $this->object = $object; + $this->element = $object; + } + + /** + * Implements RulesPluginImplInterface::access(). + */ + public function access() { + return TRUE; + } + + public function validate() {} + + public function process() {} + + public function info_alter(&$element_info) {} + + public function dependencies() {} + + public function form_alter(&$form, $form_state, $options) {} + + public function assertions() {} + +} + +/** + * Base class for implementing conditions via classes. + */ +abstract class RulesConditionHandlerBase extends RulesPluginHandlerBase implements RulesConditionHandlerInterface {} + +/** + * Base class for implementing actions via classes. + */ +abstract class RulesActionHandlerBase extends RulesPluginHandlerBase implements RulesActionHandlerInterface {} + +/** + * Provides default implementations of all RulesPluginImplInterface methods. + * + * If a plugin implementation does not provide a function for a method, the + * default method of this class will be invoked. + * + * @see RulesPluginImplInterface + * @see RulesAbstractPlugin + */ +class RulesAbstractPluginDefaults extends RulesPluginHandlerBase implements RulesPluginImplInterface { + + public function execute() { + throw new RulesEvaluationException($this->object->getPluginName() . ": Execution implementation is missing.", array(), $this->object, RulesLog::ERROR); + } + +} + +/** + * A RecursiveIterator for rule elements. + */ +class RulesRecursiveElementIterator extends ArrayIterator implements RecursiveIterator { + + public function getChildren() { + return $this->current()->getIterator(); + } + + public function hasChildren() { + return $this->current() instanceof IteratorAggregate; + } + +} + +/** + * Base class for ContainerPlugins like Rules, Logical Operations or Loops. + */ +abstract class RulesContainerPlugin extends RulesPlugin implements IteratorAggregate { + + protected $children = array(); + + public function __construct($variables = array()) { + $this->setUp(); + if (!empty($variables) && $this->isRoot()) { + $this->info['variables'] = $variables; + } + } + + /** + * Returns the specified variables, in case the plugin is used as component. + */ + public function &componentVariables() { + if ($this->isRoot()) { + $this->info += array('variables' => array()); + return $this->info['variables']; + } + // We have to return a reference in any case. + $return = NULL; + return $return; + } + + /** + * Allows access to the children through the iterator. + * + * @return RulesRecursiveElementIterator + */ + public function getIterator() { + return new RulesRecursiveElementIterator($this->children); + } + + /** + * @return RulesContainerPlugin + */ + public function integrityCheck() { + if (!empty($this->info['variables']) && !$this->isRoot()) { + throw new RulesIntegrityException(t('%plugin: Specifying state variables is not possible for child elements.', array('%plugin' => $this->getPluginName())), $this); + } + parent::integrityCheck(); + foreach ($this->children as $child) { + $child->integrityCheck(); + } + return $this; + } + + public function dependencies() { + $modules = array_flip(parent::dependencies()); + foreach ($this->children as $child) { + $modules += array_flip($child->dependencies()); + } + return array_keys($modules); + } + + public function parameterInfo($optional = FALSE) { + $params = parent::parameterInfo($optional); + if (isset($this->info['variables'])) { + foreach ($this->info['variables'] as $name => $var_info) { + if (empty($var_info['handler']) && (!isset($var_info['parameter']) || $var_info['parameter'])) { + $params[$name] = $var_info; + // For lists allow empty variables by default. + if (entity_property_list_extract_type($var_info['type'])) { + $params[$name] += array('allow null' => TRUE); + } + } + } + } + return $params; + } + + public function availableVariables() { + if (!isset($this->availableVariables)) { + if ($this->isRoot()) { + $this->availableVariables = RulesState::defaultVariables(); + if (isset($this->info['variables'])) { + $this->availableVariables += $this->info['variables']; + } + } + else { + $this->availableVariables = $this->parent->stateVariables($this); + } + } + return $this->availableVariables; + } + + /** + * Returns available state variables for an element. + * + * Returns info about variables available in the evaluation state for any + * children elements or if given for a special child element. + * + * @param $element + * The element for which the available state variables should be returned. + * If NULL is given, the variables available before any children are invoked + * are returned. If set to TRUE, the variables available after evaluating + * all children will be returned. + */ + protected function stateVariables($element = NULL) { + $vars = $this->availableVariables(); + if (isset($element)) { + // Add in variables provided by siblings executed before the element. + foreach ($this->children as $child) { + if ($child === $element) { + break; + } + $vars += $child->providesVariables(); + // Take variable info assertions into account. + if ($assertions = $child->variableInfoAssertions()) { + $vars = RulesData::addMetadataAssertions($vars, $assertions); + } + } + } + return $vars; + } + + protected function variableInfoAssertions() { + $assertions = array(); + foreach ($this->children as $child) { + if ($add = $child->variableInfoAssertions()) { + $assertions = rules_update_array($assertions, $add); + } + } + return $assertions; + } + + protected function setUpVariables() { + return isset($this->info['variables']) ? parent::parameterInfo(TRUE) + $this->info['variables'] : $this->parameterInfo(TRUE); + } + + /** + * Executes container with the given arguments. + * + * Condition containers just return a boolean while action containers return + * the configured provided variables as an array of variables. + */ + public function executeByArgs($args = array()) { + $replacements = array('%label' => $this->label(), '@plugin' => $this->itemName); + rules_log('Executing @plugin %label.', $replacements, RulesLog::INFO, $this, TRUE); + $this->processSettings(); + $state = $this->setUpState($args); + + // Handle recursion prevention. + if ($state->isBlocked($this)) { + return rules_log('Not evaluating @plugin %label to prevent recursion.', array('%label' => $this->label(), '@plugin' => $this->plugin()), RulesLog::INFO); + } + // Block the config to prevent any future recursion. + $state->block($this); + + module_invoke_all('rules_config_execute', $this); + $result = $this->evaluate($state); + $return = $this->returnVariables($state, $result); + + $state->unblock($this); + $state->cleanUp(); + rules_log('Finished executing of @plugin %label.', $replacements, RulesLog::INFO, $this, FALSE); + return $return; + } + + public function access() { + foreach ($this->children as $key => $child) { + if (!$child->access()) { + return FALSE; + } + } + return TRUE; + } + + public function destroy() { + foreach ($this->children as $key => $child) { + $child->destroy(); + } + parent::destroy(); + } + + /** + * By default we do a deep clone. + */ + public function __clone() { + parent::__clone(); + foreach ($this->children as $key => $child) { + $this->children[$key] = clone $child; + $this->children[$key]->parent = $this; + } + } + + /** + * Overrides delete to keep the children alive, if possible. + */ + public function delete($keep_children = TRUE) { + if (isset($this->parent) && $keep_children) { + foreach ($this->children as $child) { + $child->setParent($this->parent); + } + } + parent::delete(); + } + + public function __sleep() { + return parent::__sleep() + array('children' => 'children', 'info' => 'info'); + } + + /** + * Sorts all child elements by their weight. + * + * @param bool $deep + * If enabled a deep sort is performed, thus the whole element tree below + * this element is sorted. + */ + public function sortChildren($deep = FALSE) { + // Make sure the array order is kept in case two children have the same + // weight by ensuring later children would have higher weights. + foreach (array_values($this->children) as $i => $child) { + $child->weight += $i / 1000; + } + usort($this->children, array('RulesPlugin', 'compare')); + + // Fix up the weights afterwards to be unique integers. + foreach (array_values($this->children) as $i => $child) { + $child->weight = $i; + } + + if ($deep) { + foreach (new ParentIterator($this->getIterator()) as $child) { + $child->sortChildren(TRUE); + } + } + $this->resetInternalCache(); + } + + protected function exportChildren($key = NULL) { + $key = isset($key) ? $key : strtoupper($this->plugin()); + $export[$key] = array(); + foreach ($this->children as $child) { + $export[$key][] = $child->export(); + } + return $export; + } + + /** + * Determines whether the element should be exported in flat style. + * + * Flat style means that the export keys are written directly into the export + * array, whereas else the export is written into a sub-array. + */ + protected function exportFlat() { + // By default we always use flat style for plugins without any parameters + // or provided variables, as then only children have to be exported. E.g. + // this applies to the OR and AND plugins. + return $this->isRoot() || (!$this->pluginParameterInfo() && !$this->providesVariables()); + } + + protected function exportToArray() { + $export = array(); + if (!empty($this->info['variables'])) { + $export['USES VARIABLES'] = $this->info['variables']; + } + if ($this->exportFlat()) { + $export += $this->exportSettings() + $this->exportChildren(); + } + else { + $export[strtoupper($this->plugin())] = $this->exportSettings() + $this->exportChildren(); + } + return $export; + } + + public function import(array $export) { + if (!empty($export['USES VARIABLES'])) { + $this->info['variables'] = $export['USES VARIABLES']; + } + // Care for exports having the export array nested in a sub-array. + if (!$this->exportFlat()) { + $export = reset($export); + } + $this->importSettings($export); + $this->importChildren($export); + } + + protected function importChildren($export, $key = NULL) { + $key = isset($key) ? $key : strtoupper($this->plugin()); + foreach ($export[$key] as $child_export) { + $plugin = _rules_import_get_plugin(rules_array_key($child_export), $this instanceof RulesActionInterface ? 'action' : 'condition'); + $child = rules_plugin_factory($plugin); + $child->setParent($this); + $child->import($child_export); + } + } + + public function resetInternalCache() { + $this->availableVariables = NULL; + foreach ($this->children as $child) { + $child->resetInternalCache(); + } + } + + /** + * Overrides optimize(). + */ + public function optimize() { + parent::optimize(); + // Now let the children optimize itself. + foreach ($this as $element) { + $element->optimize(); + } + } + +} + +/** + * Base class for all action containers. + */ +abstract class RulesActionContainer extends RulesContainerPlugin implements RulesActionInterface { + + public function __construct($variables = array(), $providesVars = array()) { + parent::__construct($variables); + // The provided vars of a component are the names of variables, which should + // be provided to the caller. See rule(). + if ($providesVars) { + $this->info['provides'] = $providesVars; + } + } + + /** + * Adds an action to the container. + * + * Pass in either an instance of the RulesActionInterface or the arguments + * as needed by rules_action(). + * + * @return $this + */ + public function action($name, $settings = array()) { + $action = (is_object($name) && $name instanceof RulesActionInterface) ? $name : rules_action($name, $settings); + $action->setParent($this); + return $this; + } + + /** + * Evaluate, whereas by default new vars are visible in the parent's scope. + */ + public function evaluate(RulesState $state) { + foreach ($this->children as $action) { + $action->evaluate($state); + } + } + + public function pluginProvidesVariables() { + return array(); + } + + public function providesVariables() { + $provides = parent::providesVariables(); + if (isset($this->info['provides']) && $vars = $this->componentVariables()) { + // Determine the full variable info for the provided variables. Note that + // we only support providing variables list in the component vars. + $provides += array_intersect_key($vars, array_flip($this->info['provides'])); + } + return $provides; + } + + /** + * Returns an array of provided variable names. + * + * Returns an array of variable names, which are provided by passing through + * the provided variables of the children. + */ + public function &componentProvidesVariables() { + $this->info += array('provides' => array()); + return $this->info['provides']; + } + + protected function exportToArray() { + $export = parent::exportToArray(); + if (!empty($this->info['provides'])) { + $export['PROVIDES VARIABLES'] = $this->info['provides']; + } + return $export; + } + + public function import(array $export) { + parent::import($export); + if (!empty($export['PROVIDES VARIABLES'])) { + $this->info['provides'] = $export['PROVIDES VARIABLES']; + } + } + +} + +/** + * Base class for all condition containers. + */ +abstract class RulesConditionContainer extends RulesContainerPlugin implements RulesConditionInterface { + + protected $negate = FALSE; + + /** + * Adds a condition to the container. + * + * Pass in either an instance of the RulesConditionInterface or the arguments + * as needed by rules_condition(). + * + * @return $this + */ + public function condition($name, $settings = array()) { + $condition = (is_object($name) && $name instanceof RulesConditionInterface) ? $name : rules_condition($name, $settings); + $condition->setParent($this); + return $this; + } + + /** + * Negate this condition. + * + * @return RulesConditionContainer + */ + public function negate($negate = TRUE) { + $this->negate = (bool) $negate; + return $this; + } + + public function isNegated() { + return $this->negate; + } + + public function __sleep() { + return parent::__sleep() + array('negate' => 'negate'); + } + + /** + * Just return the condition container's result. + */ + protected function returnVariables(RulesState $state, $result = NULL) { + return $result; + } + + protected function exportChildren($key = NULL) { + $key = isset($key) ? $key : strtoupper($this->plugin()); + return parent::exportChildren($this->negate ? 'NOT ' . $key : $key); + } + + protected function importChildren($export, $key = NULL) { + $key = isset($key) ? $key : strtoupper($this->plugin()); + // Care for negated elements. + if (!isset($export[$key]) && isset($export['NOT ' . $key])) { + $this->negate = TRUE; + $key = 'NOT ' . $key; + } + parent::importChildren($export, $key); + } + + /** + * Overridden to exclude variable assertions of negated conditions. + */ + protected function stateVariables($element = NULL) { + $vars = $this->availableVariables(); + if (isset($element)) { + // Add in variables provided by siblings executed before the element. + foreach ($this->children as $child) { + if ($child === $element) { + break; + } + $vars += $child->providesVariables(); + // Take variable info assertions into account. + if (!$this->negate && !$child->isNegated() && ($assertions = $child->variableInfoAssertions())) { + $vars = RulesData::addMetadataAssertions($vars, $assertions); + } + } + } + return $vars; + } + +} + +/** + * The rules default logging class. + */ +class RulesLog { + + const INFO = 1; + const WARN = 2; + const ERROR = 3; + + static protected $logger; + + /** + * @return RulesLog + * Returns the rules logger instance. + */ + public static function logger() { + if (!isset(self::$logger)) { + $class = __CLASS__; + self::$logger = new $class(variable_get('rules_log_level', self::INFO)); + } + return self::$logger; + } + + protected $log = array(); + protected $logLevel; + protected $line = 0; + + /** + * This is a singleton. + */ + protected function __construct($logLevel = self::WARN) { + $this->logLevel = $logLevel; + } + + public function __clone() { + throw new Exception("Cannot clone the logger."); + } + + /** + * Logs a log message. + * + * @see rules_log() + */ + public function log($msg, $args = array(), $logLevel = self::INFO, $scope = NULL, $path = NULL) { + if ($logLevel >= $this->logLevel) { + $this->log[] = array($msg, $args, $logLevel, microtime(TRUE), $scope, $path); + } + } + + /** + * Gets an array of logged messages. + */ + public function get() { + return $this->log; + } + + /** + * Clears the logged messages. + */ + public function clear() { + $this->log = array(); + } + + /** + * Checks the log and throws an exception if there were any problems. + */ + public function checkLog($logLevel = self::WARN) { + foreach ($this->log as $entry) { + if ($entry[2] >= $logLevel) { + throw new Exception($this->render()); + } + } + } + + /** + * Checks the log for error messages. + * + * @param int $logLevel + * Lowest log level to return. Values lower than $logLevel will not be + * returned. + * + * @return bool + * Whether the an error has been logged. + */ + public function hasErrors($logLevel = self::WARN) { + foreach ($this->log as $entry) { + if ($entry[2] >= $logLevel) { + return TRUE; + } + } + return FALSE; + } + + /** + * Renders the whole log. + */ + public function render() { + $line = 0; + $output = array(); + while (isset($this->log[$line])) { + $vars['head'] = t($this->log[$line][0], $this->log[$line][1]); + $vars['log'] = $this->renderHelper($line); + $output[] = theme('rules_debug_element', $vars); + $line++; + } + return implode('', $output); + } + + /** + * Renders the log of one event invocation. + */ + protected function renderHelper(&$line = 0) { + $startTime = isset($this->log[$line][3]) ? $this->log[$line][3] : 0; + $output = array(); + while ($line < count($this->log)) { + if ($output && !empty($this->log[$line][4])) { + // The next entry stems from another evaluated set, add in its log + // messages here. + $vars['head'] = t($this->log[$line][0], $this->log[$line][1]); + if (isset($this->log[$line][5])) { + $vars['link'] = '[' . l(t('edit'), $this->log[$line][5]) . ']'; + } + $vars['log'] = $this->renderHelper($line); + $output[] = theme('rules_debug_element', $vars); + } + else { + $formatted_diff = round(($this->log[$line][3] - $startTime) * 1000, 3) . ' ms'; + $msg = $formatted_diff . ' ' . t($this->log[$line][0], $this->log[$line][1]); + if ($this->log[$line][2] >= RulesLog::WARN) { + $level = $this->log[$line][2] == RulesLog::WARN ? 'warn' : 'error'; + $msg = '' . $msg . ''; + } + if (isset($this->log[$line][5]) && !isset($this->log[$line][4])) { + $msg .= ' [' . l(t('edit'), $this->log[$line][5]) . ']'; + } + $output[] = $msg; + + if (isset($this->log[$line][4]) && !$this->log[$line][4]) { + // This was the last log entry of this set. + return theme('item_list', array('items' => $output)); + } + } + $line++; + } + return theme('item_list', array('items' => $output)); + } + +} + +/** + * A base exception class for Rules. + * + * This class can be used to catch all exceptions thrown by Rules, and it + * may be subclassed to describe more specific exceptions. + */ +abstract class RulesException extends Exception {} + +/** + * An exception that is thrown during evaluation. + * + * Messages are prepared to be logged to the watchdog, thus not yet translated. + * + * @see watchdog() + */ +class RulesEvaluationException extends RulesException { + + public $msg; + public $args; + public $severity; + public $element; + public $keys = array(); + + /** + * Constructor. + * + * @param string $msg + * The exception message containing placeholder as t(). + * @param array $args + * Replacement arguments such as for t(). + * @param $element + * The element of a configuration causing the exception or an array + * consisting of the element and keys specifying a setting value causing + * the exception. + * @param int $severity + * The RulesLog severity. Defaults to RulesLog::WARN. + */ + public function __construct($msg, array $args = array(), $element = NULL, $severity = RulesLog::WARN) { + $this->element = is_array($element) ? array_shift($element) : $element; + $this->keys = is_array($element) ? $element : array(); + $this->msg = $msg; + $this->args = $args; + $this->severity = $severity; + // If an error happened, run the integrity check on the rules configuration + // and mark it as dirty if it the check fails. + if ($severity == RulesLog::ERROR && isset($this->element)) { + $rules_config = $this->element->root(); + rules_config_update_dirty_flag($rules_config); + // If we discovered a broken configuration, exclude it in future. + if ($rules_config->dirty) { + rules_clear_cache(); + } + } + // @todo Fix _drupal_decode_exception() to use __toString() and override it. + $this->message = t($this->msg, $this->args); + } + +} + +/** + * Indicates the Rules configuration failed the integrity check. + * + * @see RulesPlugin::integrityCheck() + */ +class RulesIntegrityException extends RulesException { + + public $msg; + public $element; + public $keys = array(); + + /** + * Constructs a RulesIntegrityException object. + * + * @param string $msg + * The exception message, already translated. + * @param $element + * The element of a configuration causing the exception or an array + * consisting of the element and keys specifying a parameter or provided + * variable causing the exception, e.g. + * @code array($element, 'parameter', 'node') @endcode + */ + public function __construct($msg, $element = NULL) { + $this->element = is_array($element) ? array_shift($element) : $element; + $this->keys = is_array($element) ? $element : array(); + parent::__construct($msg); + } + +} + +/** + * An exception that is thrown for missing module dependencies. + */ +class RulesDependencyException extends RulesIntegrityException {} + +/** + * Determines the plugin to be used for importing a child element. + * + * @param string $key + * The key to look for, e.g. 'OR' or 'DO'. + * @param string $default + * The default to return if no special plugin can be found. + */ +function _rules_import_get_plugin($key, $default = 'action') { + $map = &drupal_static(__FUNCTION__); + if (!isset($map)) { + $cache = rules_get_cache(); + foreach ($cache['plugin_info'] as $name => $info) { + if (!empty($info['embeddable'])) { + $info += array('import keys' => array(strtoupper($name))); + foreach ($info['import keys'] as $k) { + $map[$k] = $name; + } + } + } + } + // Cut off any leading NOT from the key. + if (strpos($key, 'NOT ') === 0) { + $key = substr($key, 4); + } + if (isset($map[$key])) { + return $map[$key]; + } + return $default; +} diff --git a/includes/rules.event.inc b/includes/rules.event.inc new file mode 100644 index 0000000..f97dec1 --- /dev/null +++ b/includes/rules.event.inc @@ -0,0 +1,421 @@ +type, $node, $view_mode); + * @endcode + * If the event settings are optional, both events have to be invoked whereas + * usually the more general event is invoked last. E.g.: + * @code + * rules_invoke_event('node_view--' . $node->type, $node, $view_mode); + * rules_invoke_event('node_view', $node, $view_mode); + * @endcode + * + * Rules event handlers have to be declared using the 'class' key in + * hook_rules_event_info(), or may be discovered automatically, see + * rules_discover_plugins() for details. + * + * @see RulesEventHandlerBase + * @see RulesEventDefaultHandler + */ +interface RulesEventHandlerInterface { + + /** + * Constructs the event handler. + * + * @param string $event_name + * The base event string. + * @param array $info + * The event info of the given event. + */ + public function __construct($event_name, $info); + + /** + * Sets the event settings. + * + * @param array $settings + * An array of settings to set. + * + * @return RulesEventHandlerInterface + * The handler itself for chaining. + */ + public function setSettings(array $settings); + + /** + * Gets the event settings. + * + * @return array + * The array of settings. + */ + public function getSettings(); + + /** + * Returns an array of default settings. + * + * @return array + * The array of default settings. + */ + public function getDefaults(); + + /** + * Returns a user-facing summary of the settings. + * + * @return string + * The summary in HTML, i.e. properly escaped or filtered. + */ + public function summary(); + + /** + * Builds the event settings form. + * + * @param array $form_state + * An associative array containing the current state of the form. + * + * @return array + * The form structure. + */ + public function buildForm(array &$form_state); + + /** + * Validate the event settings independent from a form submission. + * + * @throws RulesIntegrityException + * In case of validation errors, RulesIntegrityExceptions are thrown. + */ + public function validate(); + + /** + * Extract the form values and update the event settings. + * + * @param array $form + * An associative array containing the structure of the form. + * @param array $form_state + * An associative array containing the current state of the form. + */ + public function extractFormValues(array &$form, array &$form_state); + + /** + * Returns the suffix to be added to the base event named based upon settings. + * + * If event settings are used, the event name Rules uses for the configured + * event is {EVENT_NAME}--{SUFFIX}. + * + * @return string + * The suffix string. Return an empty string for not appending a suffix. + */ + public function getEventNameSuffix(); + + /** + * Returns info about the variables provided by this event. + * + * @return array + * An array of provided variables, keyed by variable names and with the + * variable info array as value. + */ + public function availableVariables(); + + /** + * Returns the base name of the event the event handler belongs to. + * + * @return string + * The name of the event the event handler belongs to. + */ + public function getEventName(); + + /** + * Returns the info array of the event the event handler belongs to. + * + * @return string + * The info array of the event the event handler belongs to. + */ + public function getEventInfo(); + +} + +/** + * Interface for event dispatchers. + */ +interface RulesEventDispatcherInterface extends RulesEventHandlerInterface { + + /** + * Starts the event watcher. + */ + public function startWatching(); + + /** + * Stops the event watcher. + */ + public function stopWatching(); + + /** + * Returns whether the event dispatcher is currently active. + * + * @return bool + * TRUE if the event dispatcher is currently active, FALSE otherwise. + */ + public function isWatching(); + +} + +/** + * Base class for event handler. + */ +abstract class RulesEventHandlerBase implements RulesEventHandlerInterface { + + /** + * The event name. + * + * @var string + */ + protected $eventName; + + /** + * The event info. + * + * @var array + */ + protected $eventInfo; + + /** + * The event settings. + * + * @var array + */ + protected $settings = array(); + + /** + * Implements RulesEventHandlerInterface::__construct(). + */ + public function __construct($event_name, $info) { + $this->eventName = $event_name; + $this->eventInfo = $info; + $this->settings = $this->getDefaults(); + } + + /** + * Implements RulesEventHandlerInterface::getSettings(). + */ + public function getSettings() { + return $this->settings; + } + + /** + * Implements RulesEventHandlerInterface::setSettings(). + */ + public function setSettings(array $settings) { + $this->settings = $settings + $this->getDefaults(); + return $this; + } + + /** + * Implements RulesEventHandlerInterface::validate(). + */ + public function validate() { + // Nothing to check by default. + } + + /** + * Implements RulesEventHandlerInterface::extractFormValues(). + */ + public function extractFormValues(array &$form, array &$form_state) { + foreach ($this->getDefaults() as $key => $setting) { + $this->settings[$key] = isset($form_state['values'][$key]) ? $form_state['values'][$key] : $setting; + } + } + + /** + * Implements RulesEventHandlerInterface::availableVariables(). + */ + public function availableVariables() { + return isset($this->eventInfo['variables']) ? $this->eventInfo['variables'] : array(); + } + + /** + * Implements RulesEventHandlerInterface::getEventName(). + */ + public function getEventName() { + return $this->eventName; + } + + /** + * Implements RulesEventHandlerInterface::getEventInfo(). + */ + public function getEventInfo() { + return $this->eventInfo; + } + +} + +/** + * A handler for events having no settings. This is the default handler. + */ +class RulesEventDefaultHandler extends RulesEventHandlerBase { + + /** + * Implements RulesEventHandlerInterface::buildForm(). + */ + public function buildForm(array &$form_state) { + return array(); + } + + /** + * Implements RulesEventHandlerInterface::getConfiguredEventName(). + */ + public function getEventNameSuffix() { + return ''; + } + + /** + * Implements RulesEventHandlerInterface::summary(). + */ + public function summary() { + return check_plain($this->eventInfo['label']); + } + + /** + * Implements RulesEventHandlerInterface::getDefaults(). + */ + public function getDefaults() { + return array(); + } + + /** + * Implements RulesEventHandlerInterface::getSettings(). + */ + public function getSettings() { + return NULL; + } + +} + +/** + * Exposes the bundle of an entity as event setting. + */ +class RulesEventHandlerEntityBundle extends RulesEventHandlerBase { + + protected $entityType; + protected $entityInfo; + protected $bundleKey; + + /** + * Implements RulesEventHandlerInterface::__construct(). + */ + public function __construct($event_name, $info) { + parent::__construct($event_name, $info); + // Cut off the suffix, e.g. remove 'view' from node_view. + $this->entityType = implode('_', explode('_', $event_name, -1)); + $this->entityInfo = entity_get_info($this->entityType); + if (!$this->entityInfo) { + throw new InvalidArgumentException('Unsupported event name passed.'); + } + } + + /** + * Implements RulesEventHandlerInterface::summary(). + */ + public function summary() { + $bundle = &$this->settings['bundle']; + $bundle_label = isset($this->entityInfo['bundles'][$bundle]['label']) ? $this->entityInfo['bundles'][$bundle]['label'] : $bundle; + $suffix = isset($bundle) ? ' ' . t('of @bundle-key %name', array('@bundle-key' => $this->getBundlePropertyLabel(), '%name' => $bundle_label)) : ''; + return check_plain($this->eventInfo['label']) . $suffix; + } + + /** + * Implements RulesEventHandlerInterface::buildForm(). + */ + public function buildForm(array &$form_state) { + $form['bundle'] = array( + '#type' => 'select', + '#title' => t('Restrict by @bundle', array('@bundle' => $this->getBundlePropertyLabel())), + '#description' => t('If you need to filter for multiple values, either add multiple events or use the "Entity is of bundle" condition instead.'), + '#default_value' => $this->settings['bundle'], + '#empty_value' => '', + '#options' => array(), + ); + foreach ($this->entityInfo['bundles'] as $name => $bundle_info) { + $form['bundle']['#options'][$name] = $bundle_info['label']; + } + return $form; + } + + /** + * Returns the label to use for the bundle property. + * + * @return string + * The label to use for the bundle property. + */ + protected function getBundlePropertyLabel() { + return $this->entityInfo['entity keys']['bundle']; + } + + /** + * Implements RulesEventHandlerInterface::extractFormValues(). + */ + public function extractFormValues(array &$form, array &$form_state) { + $this->settings['bundle'] = !empty($form_state['values']['bundle']) ? $form_state['values']['bundle'] : NULL; + } + + /** + * Implements RulesEventHandlerInterface::validate(). + */ + public function validate() { + if ($this->settings['bundle'] && empty($this->entityInfo['bundles'][$this->settings['bundle']])) { + throw new RulesIntegrityException(t('The @bundle %bundle of %entity_type is not known.', + array( + '%bundle' => $this->settings['bundle'], + '%entity_type' => $this->entityInfo['label'], + '@bundle' => $this->getBundlePropertyLabel(), + )), array(NULL, 'bundle')); + } + } + + /** + * Implements RulesEventHandlerInterface::getConfiguredEventName(). + */ + public function getEventNameSuffix() { + return $this->settings['bundle']; + } + + /** + * Implements RulesEventHandlerInterface::getDefaults(). + */ + public function getDefaults() { + return array( + 'bundle' => NULL, + ); + } + + /** + * Implements RulesEventHandlerInterface::availableVariables(). + */ + public function availableVariables() { + $variables = $this->eventInfo['variables']; + if ($this->settings['bundle']) { + // Add the bundle to all variables of the entity type. + foreach ($variables as $name => $variable_info) { + if ($variable_info['type'] == $this->entityType) { + $variables[$name]['bundle'] = $this->settings['bundle']; + } + } + } + return $variables; + } + +} diff --git a/includes/rules.plugins.inc b/includes/rules.plugins.inc new file mode 100644 index 0000000..93162f5 --- /dev/null +++ b/includes/rules.plugins.inc @@ -0,0 +1,920 @@ + $this->label($this->elementName), + ), RulesLog::INFO, $this); + $return = $this->__call('execute', empty($this->info['named parameter']) ? $args : array($args)); + // Get the (partially) wrapped arguments. + $args = $state->currentArguments; + + if (is_array($return)) { + foreach ($return as $name => $data) { + // Add provided variables. + if (isset($this->info['provides'][$name])) { + $var_name = isset($this->settings[$name . ':var']) ? $this->settings[$name . ':var'] : $name; + if (!$state->varInfo($var_name)) { + $state->addVariable($var_name, $data, $this->info['provides'][$name]); + rules_log('Added the provided variable %name of type %type', array('%name' => $var_name, '%type' => $this->info['provides'][$name]['type']), RulesLog::INFO, $this); + if (!empty($this->info['provides'][$name]['save']) && $state->variables[$var_name] instanceof EntityMetadataWrapper) { + $state->saveChanges($var_name, $state->variables[$var_name]); + } + } + } + // Support updating variables by returning the values. + elseif (!isset($this->info['provides'][$name])) { + // Update the data value using the wrapper. + if (isset($args[$name]) && $args[$name] instanceof EntityMetadataWrapper) { + try { + $args[$name]->set($data); + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException('Unable to update the argument for parameter %name: %error', array('%name' => $name, '%error' => $e->getMessage()), $this); + } + } + elseif (array_key_exists($name, $args)) { + // Map back to the source variable name and update it. + $var_name = !empty($this->settings[$name . ':select']) ? str_replace('-', '_', $this->settings[$name . ':select']) : $name; + $state->variables[$var_name] = $data; + } + } + } + } + // Save parameters as defined in the parameter info. + if ($return !== FALSE) { + foreach ($this->info['parameter'] as $name => $info) { + if (!empty($info['save']) && $args[$name] instanceof EntityMetadataWrapper) { + if (isset($this->settings[$name . ':select'])) { + $state->saveChanges($this->settings[$name . ':select'], $args[$name]); + } + else { + // Wrapper has been configured via direct input, so just save. + rules_log('Saved argument of type %type for parameter %name.', array('%name' => $name, '%type' => $args[$name]->type())); + $args[$name]->save(); + } + } + } + } + } + +} + +/** + * Implements a rules condition. + */ +class RulesCondition extends RulesAbstractPlugin implements RulesConditionInterface { + + /** + * @var string + */ + protected $itemName = 'condition'; + + /** + * @var bool + */ + protected $negate = FALSE; + + public function providesVariables() { + return array(); + } + + public function negate($negate = TRUE) { + $this->negate = (bool) $negate; + return $this; + } + + public function isNegated() { + return $this->negate; + } + + protected function executeCallback(array $args, RulesState $state = NULL) { + $return = (bool) $this->__call('execute', empty($this->info['named parameter']) ? $args : array($args)); + $return = $this->negate ? !$return : $return; + rules_log('The condition %name evaluated to %bool', array( + '%name' => $this->label($this->elementName), + '%bool' => $return ? 'TRUE' : 'FALSE', + ), RulesLog::INFO, $this); + return $return; + } + + public function __sleep() { + return parent::__sleep() + array('negate' => 'negate'); + } + + /** + * Just return the boolean result. + */ + protected function returnVariables(RulesState $state, $result = NULL) { + return $result; + } + + protected function exportToArray() { + $not = $this->negate ? 'NOT ' : ''; + $export = $this->exportSettings(); + // Abbreviate the export making "USING" implicit. + return array($not . $this->elementName => isset($export['USING']) ? $export['USING'] : array()); + } + + public function import(array $export) { + $this->elementName = rules_array_key($export); + if (strpos($this->elementName, 'NOT ') === 0) { + $this->elementName = substr($this->elementName, 4); + $this->negate = TRUE; + } + // After setting the element name, setup the element again so the right + // element info is loaded. + $this->setUp(); + + // Re-add 'USING' which has been removed for abbreviation. + $this->importSettings(array('USING' => reset($export))); + } + + public function label() { + $label = parent::label(); + return $this->negate ? t('NOT !condition', array('!condition' => $label)) : $label; + } + +} + +/** + * An actual rule. + * + * Note: A rule also implements the RulesActionInterface (inherited). + */ +class Rule extends RulesActionContainer { + + protected $conditions = NULL; + + /** + * @var string + */ + protected $itemName = 'rule'; + + /** + * @var string + */ + public $label = 'unlabeled'; + + public function __construct($variables = array(), $providesVars = array()) { + parent::__construct($variables, $providesVars); + + // Initialize the conditions container. + if (!isset($this->conditions)) { + $this->conditions = rules_and(); + // Don't use setParent() to avoid having it added to the children. + $this->conditions->parent = $this; + } + } + + /** + * Gets an iterator over all contained conditions. + * + * Note that this iterator also implements the ArrayAccess interface. + * + * @return RulesRecursiveElementIterator + */ + public function conditions() { + return $this->conditions->getIterator(); + } + + /** + * Returns the "And" condition container, which contains all conditions of + * this rule. + * + * @return RulesAnd + */ + public function conditionContainer() { + return $this->conditions; + } + + public function __sleep() { + return parent::__sleep() + drupal_map_assoc(array('conditions', 'label')); + } + + /** + * Gets an iterator over all contained actions. + * + * Note that this iterator also implements the ArrayAccess interface. + * + * @return RulesRecursiveElementIterator + */ + public function actions() { + return parent::getIterator(); + } + + /** + * Adds a condition. + * + * Pass either an instance of the RulesConditionInterface or the arguments as + * needed by rules_condition(). + * + * @return $this + */ + public function condition($name, $settings = array()) { + $this->conditions->condition($name, $settings); + return $this; + } + + public function sortChildren($deep = FALSE) { + $this->conditions->sortChildren($deep); + parent::sortChildren($deep); + } + + public function evaluate(RulesState $state) { + rules_log('Evaluating conditions of rule %label.', array('%label' => $this->label), RulesLog::INFO, $this); + if ($this->conditions->evaluate($state)) { + rules_log('Rule %label fires.', array('%label' => $this->label), RulesLog::INFO, $this, TRUE); + parent::evaluate($state); + rules_log('Rule %label has fired.', array('%label' => $this->label), RulesLog::INFO, $this, FALSE); + } + } + + /** + * Fires the rule, i.e. evaluates the rule without checking its conditions. + * + * @see RulesPlugin::evaluate() + */ + public function fire(RulesState $state) { + rules_log('Firing rule %label.', array('%label' => $this->label), RulesLog::INFO, $this); + parent::evaluate($state); + } + + public function integrityCheck() { + parent::integrityCheck(); + $this->conditions->integrityCheck(); + return $this; + } + + public function access() { + return (!isset($this->conditions) || $this->conditions->access()) && parent::access(); + } + + public function dependencies() { + return array_keys(array_flip($this->conditions->dependencies()) + array_flip(parent::dependencies())); + } + + public function destroy() { + $this->conditions->destroy(); + parent::destroy(); + } + + /** + * @return RulesRecursiveElementIterator + */ + public function getIterator() { + $array = array_merge(array($this->conditions), $this->children); + return new RulesRecursiveElementIterator($array); + } + + protected function stateVariables($element = NULL) { + // Don't add in provided action variables for the conditions. + if (isset($element) && $element === $this->conditions) { + return $this->availableVariables(); + } + $vars = parent::stateVariables($element); + // Take variable info assertions of conditions into account. + if ($assertions = $this->conditions->variableInfoAssertions()) { + $vars = RulesData::addMetadataAssertions($vars, $assertions); + } + return $vars; + } + + protected function exportFlat() { + return $this->isRoot(); + } + + protected function exportToArray() { + $export = parent::exportToArray(); + if (!$this->isRoot()) { + $export[strtoupper($this->plugin())]['LABEL'] = $this->label; + } + return $export; + } + + protected function exportChildren($key = NULL) { + $export = array(); + if ($this->conditions->children) { + $export = $this->conditions->exportChildren('IF'); + } + return $export + parent::exportChildren('DO'); + } + + public function import(array $export) { + if (!$this->isRoot() && isset($export[strtoupper($this->plugin())]['LABEL'])) { + $this->label = $export[strtoupper($this->plugin())]['LABEL']; + } + parent::import($export); + } + + protected function importChildren($export, $key = NULL) { + if (!empty($export['IF'])) { + $this->conditions->importChildren($export, 'IF'); + } + parent::importChildren($export, 'DO'); + } + + public function __clone() { + parent::__clone(); + $this->conditions = clone $this->conditions; + $this->conditions->parent = $this; + } + + /** + * Overrides RulesPlugin::variableInfoAssertions(). + * + * Rules may not provide any variable info assertions, as Rules are only + * conditionally executed. + */ + protected function variableInfoAssertions() { + return array(); + } + + /** + * Overridden to ensure the whole Rule is deleted at once. + */ + public function delete($keep_children = FALSE) { + parent::delete($keep_children); + } + + /** + * Overridden to expose the variables of all actions for embedded rules. + */ + public function providesVariables() { + $provides = parent::providesVariables(); + if (!$this->isRoot()) { + foreach ($this->actions() as $action) { + $provides += $action->providesVariables(); + } + } + return $provides; + } + + public function resetInternalCache() { + parent::resetInternalCache(); + $this->conditions->resetInternalCache(); + } + +} + +/** + * Represents rules getting triggered by events. + */ +class RulesReactionRule extends Rule implements RulesTriggerableInterface { + + /** + * @var string + */ + protected $itemName = 'reaction rule'; + + /** + * @var array + */ + protected $events = array(); + + /** + * @var array + */ + protected $eventSettings = array(); + + /** + * Implements RulesTriggerableInterface::events(). + */ + public function events() { + return $this->events; + } + + /** + * Implements RulesTriggerableInterface::removeEvent(). + */ + public function removeEvent($event) { + if (($id = array_search($event, $this->events)) !== FALSE) { + unset($this->events[$id]); + } + return $this; + } + + /** + * Implements RulesTriggerableInterface::event(). + */ + public function event($event_name, array $settings = NULL) { + // Process any settings and determine the configured event's name. + if ($settings) { + $handler = rules_get_event_handler($event_name, $settings); + if ($suffix = $handler->getEventNameSuffix()) { + $event_name .= '--' . $suffix; + $this->eventSettings[$event_name] = $settings; + } + else { + // Do not store settings if there is no suffix. + unset($this->eventSettings[$event_name]); + } + } + if (array_search($event_name, $this->events) === FALSE) { + $this->events[] = $event_name; + } + return $this; + } + + /** + * Implements RulesTriggerableInterface::getEventSettings(). + */ + public function getEventSettings($event_name) { + if (isset($this->eventSettings[$event_name])) { + return $this->eventSettings[$event_name]; + } + } + + public function integrityCheck() { + parent::integrityCheck(); + // Check integrity of the configured events. + foreach ($this->events as $event_name) { + $handler = rules_get_event_handler($event_name, $this->getEventSettings($event_name)); + $handler->validate(); + } + return $this; + } + + /** + * Reaction rules can't add variables to the parent scope, so clone $state. + */ + public function evaluate(RulesState $state) { + // Implement recursion prevention for reaction rules. + if ($state->isBlocked($this)) { + return rules_log('Not evaluating @plugin %label to prevent recursion.', array('%label' => $this->label(), '@plugin' => $this->plugin()), RulesLog::INFO, $this); + } + $state->block($this); + $copy = clone $state; + parent::evaluate($copy); + $state->unblock($this); + } + + public function access() { + foreach ($this->events as $event_name) { + $event_info = rules_get_event_info($event_name); + if (!empty($event_info['access callback']) && !call_user_func($event_info['access callback'], 'event', $event_info['name'])) { + return FALSE; + } + } + return parent::access(); + } + + public function dependencies() { + $modules = array_flip(parent::dependencies()); + foreach ($this->events as $event_name) { + $event_info = rules_get_event_info($event_name); + if (isset($event_info['module'])) { + $modules[$event_info['module']] = TRUE; + } + } + return array_keys($modules); + } + + public function providesVariables() { + return array(); + } + + public function parameterInfo($optional = FALSE) { + // If executed directly, all variables as defined by the event need to + // be passed. + return rules_filter_array($this->availableVariables(), 'handler', FALSE); + } + + public function availableVariables() { + if (!isset($this->availableVariables)) { + if (isset($this->parent)) { + // Return the event variables provided by the event set, once cached. + $this->availableVariables = $this->parent->stateVariables(); + } + else { + // The intersection of the variables provided by the events are + // available. + foreach ($this->events as $event_name) { + $handler = rules_get_event_handler($event_name, $this->getEventSettings($event_name)); + + if (isset($this->availableVariables)) { + $event_vars = $handler->availableVariables(); + // Merge variable info by intersecting the variable-info keys also, + // so we have only metadata available that is valid for all of the + // provided variables. + foreach (array_intersect_key($this->availableVariables, $event_vars) as $name => $variable_info) { + $this->availableVariables[$name] = array_intersect_key($variable_info, $event_vars[$name]); + } + } + else { + $this->availableVariables = $handler->availableVariables(); + } + } + $this->availableVariables = isset($this->availableVariables) ? RulesState::defaultVariables() + $this->availableVariables : RulesState::defaultVariables(); + } + } + return $this->availableVariables; + } + + public function __sleep() { + return parent::__sleep() + drupal_map_assoc(array('events', 'eventSettings')); + } + + protected function exportChildren($key = 'ON') { + $export = array(); + foreach ($this->events as $event_name) { + $export[$key][$event_name] = (array) $this->getEventSettings($event_name); + } + return $export + parent::exportChildren(); + } + + protected function importChildren($export, $key = 'ON') { + // Detect and support old-style exports: a numerically indexed array of + // event names. + if (isset($export[$key])) { + if (is_string(reset($export[$key])) && is_numeric(key($export[$key]))) { + $this->events = $export[$key]; + } + else { + $this->events = array_keys($export[$key]); + $this->eventSettings = array_filter($export[$key]); + } + } + parent::importChildren($export); + } + + /** + * Overrides optimize(). + */ + public function optimize() { + parent::optimize(); + // No need to keep event settings for evaluation. + $this->eventSettings = array(); + } + +} + +/** + * A logical AND. + */ +class RulesAnd extends RulesConditionContainer { + + /** + * @var string + */ + protected $itemName = 'and'; + + public function evaluate(RulesState $state) { + foreach ($this->children as $condition) { + if (!$condition->evaluate($state)) { + rules_log('%condition evaluated to %bool.', array( + '%condition' => $this->label(), + '%bool' => 'FALSE', + )); + return $this->negate; + } + } + rules_log('%condition evaluated to %bool.', array( + '%condition' => $this->label(), + '%bool' => 'TRUE', + )); + return !$this->negate; + } + + public function label() { + return !empty($this->label) ? $this->label : ($this->negate ? t('NOT AND') : t('AND')); + } + +} + +/** + * A logical OR. + */ +class RulesOr extends RulesConditionContainer { + + /** + * @var string + */ + protected $itemName = 'or'; + + public function evaluate(RulesState $state) { + foreach ($this->children as $condition) { + if ($condition->evaluate($state)) { + rules_log('%condition evaluated to %bool.', array( + '%condition' => $this->label(), + '%bool' => 'TRUE', + )); + return !$this->negate; + } + } + rules_log('%condition evaluated to %bool.', array( + '%condition' => $this->label(), + '%bool' => 'FALSE', + )); + return $this->negate; + } + + public function label() { + return !empty($this->label) ? $this->label : ($this->negate ? t('NOT OR') : t('OR')); + } + + /** + * Overrides RulesContainerPlugin::stateVariables(). + * + * Overridden to exclude all variable assertions as in an OR we cannot assert + * the children are successfully evaluated. + */ + protected function stateVariables($element = NULL) { + $vars = $this->availableVariables(); + if (isset($element)) { + // Add in variables provided by siblings executed before the element. + foreach ($this->children as $child) { + if ($child === $element) { + break; + } + $vars += $child->providesVariables(); + } + } + return $vars; + } + +} + +/** + * A loop element. + */ +class RulesLoop extends RulesActionContainer { + + /** + * @var string + */ + protected $itemName = 'loop'; + protected $listItemInfo; + + public function __construct($settings = array(), $variables = NULL) { + $this->setUp(); + $this->settings = (array) $settings + array( + 'item:var' => 'list_item', + 'item:label' => t('Current list item'), + ); + if (!empty($variables)) { + $this->info['variables'] = $variables; + } + } + + public function pluginParameterInfo() { + $info['list'] = array( + 'type' => 'list', + 'restriction' => 'selector', + 'label' => t('List'), + 'description' => t('The list to loop over. The loop will step through each item in the list, allowing further actions on them. See the online handbook for more information on how to use loops.', + array('@url' => rules_external_help('loops'))), + ); + return $info; + } + + public function integrityCheck() { + parent::integrityCheck(); + $this->checkVarName($this->settings['item:var']); + } + + public function listItemInfo() { + if (!isset($this->listItemInfo)) { + if ($info = $this->getArgumentInfo('list')) { + // Pass through the variable info keys like property info. + $this->listItemInfo = array_intersect_key($info, array_flip(array('type', 'property info', 'bundle'))); + $this->listItemInfo['type'] = isset($info['type']) ? entity_property_list_extract_type($info['type']) : 'unknown'; + } + else { + $this->listItemInfo = array('type' => 'unknown'); + } + $this->listItemInfo['label'] = $this->settings['item:label']; + } + return $this->listItemInfo; + } + + public function evaluate(RulesState $state) { + try { + $param_info = $this->pluginParameterInfo(); + $list = $this->getArgument('list', $param_info['list'], $state); + $item_var_info = $this->listItemInfo(); + $item_var_name = $this->settings['item:var']; + + if (isset($this->settings['list:select'])) { + rules_log('Looping over the list items of %selector', array('%selector' => $this->settings['list:select']), RulesLog::INFO, $this); + } + + // Loop over the list and evaluate the children for each list item. + foreach ($list as $key => $item) { + // Use a separate state so variables are available in the loop only. + $state2 = clone $state; + $state2->addVariable($item_var_name, $list[$key], $item_var_info); + parent::evaluate($state2); + + // Update variables from parent scope. + foreach ($state->variables as $var_key => &$var_value) { + if (array_key_exists($var_key, $state2->variables)) { + $var_value = $state2->variables[$var_key]; + } + } + } + } + catch (RulesEvaluationException $e) { + rules_log($e->msg, $e->args, $e->severity); + rules_log('Unable to evaluate %name.', array('%name' => $this->getPluginName()), RulesLog::WARN, $this); + } + } + + protected function stateVariables($element = NULL) { + return array($this->settings['item:var'] => $this->listItemInfo()) + parent::stateVariables($element); + } + + public function label() { + return !empty($this->label) ? $this->label : t('Loop'); + } + + protected function exportChildren($key = 'DO') { + return parent::exportChildren($key); + } + + protected function importChildren($export, $key = 'DO') { + parent::importChildren($export, $key); + } + + protected function exportSettings() { + $export = parent::exportSettings(); + $export['ITEM'][$this->settings['item:var']] = $this->settings['item:label']; + return $export; + } + + protected function importSettings($export) { + parent::importSettings($export); + if (isset($export['ITEM'])) { + $this->settings['item:var'] = rules_array_key($export['ITEM']); + $this->settings['item:label'] = reset($export['ITEM']); + } + } + +} + +/** + * An action set component. + */ +class RulesActionSet extends RulesActionContainer { + + /** + * @var string + */ + protected $itemName = 'action set'; + +} + +/** + * A set of rules to execute upon defined variables. + */ +class RulesRuleSet extends RulesActionContainer { + + /** + * @var string + */ + protected $itemName = 'rule set'; + + /** + * @return RulesRuleSet + */ + public function rule($rule) { + return $this->action($rule); + } + + protected function exportChildren($key = 'RULES') { + return parent::exportChildren($key); + } + + protected function importChildren($export, $key = 'RULES') { + parent::importChildren($export, $key); + } + +} + +/** + * This class is used for caching the rules to be evaluated per event. + */ +class RulesEventSet extends RulesRuleSet { + + /** + * @var string + */ + protected $itemName = 'event set'; + + /** + * Event sets may recurse as we block recursions on rule-level. + * + * @var bool + */ + public $recursion = TRUE; + + public function __construct($info = array()) { + $this->setup(); + $this->info = $info; + } + + public function executeByArgs($args = array()) { + rules_log('Reacting on event %label.', array('%label' => $this->info['label']), RulesLog::INFO, NULL, TRUE); + $state = $this->setUpState($args); + module_invoke_all('rules_config_execute', $this); + $this->evaluate($state); + $state->cleanUp($this); + rules_log('Finished reacting on event %label.', array('%label' => $this->info['label']), RulesLog::INFO, NULL, FALSE); + } + + /** + * Rebuilds the event cache. + * + * We cache event-sets per event in order to allow efficient usage via + * rules_invoke_event(). + * + * @see rules_get_cache() + * @see rules_invoke_event() + */ + public static function rebuildEventCache() { + // Set up the per-event cache. + $events = rules_fetch_data('event_info'); + $sets = array(); + // Add all rules associated with this event to an EventSet for caching. + $rules = rules_config_load_multiple(FALSE, array('plugin' => 'reaction rule', 'active' => TRUE)); + + foreach ($rules as $name => $rule) { + foreach ($rule->events() as $event_name) { + $event_base_name = rules_get_event_base_name($event_name); + // Skip not defined events. + if (empty($events[$event_base_name])) { + continue; + } + // Create an event set if not yet done. + if (!isset($sets[$event_name])) { + $handler = rules_get_event_handler($event_name, $rule->getEventSettings($event_name)); + + // Start the event dispatcher for this event, if any. + if ($handler instanceof RulesEventDispatcherInterface && !$handler->isWatching()) { + $handler->startWatching(); + } + + // Update the event info with the variables available based on the + // event settings. + $event_info = $events[$event_base_name]; + $event_info['variables'] = $handler->availableVariables(); + $sets[$event_name] = new RulesEventSet($event_info); + $sets[$event_name]->name = $event_name; + } + + // If a rule is marked as dirty, check if this still applies. + if ($rule->dirty) { + rules_config_update_dirty_flag($rule); + } + if (!$rule->dirty) { + // Clone the rule to avoid modules getting the changed version from + // the static cache. + $sets[$event_name]->rule(clone $rule); + } + } + } + + // Create cache items for all created sets. + foreach ($sets as $event_name => $set) { + $set->sortChildren(); + $set->optimize(); + // Allow modules to alter the cached event set. + drupal_alter('rules_event_set', $event_name, $set); + rules_set_cache('event_' . $event_name, $set); + } + // Cache a whitelist of configured events so we can use it to speed up later + // calls. See rules_invoke_event(). + rules_set_cache('rules_event_whitelist', array_flip(array_keys($sets))); + } + + protected function stateVariables($element = NULL) { + return $this->availableVariables(); + } + + /** + * Do not save since this class is for caching purposes only. + * + * @see RulesPlugin::save() + */ + public function save($name = NULL, $module = 'rules') { + return FALSE; + } + +} diff --git a/includes/rules.processor.inc b/includes/rules.processor.inc new file mode 100644 index 0000000..42ca45d --- /dev/null +++ b/includes/rules.processor.inc @@ -0,0 +1,387 @@ +setting = $setting; + $this->processor = $processor; + } + + /** + * Return $this or skip this processor by returning the next processor. + */ + protected function getPreparedValue() { + return isset($this->setting) && array_filter($this->setting) ? $this : $this->processor; + } + + /** + * Determines whether the current user has permission to edit this chain of + * data processors. + * + * @return bool + * Whether the current user has permission to edit this chain of data + * processors. + */ + public function editAccess() { + return $this->access() && (!isset($this->processor) || $this->processor->editAccess()); + } + + /** + * Prepares the processor for parameters. + * + * It turns the settings into a suitable processor object, which gets invoked + * on evaluation time. + * + * @param $setting + * The processor settings which are to be prepared. + * @param $param_info + * The info about the parameter to prepare the processor for. + * @param array $var_info + * An array of info about the available variables. + */ + public static function prepareSetting(&$setting, $param_info, $var_info = array()) { + $processor = NULL; + foreach (self::processors($param_info, FALSE) as $name => $info) { + if (!empty($setting[$name])) { + $object = new $info['class']($setting[$name], $param_info, $var_info, $processor); + $processor = $object->getPreparedValue(); + } + } + $setting = $processor; + } + + /** + * Attaches the form of applicable data processors. + */ + public static function attachForm(&$form, $settings, $param_info, $var_info, $access_check = TRUE) { + // If $settings is already prepared get the settings from the processors. + if ($settings instanceof RulesDataProcessor) { + $settings = $settings->getChainSettings(); + } + foreach (self::processors($param_info, $access_check) as $name => $info) { + $settings += array($name => array()); + $form[$name] = call_user_func(array($info['class'], 'form'), $settings[$name], $var_info); + $form[$name]['#weight'] = $info['weight']; + } + } + + /** + * Returns defined data processors applicable for the given parameter. + * + * Optionally also checks access to the processors. + * + * @param $param_info + * If given, only processors valid for this parameter are returned. + * @param bool $access_check + * @param string $hook + */ + public static function processors($param_info = NULL, $access_check = TRUE, $hook = 'data_processor_info') { + static $items = array(); + + if (!isset($items[$hook]['all'])) { + $items[$hook]['all'] = rules_fetch_data($hook); + if (isset($items[$hook]['all'])) { + uasort($items[$hook]['all'], array(__CLASS__, '_item_sort')); + } + } + // Data processing isn't supported for multiple types. + if (isset($param_info) && is_array($param_info['type'])) { + return array(); + } + // Filter the items by type. + if (isset($param_info['type']) && !isset($items[$hook][$param_info['type']])) { + $items[$hook][$param_info['type']] = array(); + foreach ($items[$hook]['all'] as $name => $info) { + // Check whether the parameter type matches the supported types. + $info += array('type' => 'text'); + if (RulesData::typesMatch($param_info, $info, FALSE)) { + $items[$hook][$param_info['type']][$name] = $info; + } + } + } + // Apply the access check. + $return = isset($param_info['type']) ? $items[$hook][$param_info['type']] : $items[$hook]['all']; + if ($access_check) { + foreach ($return as $base => $info) { + if (!call_user_func(array($info['class'], 'access'))) { + unset($return[$base]); + } + } + } + return $return; + } + + public static function _item_sort($a, $b) { + return $a['weight'] < $b['weight'] ? -1 : ($a['weight'] > $b['weight'] ? 1 : 0); + } + + /** + * Gets the settings array for this and all contained chained processors. + */ + public function getChainSettings() { + foreach ($this->unchain() as $name => $processor) { + $settings[$name] = $processor->getSetting(); + } + return isset($settings) ? $settings : array(); + } + + /** + * Returns an array of modules which we depend on. + */ + public function dependencies() { + $used_processor_info = array_intersect_key($this->processors(), $this->unchain()); + $modules = array(); + foreach ($used_processor_info as $name => $info) { + $modules[] = $info['module']; + } + return array_filter($modules); + } + + /** + * @return + * An array of processors keyed by processor name. + */ + protected function unchain() { + $processor = $this; + while ($processor instanceof RulesDataProcessor) { + $processors[get_class($processor)] = $processor; + $processor = $processor->processor; + } + // Note: Don't use the static context to call processors() here as we need a + // late binding to invoke the input evaluators version, if needed. + $return = array(); + foreach ($this->processors() as $name => $info) { + if (isset($processors[$info['class']])) { + $return[$name] = $processors[$info['class']]; + } + } + return $return; + } + + /** + * Gets the settings of this processor. + */ + public function getSetting() { + return $this->setting; + } + + /** + * Processes the value. + * + * If $this->processor is set, invoke this processor first so chaining + * multiple processors is working. + * + * @param $value + * The value to process. + * @param $info + * Info about the parameter for which we process the value. + * @param RulesState $state + * The rules evaluation state. + * @param RulesPlugin $element + * The element for which we process the value. + * + * @return + * The processed value. + */ + abstract public function process($value, $info, RulesState $state, RulesPlugin $element); + + /** + * Return whether the current user has permission to use the processor. + * + * @return bool + * Whether the current user has permission to use the processor. + */ + public static function access() { + return TRUE; + } + + /** + * Defines the processor form element. + * + * @param $settings + * The settings of the processor. + * @param array $var_info + * An array of info about the available variables. + * + * @return + * A form element structure. + */ + protected static function form($settings, $var_info) { + return array(); + } + +} + + +/** + * A base processor for use by input evaluators. + * + * Input evaluators are not listed in hook_rules_data_processor_info(). Instead + * they use hook_rules_evaluator_info() and get attached to input forms. + */ +abstract class RulesDataInputEvaluator extends RulesDataProcessor { + + /** + * Overridden to invoke prepare(). + */ + protected function __construct($setting, $param_info, $var_info = array(), $processor = NULL) { + $this->setting = TRUE; + $this->processor = $processor; + $this->prepare($setting, $var_info, $param_info); + } + + /** + * Overridden to generate evaluator $options and invoke evaluate(). + */ + public function process($value, $info, RulesState $state, RulesPlugin $element, $options = NULL) { + $options = isset($options) ? $options : $this->getEvaluatorOptions($info, $state, $element); + $value = isset($this->processor) ? $this->processor->process($value, $info, $state, $element, $options) : $value; + return $this->evaluate($value, $options, $state); + } + + /** + * Generates the evaluator $options. + */ + protected function getEvaluatorOptions($info, $state, $element) { + $cache = rules_get_cache(); + $languages = language_list(); + $info += array( + 'cleaning callback' => isset($cache['data info'][$info['type']]['cleaning callback']) ? $cache['data info'][$info['type']]['cleaning callback'] : FALSE, + 'sanitize' => FALSE, + ); + $options = array_filter(array( + 'language' => $info['#langcode'] != LANGUAGE_NONE && isset($languages[$info['#langcode']]) ? $languages[$info['#langcode']] : NULL, + 'callback' => $info['cleaning callback'], + 'sanitize' => $info['sanitize'], + )); + return $options; + } + + /** + * Overridden to prepare input evaluator processors. + * + * The setting is expected to be the input value to be evaluated later on + * and is replaced by the suitable processor. + */ + public static function prepareSetting(&$setting, $param_info, $var_info = array()) { + $processor = NULL; + foreach (self::evaluators($param_info, FALSE) as $name => $info) { + $object = new $info['class']($setting, $param_info, $var_info, $processor); + $processor = $object->getPreparedValue(); + } + $setting = $processor; + } + + protected function getPreparedValue() { + return isset($this->setting) ? $this : $this->processor; + } + + /** + * Overrides RulesDataProcessor::attachForm(). + * + * Overridden to just attach the help() of evaluators. + */ + public static function attachForm(&$form, $settings, $param_info, $var_info, $access_check = TRUE) { + foreach (self::evaluators($param_info, $access_check) as $name => $info) { + $form['help'][$name] = call_user_func(array($info['class'], 'help'), $var_info, $param_info); + $form['help'][$name]['#weight'] = $info['weight']; + } + } + + /** + * Returns all input evaluators that can be applied to the parameters type. + */ + public static function evaluators($param_info = NULL, $access_check = TRUE) { + return parent::processors($param_info, $access_check, 'evaluator_info'); + } + + /** + * Overrides RulesDataProcessor::processors(). + * + * Overridden to default to our hook, thus being equivalent to + * self::evaluators(). + */ + public static function processors($param_info = NULL, $access_check = TRUE, $hook = 'evaluator_info') { + return parent::processors($param_info, $access_check, $hook); + } + + /** + * Prepares the evaluation. + * + * For example, to determine whether the input evaluator has been used. + * If this evaluator should be skipped just unset $this->setting. + * + * @param string $text + * The text to evaluate later on. + * @param array $variables + * An array of info about available variables. + * @param array $param_info + * (optional) An array of information about the handled parameter value. + * For backward compatibility, this parameter is not required. + */ + abstract public function prepare($text, $variables); + + /** + * Apply the input evaluator. + * + * @param string $text + * The text to evaluate. + * @param array $options + * A keyed array of settings and flags to control the processing. + * Supported options are: + * - language: A language object to be used when processing. + * - callback: A callback function that will be used to post-process + * replacements that might be incorporated, so they can be cleaned in a + * certain way. + * - sanitize: A boolean flag indicating whether incorporated replacements + * should be sanitized. + * @param RulesState $state + * The rules evaluation state. + * + * @return + * The evaluated text. + */ + abstract public function evaluate($text, $options, RulesState $state); + + /** + * Provide some usage help for the evaluator. + * + * @param array $variables + * An array of info about available variables. + * @param array $param_info + * (optional) An array of information about the handled parameter value. + * For backward compatibility, this parameter is not required. + * + * @return array + * A renderable array. + */ + public static function help($variables) { + return array(); + } + +} diff --git a/includes/rules.state.inc b/includes/rules.state.inc new file mode 100644 index 0000000..ae17d84 --- /dev/null +++ b/includes/rules.state.inc @@ -0,0 +1,804 @@ +save = new ArrayObject(); + $this->addVariable('site', FALSE, self::defaultVariables('site')); + } + + /** + * Adds the given variable to the given execution state. + */ + public function addVariable($name, $data, $info) { + $this->info[$name] = $info + array( + 'skip save' => FALSE, + 'type' => 'unknown', + 'handler' => FALSE, + ); + if (empty($this->info[$name]['handler'])) { + $this->variables[$name] = rules_wrap_data($data, $this->info[$name]); + } + } + + /** + * Runs post-evaluation tasks, such as saving variables. + */ + public function cleanUp() { + // Make changes permanent. + foreach ($this->save->getArrayCopy() as $selector => $wrapper) { + $this->saveNow($selector); + } + unset($this->currentArguments); + } + + /** + * Block a rules configuration from execution. + */ + public function block($rules_config) { + if (empty($rules_config->recursion) && $rules_config->id) { + self::$blocked[$rules_config->id] = TRUE; + } + } + + /** + * Unblock a rules configuration from execution. + */ + public function unblock($rules_config) { + if (empty($rules_config->recursion) && $rules_config->id) { + unset(self::$blocked[$rules_config->id]); + } + } + + /** + * Returns whether a rules configuration should be blocked from execution. + */ + public function isBlocked($rule_config) { + return !empty($rule_config->id) && isset(self::$blocked[$rule_config->id]); + } + + /** + * Get the info about the state variables or a single variable. + */ + public function varInfo($name = NULL) { + if (isset($name)) { + return isset($this->info[$name]) ? $this->info[$name] : FALSE; + } + return $this->info; + } + + /** + * Returns whether the given wrapper is savable. + */ + public function isSavable($wrapper) { + return ($wrapper instanceof EntityDrupalWrapper && entity_type_supports($wrapper->type(), 'save')) || $wrapper instanceof RulesDataWrapperSavableInterface; + } + + /** + * Returns whether the variable with the given name is an entity. + */ + public function isEntity($name) { + $entity_info = entity_get_info(); + return isset($this->info[$name]['type']) && isset($entity_info[$this->info[$name]['type']]); + } + + /** + * Gets a variable. + * + * If necessary, the specified handler is invoked to fetch the variable. + * + * @param string $name + * The name of the variable to return. + * + * @return + * The variable or a EntityMetadataWrapper containing the variable. + * + * @throws RulesEvaluationException + * Throws a RulesEvaluationException in case we have info about the + * requested variable, but it is not defined. + */ + public function &get($name) { + if (!array_key_exists($name, $this->variables)) { + // If there is handler to load the variable, do it now. + if (!empty($this->info[$name]['handler'])) { + $data = call_user_func($this->info[$name]['handler'], rules_unwrap_data($this->variables), $name, $this->info[$name]); + $this->variables[$name] = rules_wrap_data($data, $this->info[$name]); + $this->info[$name]['handler'] = FALSE; + if (!isset($data)) { + throw new RulesEvaluationException('Unable to load variable %name, aborting.', array('%name' => $name), NULL, RulesLog::INFO); + } + } + else { + throw new RulesEvaluationException('Unable to get variable %name, it is not defined.', array('%name' => $name), NULL, RulesLog::ERROR); + } + } + return $this->variables[$name]; + } + + /** + * Apply permanent changes provided the wrapper's data type is savable. + * + * @param $selector + * The data selector of the wrapper to save or just a variable name. + * @param $wrapper + * @param bool $immediate + * Pass FALSE to postpone saving to later on. Else it's immediately saved. + */ + public function saveChanges($selector, $wrapper, $immediate = FALSE) { + $info = $wrapper->info(); + if (empty($info['skip save']) && $this->isSavable($wrapper)) { + $this->save($selector, $wrapper, $immediate); + } + // No entity, so try saving the parent. + elseif (empty($info['skip save']) && isset($info['parent']) && !($wrapper instanceof EntityDrupalWrapper)) { + // Cut of the last part of the selector. + $selector = implode(':', explode(':', $selector, -1)); + $this->saveChanges($selector, $info['parent'], $immediate); + } + return $this; + } + + /** + * Remembers to save the wrapper on cleanup or does it now. + */ + protected function save($selector, EntityMetadataWrapper $wrapper, $immediate) { + // Convert variable names and selectors to both use underscores. + $selector = strtr($selector, '-', '_'); + if (isset($this->save[$selector])) { + if ($this->save[$selector][0]->getIdentifier() == $wrapper->getIdentifier()) { + // The entity is already remembered. So do a combined save. + $this->save[$selector][1] += self::$blocked; + } + else { + // The wrapper is already in there, but wraps another entity. So first + // save the old one, then care about the new one. + $this->saveNow($selector); + } + } + if (!isset($this->save[$selector])) { + // In case of immediate saving don't clone the wrapper, so saving a new + // entity immediately makes the identifier available afterwards. + $this->save[$selector] = array($immediate ? $wrapper : clone $wrapper, self::$blocked); + } + if ($immediate) { + $this->saveNow($selector); + } + } + + /** + * Saves the wrapper for the given selector. + */ + protected function saveNow($selector) { + // Add the set of blocked elements for the recursion prevention. + $previously_blocked = self::$blocked; + self::$blocked += $this->save[$selector][1]; + + // Actually save! + $wrapper = $this->save[$selector][0]; + $entity = $wrapper->value(); + // When operating in hook_entity_insert() $entity->is_new might be still + // set. In that case remove the flag to avoid causing another insert instead + // of an update. + if (!empty($entity->is_new) && $wrapper->getIdentifier()) { + $entity->is_new = FALSE; + } + rules_log('Saved %selector of type %type.', array('%selector' => $selector, '%type' => $wrapper->type())); + $wrapper->save(); + + // Restore the state's set of blocked elements. + self::$blocked = $previously_blocked; + unset($this->save[$selector]); + } + + /** + * Merges info from the given state into the existing state. + * + * Merges the info about to-be-saved variables from the given state into the + * existing state. Therefore we can aggregate saves from invoked components. + * Merged-in saves are removed from the given state, but not-mergeable saves + * remain there. + * + * @param $state + * The state for which to merge the to be saved variables in. + * @param $component + * The component which has been invoked, thus needs to be blocked for the + * merged in saves. + * @param $settings + * The settings of the element that invoked the component. Contains + * information about variable/selector mappings between the states. + */ + public function mergeSaveVariables(RulesState $state, RulesPlugin $component, $settings) { + // For any saves that we take over, also block the component. + $this->block($component); + + foreach ($state->save->getArrayCopy() as $selector => $data) { + $parts = explode(':', $selector, 2); + // Adapt the selector to fit for the parent state and move the wrapper. + if (isset($settings[$parts[0] . ':select'])) { + $parts[0] = $settings[$parts[0] . ':select']; + $this->save(implode(':', $parts), $data[0], FALSE); + unset($state->save[$selector]); + } + } + $this->unblock($component); + } + + /** + * Returns an entity metadata wrapper as specified in the selector. + * + * @param string $selector + * The selector string, e.g. "node:author:mail". + * @param string $langcode + * (optional) The language code used to get the argument value if the + * argument value should be translated. Defaults to LANGUAGE_NONE. + * + * @return EntityMetadataWrapper + * The wrapper for the given selector. + * + * @throws RulesEvaluationException + * Throws a RulesEvaluationException in case the selector cannot be applied. + */ + public function applyDataSelector($selector, $langcode = LANGUAGE_NONE) { + $parts = explode(':', str_replace('-', '_', $selector), 2); + $wrapper = $this->get($parts[0]); + if (count($parts) == 1) { + return $wrapper; + } + elseif (!$wrapper instanceof EntityMetadataWrapper) { + throw new RulesEvaluationException('Unable to apply data selector %selector. The specified variable is not wrapped correctly.', array('%selector' => $selector)); + } + try { + foreach (explode(':', $parts[1]) as $name) { + if ($wrapper instanceof EntityListWrapper || $wrapper instanceof EntityStructureWrapper) { + // Make sure we are using the right language. Wrappers might be cached + // and have previous langcodes set, so always set the right language. + if ($wrapper instanceof EntityStructureWrapper) { + $wrapper->language($langcode); + } + $wrapper = $wrapper->get($name); + } + else { + throw new RulesEvaluationException('Unable to apply data selector %selector. The specified variable is not a list or a structure: %wrapper.', array('%selector' => $selector, '%wrapper' => $wrapper)); + } + } + } + catch (EntityMetadataWrapperException $e) { + // In case of an exception, re-throw it. + throw new RulesEvaluationException('Unable to apply data selector %selector: %error', array('%selector' => $selector, '%error' => $e->getMessage())); + } + return $wrapper; + } + + /** + * Magic method. Only serialize variables and their info. + * + * Additionally we remember currently blocked configs, so we can restore them + * upon deserialization using restoreBlocks(). + */ + public function __sleep() { + $this->currentlyBlocked = self::$blocked; + return array('info', 'variables', 'currentlyBlocked'); + } + + /** + * Magic method. Unserialize variables and their info. + */ + public function __wakeup() { + $this->save = new ArrayObject(); + } + + /** + * Restores the before-serialization blocked configurations. + * + * Warning: This overwrites any possible currently blocked configs. Thus + * do not invoke this method if there might be evaluations active. + */ + public function restoreBlocks() { + self::$blocked = $this->currentlyBlocked; + } + + /** + * Defines always-available variables. + * + * @param $key + * (optional) + */ + public static function defaultVariables($key = NULL) { + // Add a variable for accessing site-wide data properties. + $vars['site'] = array( + 'type' => 'site', + 'label' => t('Site information'), + 'description' => t("Site-wide settings and other global information."), + // Add the property info via a callback making use of the cached info. + 'property info alter' => array('RulesData', 'addSiteMetadata'), + 'property info' => array(), + 'optional' => TRUE, + ); + return isset($key) ? $vars[$key] : $vars; + } + +} + +/** + * A class holding static methods related to data. + */ +class RulesData { + + /** + * Returns whether the type match. They match if type1 is compatible to type2. + * + * @param $var_info + * The name of the type to check for whether it is compatible to type2. + * @param $param_info + * The type expression to check for. + * @param bool $ancestors + * (optional) Whether sub-type relationships for checking type compatibility + * should be taken into account. Defaults to TRUE. + * + * @return bool + * Whether the types match. + */ + public static function typesMatch($var_info, $param_info, $ancestors = TRUE) { + $var_type = $var_info['type']; + $param_type = $param_info['type']; + + if ($param_type == '*' || $param_type == 'unknown') { + return TRUE; + } + + if ($var_type == $param_type) { + // Make sure the bundle matches, if specified by the parameter. + return !isset($param_info['bundles']) || isset($var_info['bundle']) && in_array($var_info['bundle'], $param_info['bundles']); + } + + // Parameters may specify multiple types using an array. + $valid_types = is_array($param_type) ? $param_type : array($param_type); + if (in_array($var_type, $valid_types)) { + return TRUE; + } + + // Check for sub-type relationships. + if ($ancestors && !isset($param_info['bundles'])) { + $cache = &rules_get_cache(); + self::typeCalcAncestors($cache, $var_type); + // If one of the types is an ancestor return TRUE. + return (bool) array_intersect_key($cache['data_info'][$var_type]['ancestors'], array_flip($valid_types)); + } + return FALSE; + } + + protected static function typeCalcAncestors(&$cache, $type) { + if (!isset($cache['data_info'][$type]['ancestors'])) { + $cache['data_info'][$type]['ancestors'] = array(); + if (isset($cache['data_info'][$type]['parent']) && $parent = $cache['data_info'][$type]['parent']) { + $cache['data_info'][$type]['ancestors'][$parent] = TRUE; + self::typeCalcAncestors($cache, $parent); + // Add all parent ancestors to our own ancestors. + $cache['data_info'][$type]['ancestors'] += $cache['data_info'][$parent]['ancestors']; + } + // For special lists like list add in "list" as valid parent. + if (entity_property_list_extract_type($type)) { + $cache['data_info'][$type]['ancestors']['list'] = TRUE; + } + } + } + + /** + * Returns data for the given info and the to-be-configured parameter. + * + * Returns matching data variables or properties for the given info and the + * to-be-configured parameter. + * + * @param $source + * Either an array of info about available variables or a entity metadata + * wrapper. + * @param $param_info + * The information array about the to be configured parameter. + * @param string $prefix + * An optional prefix for the data selectors. + * @param int $recursions + * The number of recursions used to go down the tree. Defaults to 2. + * @param bool $suggestions + * Whether possibilities to recurse are suggested as soon as the deepest + * level of recursions is reached. Defaults to TRUE. + * + * @return array + * An array of info about matching variables or properties that match, keyed + * with the data selector. + */ + public static function matchingDataSelector($source, $param_info, $prefix = '', $recursions = 2, $suggestions = TRUE) { + // If an array of info is given, get entity metadata wrappers first. + $data = NULL; + if (is_array($source)) { + foreach ($source as $name => $info) { + $source[$name] = rules_wrap_data($data, $info, TRUE); + } + } + + $matches = array(); + foreach ($source as $name => $wrapper) { + $info = $wrapper->info(); + $name = str_replace('_', '-', $name); + + if (self::typesMatch($info, $param_info)) { + $matches[$prefix . $name] = $info; + if (!is_array($source) && $source instanceof EntityListWrapper) { + // Add some more possible list items. + for ($i = 1; $i < 4; $i++) { + $matches[$prefix . $i] = $info; + } + } + } + // Recurse later on to get an improved ordering of the results. + if ($wrapper instanceof EntityStructureWrapper || $wrapper instanceof EntityListWrapper) { + $recurse[$prefix . $name] = $wrapper; + if ($recursions > 0) { + $matches += self::matchingDataSelector($wrapper, $param_info, $prefix . $name . ':', $recursions - 1, $suggestions); + } + elseif ($suggestions) { + // We may not recurse any more, + // but indicate the possibility to recurse. + $matches[$prefix . $name . ':'] = $wrapper->info(); + if (!is_array($source) && $source instanceof EntityListWrapper) { + // Add some more possible list items. + for ($i = 1; $i < 4; $i++) { + $matches[$prefix . $i . ':'] = $wrapper->info(); + } + } + } + } + } + return $matches; + } + + /** + * Adds asserted metadata to the variable info. + * + * In case there are already assertions for a variable, the assertions are + * merged such that both apply. + * + * @see RulesData::applyMetadataAssertions() + */ + public static function addMetadataAssertions($var_info, $assertions) { + foreach ($assertions as $selector => $assertion) { + // Convert the selector back to underscores, such it matches the varname. + $selector = str_replace('-', '_', $selector); + + $parts = explode(':', $selector); + if (isset($var_info[$parts[0]])) { + // Apply the selector to determine the right target array. We build an + // array like + // $var_info['rules assertion']['property1']['property2']['#info'] = .. + $target = &$var_info[$parts[0]]['rules assertion']; + foreach (array_slice($parts, 1) as $part) { + $target = &$target[$part]; + } + + // In case the assertion is directly for a variable, we have to modify + // the variable info directly. In case the asserted property is nested + // the info-has to be altered by RulesData::applyMetadataAssertions() + // before the child-wrapper is created. + if (count($parts) == 1) { + // Support asserting a type in case of generic entity references only. + $var_type = &$var_info[$parts[0]]['type']; + if (isset($assertion['type']) && ($var_type == 'entity' || $var_type == 'list')) { + $var_type = $assertion['type']; + unset($assertion['type']); + } + // Add any single bundle directly to the variable info, so the + // variable fits as argument for parameters requiring the bundle. + if (isset($assertion['bundle']) && count($bundles = (array) $assertion['bundle']) == 1) { + $var_info[$parts[0]]['bundle'] = reset($bundles); + } + } + + // Add the assertions, but merge them with any previously added + // assertions if necessary. + $target['#info'] = isset($target['#info']) ? rules_update_array($target['#info'], $assertion) : $assertion; + + // Add in a callback that the entity metadata wrapper pick up for + // altering the property info, such that we can add in the assertions. + $var_info[$parts[0]] += array('property info alter' => array('RulesData', 'applyMetadataAssertions')); + + // In case there is a VARNAME_unchanged variable as it is used in update + // hooks, assume the assertions are valid for the unchanged variable + // too. + if (isset($var_info[$parts[0] . '_unchanged'])) { + $name = $parts[0] . '_unchanged'; + $var_info[$name]['rules assertion'] = $var_info[$parts[0]]['rules assertion']; + $var_info[$name]['property info alter'] = array('RulesData', 'applyMetadataAssertions'); + + if (isset($var_info[$parts[0]]['bundle']) && !isset($var_info[$name]['bundle'])) { + $var_info[$name]['bundle'] = $var_info[$parts[0]]['bundle']; + } + } + } + } + return $var_info; + } + + /** + * Property info alter callback for the entity metadata wrapper. + * + * Used for applying the rules metadata assertions. + * + * @see RulesData::addMetadataAssertions() + */ + public static function applyMetadataAssertions(EntityMetadataWrapper $wrapper, $property_info) { + $info = $wrapper->info(); + + if (!empty($info['rules assertion'])) { + $assertion = $info['rules assertion']; + + // In case there are list-wrappers pass through the assertions of the item + // but make sure we only apply the assertions for the list items for + // which the conditions are executed. + if (isset($info['parent']) && $info['parent'] instanceof EntityListWrapper) { + $assertion = isset($assertion[$info['name']]) ? $assertion[$info['name']] : array(); + } + + // Support specifying multiple bundles, whereas the added properties are + // the intersection of the bundle properties. + if (isset($assertion['#info']['bundle'])) { + $bundles = (array) $assertion['#info']['bundle']; + foreach ($bundles as $bundle) { + $properties[] = isset($property_info['bundles'][$bundle]['properties']) ? $property_info['bundles'][$bundle]['properties'] : array(); + } + // Add the intersection. + $property_info['properties'] += count($properties) > 1 ? call_user_func_array('array_intersect_key', $properties) : reset($properties); + } + // Support adding directly asserted property info. + if (isset($assertion['#info']['property info'])) { + $property_info['properties'] += $assertion['#info']['property info']; + } + + // Pass through any rules assertion of properties to their info, so any + // derived wrappers apply it. + foreach (element_children($assertion) as $key) { + $property_info['properties'][$key]['rules assertion'] = $assertion[$key]; + $property_info['properties'][$key]['property info alter'] = array('RulesData', 'applyMetadataAssertions'); + + // Apply any 'type' and 'bundle' assertion directly to the property + // info. + if (isset($assertion[$key]['#info']['type'])) { + $type = $assertion[$key]['#info']['type']; + // Support asserting a type in case of generic entity references only. + if ($property_info['properties'][$key]['type'] == 'entity' && entity_get_info($type)) { + $property_info['properties'][$key]['type'] = $type; + } + } + if (isset($assertion[$key]['#info']['bundle'])) { + $bundle = (array) $assertion[$key]['#info']['bundle']; + // Add any single bundle directly to the variable info, so the + // property fits as argument for parameters requiring the bundle. + if (count($bundle) == 1) { + $property_info['properties'][$key]['bundle'] = reset($bundle); + } + } + } + } + return $property_info; + } + + /** + * Property info alter callback for the entity metadata wrapper. + * + * Used to inject metadata for the 'site' variable. In contrast to doing this + * via hook_rules_data_info() this callback makes use of the already existing + * property info cache for site information of entity metadata. + * + * @see RulesPlugin::availableVariables() + */ + public static function addSiteMetadata(EntityMetadataWrapper $wrapper, $property_info) { + $site_info = entity_get_property_info('site'); + $property_info['properties'] += $site_info['properties']; + // Also invoke the usual callback for altering metadata, in case actions + // have specified further metadata. + return RulesData::applyMetadataAssertions($wrapper, $property_info); + } + +} + +/** + * A wrapper class similar to the EntityDrupalWrapper, but for non-entities. + * + * This class is intended to serve as base for a custom wrapper classes of + * identifiable data types, which are non-entities. By extending this class only + * the extractIdentifier() and load() methods have to be defined. + * In order to make the data type savable implement the + * RulesDataWrapperSavableInterface. + * + * That way it is possible for non-entity data types to be work with Rules, i.e. + * one can implement a 'ui class' with a direct input form returning the + * identifier of the data. However, instead of that it is suggested to implement + * an entity type, such that the same is achieved via general API functions like + * entity_load(). + */ +abstract class RulesIdentifiableDataWrapper extends EntityStructureWrapper { + + /** + * Contains the id. + */ + protected $id = FALSE; + + /** + * Construct a new wrapper object. + * + * @param $type + * The type of the passed data. + * @param $data + * (optional) The data to wrap or its identifier. + * @param array $info + * (optional) Used internally to pass info about properties down the tree. + */ + public function __construct($type, $data = NULL, $info = array()) { + parent::__construct($type, $data, $info); + $this->setData($data); + } + + /** + * Sets the data internally accepting both the data id and object. + */ + protected function setData($data) { + if (isset($data) && $data !== FALSE && !is_object($data)) { + $this->id = $data; + $this->data = FALSE; + } + elseif (is_object($data)) { + // We got the data object passed. + $this->data = $data; + $id = $this->extractIdentifier($data); + $this->id = isset($id) ? $id : FALSE; + } + } + + /** + * Returns the identifier of the wrapped data. + */ + public function getIdentifier() { + return $this->dataAvailable() && $this->value() ? $this->id : NULL; + } + + /** + * Overridden. + */ + public function value(array $options = array()) { + $this->setData(parent::value()); + if (!$this->data && !empty($this->id)) { + // Lazy load the data if necessary. + $this->data = $this->load($this->id); + if (!$this->data) { + throw new EntityMetadataWrapperException('Unable to load the ' . check_plain($this->type) . ' with the id ' . check_plain($this->id) . '.'); + } + } + return $this->data; + } + + /** + * Overridden to support setting the data by either the object or the id. + */ + public function set($value) { + if (!$this->validate($value)) { + throw new EntityMetadataWrapperException('Invalid data value given. Be sure it matches the required data type and format.'); + } + // As custom wrapper classes can only appear for Rules variables, but not + // as properties we don't have to care about updating the parent. + $this->clear(); + $this->setData($value); + return $this; + } + + /** + * Overridden. + */ + public function clear() { + $this->id = NULL; + parent::clear(); + } + + /** + * Prepare for serialization. + */ + public function __sleep() { + $vars = parent::__sleep(); + // Don't serialize the loaded data, except for the case the data is not + // saved yet. + if (!empty($this->id)) { + unset($vars['data']); + } + return $vars; + } + + /** + * Prepare for unserialization. + */ + public function __wakeup() { + if ($this->id !== FALSE) { + // Make sure data is set, so the data will be loaded when needed. + $this->data = FALSE; + } + } + + /** + * Extract the identifier of the given data object. + * + * @return + * The extracted identifier. + */ + abstract protected function extractIdentifier($data); + + /** + * Load a data object given an identifier. + * + * @return + * The loaded data object, or FALSE if loading failed. + */ + abstract protected function load($id); + +} + +/** + * Used to declare custom wrapper classes as savable. + */ +interface RulesDataWrapperSavableInterface { + + /** + * Save the currently wrapped data. + */ + public function save(); + +} diff --git a/includes/rules.upgrade.inc b/includes/rules.upgrade.inc new file mode 100644 index 0000000..2eb2cad --- /dev/null +++ b/includes/rules.upgrade.inc @@ -0,0 +1,745 @@ + $export) { + // Rules have been already converted and exported, so show the export. + $form['export'][$key] = array( + '#type' => 'textarea', + '#title' => t('Export %name', array('%name' => $key)), + '#description' => t('For importing copy the content of the text area and paste it into the import page of the Rules admin UI. In case the export does not pass the integrity check during import, try using the save to database method instead and manually fix your configuration after conversion.'), + '#rows' => 10, + '#default_value' => $export, + ); + } + return $form; + } + + $form['help'] = array( + '#prefix' => '

', + '#suffix' => '

', + '#markup' => t('This form allows you to convert rules or rule sets from Rules 1.x to Rules 2.x.') . ' ' . + t('In order to convert a rule or rule set make sure you have all dependent modules installed and upgraded, i.e. modules which provide Rules integration that has been used in your rules or rule sets. In addition those modules may need to implement some Rules specific update hooks for the conversion to properly work.') . ' ' . + t('After conversion, the old rules and rule sets will stay in the database until you manually delete them. That way you can make sure the conversion has gone right before you delete the old rules and rule sets.'), + ); + + $option_rules = $option_sets = array(); + if (!db_table_exists('rules_rules')) { + drupal_set_message('There are no Rules 1.x rules or rule sets left to convert.', 'error'); + } + else { + foreach (_rules_upgrade_fetch_all_rules() as $name => $rule) { + if (!empty($rule['#set']) && strpos($rule['#set'], 'event_') === 0) { + $option_rules[$name] = $name . ': ' . $rule['#label']; + } + } + $query = db_select('rules_sets', 'r')->fields('r'); + foreach ($query->execute() as $row) { + $set = unserialize($row->data); + $option_sets[$row->name] = $row->name . ': ' . $set['label']; + } + + $form['clear'] = array( + '#prefix' => '

', + '#suffix' => '

', + '#markup' => t('Once you have successfully converted your configuration, you can clean up your database and delete all Rules 1.x configurations.', array('!url' => url('admin/config/workflow/rules/upgrade/clear'))), + ); + } + + $form['rules'] = array( + '#type' => 'select', + '#title' => t('Rules'), + '#options' => $option_rules, + '#multiple' => TRUE, + ); + + $form['sets'] = array( + '#type' => 'select', + '#title' => t('Rule sets'), + '#options' => $option_sets, + '#multiple' => TRUE, + ); + $form['method'] = array( + '#type' => 'radios', + '#title' => t('Method'), + '#options' => array( + 'export' => t('Convert configuration and export it.'), + 'save' => t('Convert configuration and save it.'), + ), + '#default_value' => 'export', + ); + + $form['actions']['convert'] = array( + '#type' => 'submit', + '#value' => t('Convert'), + '#disabled' => !db_table_exists('rules_rules'), + ); + return $form; +} + +/** + * Submit handler for the form. + */ +function rules_upgrade_form_submit($form, &$form_state) { + // Load all rules includes and install files so modules may put there upgrade + // information in both locations. + module_load_all_includes('rules.inc'); + module_load_all_includes('install'); + + $configs = array(); + + try { + foreach ($form_state['values']['rules'] as $name) { + drupal_set_message(t('Converting %plugin %name...', array('%plugin' => t('rule'), '%name' => $name))); + $configs[$name] = rules_upgrade_convert_rule($name, _rules_upgrade_fetch_item($name, 'rules_rules')); + } + foreach ($form_state['values']['sets'] as $name) { + drupal_set_message(t('Converting %plugin %name...', array('%plugin' => t('rule set'), '%name' => $name))); + $configs[$name] = rules_upgrade_convert_rule_set($name, _rules_upgrade_fetch_item($name, 'rules_sets')); + } + drupal_set_message(t('Completed.')); + + if ($form_state['values']['method'] == 'save') { + foreach ($configs as $config) { + $config->save(); + } + drupal_set_message(t('Converted configurations have been saved to the database and will appear in the Rules administration interface.')); + } + elseif ($form_state['values']['method'] == 'export') { + $export = array(); + foreach ($configs as $name => $config) { + $export[$name] = $config->export(); + } + $form_state['export'] = $export; + $form_state['rebuild'] = TRUE; + } + } + catch (RulesException $e) { + drupal_set_message($e->getMessage(), 'error'); + } +} + +/** + * Confirm form for deleting data. + */ +function rules_upgrade_confirm_clear_form($form, $form_state) { + $confirm_question = t('Are you sure you want to drop the Rules 1.x tables from the database?'); + $confirm_question_long = t('Are you sure you want to drop the Rules 1.x tables from the database? All Rules 1.x configurations will be deleted regardless whether they have been already converted.') . ' ' . t('This action cannot be undone.'); + return confirm_form($form, $confirm_question, 'admin/config/workflow/rules/upgrade', $confirm_question_long, t('Delete data'), t('Cancel')); +} + +/** + * Submit handler for deleting data. + */ +function rules_upgrade_confirm_clear_form_submit($form, &$form_state) { + db_drop_table('rules_rules'); + db_drop_table('rules_sets'); + db_drop_table('rules_scheduler_d6'); + drupal_set_message(t('Rules 1.x configurations have been deleted.')); + $form_state['redirect'] = 'admin'; +} + +/** + * Fetches a single item (rule | rule set). + */ +function _rules_upgrade_fetch_item($name, $table) { + $query = db_select($table, 'r')->fields('r')->condition('name', $name); + $row = $query->execute()->fetchAssoc(); + return unserialize($row['data']); +} + +/** + * Fetches all rules. + */ +function _rules_upgrade_fetch_all_rules() { + $static = drupal_static(__FUNCTION__); + + if (!isset($static)) { + $query = db_select('rules_rules', 'r')->fields('r'); + $static['rules'] = array(); + foreach ($query->execute() as $row) { + $static['rules'][$row->name] = unserialize($row->data); + } + } + return $static['rules']; +} + +/** + * Converts a single reaction rule. + */ +function rules_upgrade_convert_rule($name, $cfg_old) { + $config = rules_upgrade_plugin_factory($cfg_old); + $config->name = $name; + + if ($config instanceof RulesReactionRule) { + rules_upgrade_convert_element($cfg_old, $config); + } + return $config; +} + +/** + * Converts a single rule set, including all of its rules. + */ +function rules_upgrade_convert_rule_set($name, $cfg_old) { + $config = rules_plugin_factory('rule set'); + $config->name = $name; + foreach (array('label', 'weight') as $key) { + if (isset($cfg_old[$key])) { + $config->$key = $cfg_old[$key]; + } + } + if (isset($cfg_old['arguments'])) { + $vars = &$config->componentVariables(); + foreach ($cfg_old['arguments'] as $var_name => $info) { + // Map data types as required. + if ($info['type'] == 'string') { + $info['type'] = 'text'; + } + $vars[$var_name] = $info; + } + } + + // Add in all rules of the set. + foreach (_rules_upgrade_fetch_all_rules() as $rule_name => $rule) { + if ($rule['#set'] == $name) { + drupal_set_message(' >> ' . t('Converting %plugin %name...', array('%plugin' => t('rule'), '%name' => $rule_name . ': ' . $rule['#label']))); + $new_rule = rules_upgrade_plugin_factory($rule); + rules_upgrade_convert_element($rule, $new_rule); + $new_rule->setParent($config); + } + } + return $config; +} + +/** + * Convert a single element. + * + * @param array $element + * The element to convert. + * @param RulesPlugin $target + * The converted element to write to. + */ +function rules_upgrade_convert_element(array $element, RulesPlugin $target) { + foreach (array('active', 'label', 'weight') as $key) { + if (isset($element['#' . $key])) { + $target->$key = $element['#' . $key]; + } + } + // Go through the parameters and take over its configuration if possible. + foreach ($target->pluginParameterInfo() as $name => $info) { + rules_upgrade_element_parameter_settings($element, $target, $name); + } + // @todo Care about php input evaluator for non-text parameters. + + // Take care of variable names and labels. + foreach ($target->pluginProvidesVariables() as $name => $info) { + rules_upgrade_element_variable_settings($element, $target, $name); + } + + if ($target instanceof RulesConditionInterface && !empty($element['#negate'])) { + $target->negate(TRUE); + } + if ($target instanceof RulesReactionRule) { + // Cut of the 'event_' prefix. + $target->event(substr($element['#set'], 6)); + } + if ($element['#type'] == 'rule') { + if (!empty($element['#conditions'])) { + foreach (element_children($element['#conditions']) as $key) { + $child = rules_upgrade_plugin_factory($element['#conditions'][$key]); + rules_upgrade_convert_element($element['#conditions'][$key], $child); + $target->condition($child); + } + } + if (!empty($element['#actions'])) { + foreach (element_children($element['#actions']) as $key) { + $child = rules_upgrade_plugin_factory($element['#actions'][$key]); + rules_upgrade_convert_element($element['#actions'][$key], $child); + $target->action($child); + } + } + } + + // Invoke action/condition specific hooks and a general one. + if (($element['#type'] == 'action' || $element['#type'] == 'condition')) { + if (function_exists($function = $element['#name'] . '_upgrade')) { + $element_name = $function($element, $target); + } + elseif (isset($element['#info']['base']) && function_exists($function = $element['#info']['base'] . '_upgrade')) { + $element_name = $function($element, $target); + } + } + + drupal_alter('rules_element_upgrade', $element, $target); + // Recurse down, if necessary. + foreach (element_children($element) as $key) { + $child = rules_upgrade_plugin_factory($element[$key]); + rules_upgrade_convert_element($element[$key], $child); + $child->setParent($target); + } + if ($target instanceof RulesContainerPlugin) { + $target->sortChildren(); + } +} + +/** + * Creates the right element. + */ +function rules_upgrade_plugin_factory($element) { + if ($element['#type'] == 'rule' && !empty($element['#set']) && strpos($element['#set'], 'event_') === 0) { + return rules_plugin_factory('reaction rule'); + } + + switch ($element['#type']) { + case 'OR': + return rules_plugin_factory('or'); + + case 'AND': + return rules_plugin_factory('and'); + + default: + return rules_plugin_factory($element['#type']); + + case 'action': + case 'condition': + if (isset($element['#name'])) { + // Try to come up with the right action/condition name ourself, then + // invoke a hook. + $cache = rules_get_cache(); + $items = $cache[$element['#type'] == 'action' ? 'action_info' : 'condition_info']; + + if (isset($items[$element['#name']])) { + $element_name = $element['#name']; + } + elseif (($name = str_replace('rules_', '', $element['#name'])) && isset($items[$name])) { + $element_name = $name; + } + elseif (($name = str_replace($element['#type'] . '_', '', $element['#name'])) && isset($items[$name])) { + $element_name = $name; + } + elseif (($name = str_replace('rules_' . $element['#type'] . '_', '', $element['#name'])) && isset($items[$name])) { + $element_name = $name; + } + elseif (isset($element['#info']['base']) && isset($items[$element['#info']['base']])) { + $element_name = $name; + } + + // Call the upgrade callback if one has been defined. + if (function_exists($function = $element['#name'] . '_upgrade_map_name') || (isset($element['#info']['base']) && function_exists($function = $element['#info']['base'] . '_upgrade_map_name'))) { + $element_name = $function($element); + } + if (!isset($element_name)) { + throw new RulesIntegrityException(t("Cannot find @plugin %name. Maybe a required is missing or the module has not implemented the upgrade functionality.", array('@plugin' => $element['#type'], '%name' => $element['#name']))); + } + return rules_plugin_factory($element['#type'], $element_name); + } + break; + } +} + +/** + * Converts the settings for a given parameter. + */ +function rules_upgrade_element_parameter_settings($element, $target, $name, $new_name = NULL) { + if (!isset($new_name)) { + $new_name = $name; + } + if (isset($element['#settings'][$name])) { + // In case a single token has been used, just convert it to a data + // selector. + if (is_string($element['#settings'][$name]) && preg_match("/\[(.*)\]$/", $element['#settings'][$name], $matches)) { + $target->settings[$new_name . ':select'] = $matches[1]; + } + else { + $target->settings[$new_name] = $element['#settings'][$name]; + } + } + elseif (isset($element['#settings']['#argument map'][$name])) { + $target->settings[$new_name . ':select'] = $element['#settings']['#argument map'][$name]; + } +} + +/** + * Converts the settings for a given variable. + */ +function rules_upgrade_element_variable_settings($element, $target, $name, $new_name = NULL) { + if (!isset($new_name)) { + $new_name = $name; + } + if (isset($element['#settings']['#argument map'][$name])) { + $target->settings[$new_name . ':var'] = $element['#settings']['#argument map'][$name]; + $target->settings[$new_name . ':label'] = $element['#info']['new variables'][$target->settings[$new_name . ':var']]['label']; + } +} + +/** + * Upgrade callbacks for upgrading the provided Rules 1.x integration. + */ + +/** + * Comment.module integration. + */ +function rules_action_load_comment_upgrade_map_name($element) { + return 'entity_fetch'; +} + +function rules_action_load_comment_upgrade($element, $target) { + $target->settings['type'] = 'comment'; + rules_upgrade_element_parameter_settings($element, $target, 'cid', 'id'); + rules_upgrade_element_variable_settings($element, $target, 'comment_loaded', 'entity_fetched'); +} + +/** + * Node.module integration. + */ +function rules_condition_content_is_type_upgrade_map_name($element) { + return 'node_is_of_type'; +} + +function rules_condition_content_is_published_upgrade_map_name($element) { + return 'node_is_published'; +} + +function rules_condition_content_is_sticky_upgrade_map_name($element) { + return 'node_is_sticky'; +} + +function rules_condition_content_is_promoted_upgrade_map_name($element) { + return 'node_is_promoted'; +} + +function rules_condition_content_is_new_upgrade_map_name($element) { + return 'entity_is_new'; +} + +function rules_condition_content_is_new_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'node', 'entity'); +} + +function rules_action_node_set_author_upgrade_map_name($element) { + return 'data_set'; +} + +function rules_action_node_set_author_upgrade($element, $target) { + $target->settings['data:select'] = $element['#settings']['#argument map']['node'] . ':author'; + $target->settings['value:select'] = $element['#settings']['#argument map']['author']; +} + +function rules_action_node_load_author_upgrade_map_name($element) { + return 'entity_fetch'; +} + +function rules_action_node_load_author_upgrade($element, $target) { + $target->settings['type'] = 'user'; + $target->settings['id'] = $element['#settings']['#argument map']['node'] . ':author:uid'; +} + +function rules_action_set_node_title_upgrade_map_name($element) { + return 'data_set'; +} + +function rules_action_set_node_title_upgrade($element, $target) { + $target->settings['data:select'] = $element['#settings']['#argument map']['node'] . ':title'; + $target->settings['value'] = $element['#settings']['title']; +} + +function rules_action_add_node_upgrade_map_name($element) { + return 'entity_create'; +} + +function rules_action_add_node_upgrade($element, $target) { + $target->settings['type'] = 'node'; + rules_upgrade_element_parameter_settings($element, $target, 'title', 'param_title'); + rules_upgrade_element_parameter_settings($element, $target, 'author', 'param_author'); + rules_upgrade_element_parameter_settings($element, $target, 'type', 'param_type'); + rules_upgrade_element_variable_settings($element, $target, 'node_added', 'entity_created'); + if (!empty($element['#settings']['node_access'])) { + drupal_set_message(t('Warning: The node-access check option for the node creation action is not supported any more.')); + } +} + +function rules_action_load_node_upgrade_map_name($element) { + return 'entity_fetch'; +} + +function rules_action_load_node_upgrade($element, $target) { + $target->settings['type'] = 'node'; + rules_upgrade_element_parameter_settings($element, $target, 'nid', 'id'); + rules_upgrade_element_parameter_settings($element, $target, 'vid', 'revision_id'); + rules_upgrade_element_variable_settings($element, $target, 'node_loaded', 'entity_fetched'); +} + +function rules_action_delete_node_upgrade_map_name($element) { + return 'entity_delete'; +} + +function rules_action_delete_node_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'node', 'entity'); +} + +function rules_core_node_publish_action_upgrade_map_name($element) { + return 'node_publish'; +} + +function rules_core_node_unpublish_action_upgrade_map_name($element) { + return 'node_unpublish'; +} + +function rules_core_node_make_sticky_action_upgrade_map_name($element) { + return 'node_make_sticky_action'; +} + +function rules_core_node_make_unsticky_action_upgrade_map_name($element) { + return 'node_make_unsticky_action'; +} + +function rules_core_node_promote_action_upgrade_map_name($element) { + return 'node_promote_action'; +} + +function rules_core_node_unpromote_action_upgrade_map_name($element) { + return 'node_unpromote_action'; +} + +/** + * Path.module integration. + */ +function rules_condition_url_has_alias_upgrade_map_name($element) { + return 'path_has_alias'; +} + +function rules_condition_url_has_alias_upgrade($element, $target) { + $target->settings['source'] = $element['#settings']['src']; + $target->settings['alias'] = $element['#settings']['dst']; +} + +function rules_condition_alias_exists_upgrade_map_name($element) { + return 'path_alias_exists'; +} + +function rules_condition_alias_exists_upgrade($element, $target) { + $target->settings['alias'] = $element['#settings']['dst']; +} + +function rules_action_path_alias_upgrade($element, $target) { + $target->settings['source'] = $element['#settings']['src']; + $target->settings['alias'] = $element['#settings']['dst']; +} + +function rules_action_node_path_alias_upgrade($element, $target) { + $target->settings['alias'] = $element['#settings']['dst']; +} + +/** + * PHP.module integration. + */ +function rules_condition_custom_php_upgrade_map_name($element) { + return 'php_eval'; +} + +function rules_action_custom_php_upgrade_map_name($element) { + return 'php_eval'; +} + +/** + * General Rules integration. + */ +function rules_condition_text_compare_upgrade_map_name($element) { + // @todo Support regex. + return 'data_is'; +} + +function rules_condition_text_compare_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'text1', 'data'); + rules_upgrade_element_parameter_settings($element, $target, 'text2', 'value'); +} + +function rules_condition_number_compare_upgrade_map_name($element) { + return 'data_is'; +} + +function rules_condition_number_compare_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'number1', 'data'); + rules_upgrade_element_parameter_settings($element, $target, 'number2', 'value'); +} + +function rules_condition_check_boolean_upgrade_map_name($element) { + return 'data_is'; +} + +function rules_condition_check_boolean_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'boolean', 'data'); + $target->settings['value'] = TRUE; +} + +function rules_action_invoke_set_upgrade_map_name($element) { + return 'component_' . $element['#info']['set']; +} + +function rules_action_invoke_set_upgrade($element, $target) { + foreach ($element['#info']['arguments'] as $name => $info) { + rules_upgrade_element_parameter_settings($element, $target, $name); + } +} + +function rules_action_save_variable_upgrade_map_name($element) { + return isset($element['#info']['new variables']) ? 'variable_add' : 'entity_save'; +} + +function rules_action_save_variable_upgrade($element, $target) { + $type = $element['#info']['arguments']['var_name']['default value']; + if (isset($element['#info']['new variables'])) { + $target->settings['type'] = $type; + rules_upgrade_element_parameter_settings($element, $target, $type, 'value'); + rules_upgrade_element_variable_settings($element, $target, $type, 'variable_added'); + } + else { + rules_upgrade_element_parameter_settings($element, $target, $type, 'entity'); + } +} + +/** + * System.module integration. + */ +function rules_action_set_breadcrumb_upgrade_map_name($element) { + return 'breadcrumb_set'; +} + +function rules_action_mail_to_user_upgrade_map_name($element) { + return 'mail'; +} + +function rules_action_mail_to_user_upgrade($element, $target) { + $target->settings['to:select'] = $element['#settings']['#argument map']['user'] . ':mail'; +} + +function rules_action_drupal_goto_upgrade_map_name($element) { + return 'redirect'; +} + +function rules_action_drupal_goto_upgrade($element, $target) { + $settings = $element['#settings']; + $target->settings['url'] = $settings['path']; + $target->settings['url'] .= $settings['query'] ? '?' . $settings['query'] : ''; + $target->settings['url'] .= $settings['fragment'] ? '#' . $settings['fragment'] : ''; + if ($settings['immediate']) { + drupal_set_message(t("Warning: The 'immediate' option for the page redirect action has been dropped in Rules 2.x.")); + } +} + +function rules_action_watchdog_upgrade_map_name($element) { + // @todo Support action in Rules 2.x! + return NULL; +} + +/** + * Taxonomy.module integration. + * + * @todo Finish. + */ +function rules_action_taxonomy_load_term_upgrade_map_name($element) { + return 'entity_fetch'; +} + +function rules_action_taxonomy_add_term_upgrade_map_name($element) { + return 'entity_create'; +} + +function rules_action_taxonomy_delete_term_upgrade_map_name($element) { + return 'entity_delete'; +} + +function rules_action_taxonomy_term_assign_to_content_upgrade_map_name($element) { + // @todo List. + return NULL; +} + +function rules_action_taxonomy_term_remove_from_content_upgrade_map_name($element) { + // @todo List. + return NULL; +} + +function rules_action_taxonomy_load_vocab_upgrade_map_name($element) { + return 'entity_fetch'; +} + +function rules_action_taxonomy_add_vocab_upgrade_map_name($element) { + return 'data_set'; +} + +/** + * User.module integration. + */ +function rules_condition_user_hasrole_upgrade_map_name($element) { + return 'user_has_role'; +} + +function rules_condition_user_hasrole_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'user', 'account'); +} + +function rules_condition_user_comparison_upgrade_map_name($element) { + return 'data_is'; +} + +function rules_condition_user_comparison_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'user1', 'data'); + rules_upgrade_element_parameter_settings($element, $target, 'user2', 'value'); +} + +function rules_action_user_addrole_upgrade_map_name($element) { + return 'user_add_role'; +} + +function rules_action_user_addrole_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'user', 'account'); +} + +function rules_action_user_removerole_upgrade_map_name($element) { + return 'user_remove_role'; +} + +function rules_action_user_removerole_upgrade($element, $target) { + rules_upgrade_element_parameter_settings($element, $target, 'user', 'account'); +} + +function rules_action_load_user_upgrade_map_name($element) { + if (!empty($element['#settings']['username'])) { + drupal_set_message(t('Warning: Directly upgrading the load user by name action is not supported.')); + } + return 'entity_fetch'; +} + +function rules_action_load_user_upgrade($element, $target) { + $target->settings['type'] = 'user'; + rules_upgrade_element_parameter_settings($element, $target, 'userid', 'id'); + rules_upgrade_element_variable_settings($element, $target, 'user_loaded', 'entity_fetched'); +} + +function rules_action_user_create_upgrade_map_name($element) { + return 'entity_create'; +} + +function rules_action_user_create_upgrade($element, $target) { + $target->settings['type'] = 'user'; + rules_upgrade_element_parameter_settings($element, $target, 'username', 'param_name'); + rules_upgrade_element_parameter_settings($element, $target, 'email', 'param_mail'); + rules_upgrade_element_variable_settings($element, $target, 'user_added', 'entity_created'); +} + +function rules_core_user_block_user_action_upgrade_map_name($element) { + return 'user_block'; +} + +function rules_core_user_block_user_action_upgrade($element, $target) { + $target->settings['account:select'] = $element['#settings']['#argument map']['user']; +} diff --git a/modules/comment.rules.inc b/modules/comment.rules.inc new file mode 100644 index 0000000..c966a26 --- /dev/null +++ b/modules/comment.rules.inc @@ -0,0 +1,102 @@ + t('comment'), + 'module' => 'comment', + 'access callback' => 'rules_comment_integration_access', + 'class' => 'RulesCommentEventHandler', + ); + return array( + 'comment_insert' => $defaults + array( + 'label' => t('After saving a new comment'), + 'variables' => array( + 'comment' => array('type' => 'comment', 'label' => t('created comment')), + ), + ), + 'comment_update' => $defaults + array( + 'label' => t('After updating an existing comment'), + 'variables' => array( + 'comment' => array( + 'type' => 'comment', + 'label' => t('updated comment'), + ), + 'comment_unchanged' => array( + 'type' => 'comment', + 'label' => t('unchanged comment'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'comment_presave' => $defaults + array( + 'label' => t('Before saving a comment'), + 'variables' => array( + 'comment' => array( + 'type' => 'comment', + 'label' => t('saved comment'), + 'skip save' => TRUE, + ), + 'comment_unchanged' => array( + 'type' => 'comment', + 'label' => t('unchanged comment'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'comment_view' => $defaults + array( + 'label' => t('A comment is viewed'), + 'variables' => array( + 'comment' => array('type' => 'comment', 'label' => t('viewed comment')), + ), + 'help' => t("Note that if drupal's page cache is enabled, this event won't be generated for pages served from cache."), + ), + 'comment_delete' => $defaults + array( + 'label' => t('After deleting a comment'), + 'variables' => array( + 'comment' => array('type' => 'comment', 'label' => t('deleted comment')), + ), + ), + ); +} + +/** + * Comment integration access callback. + */ +function rules_comment_integration_access($type, $name) { + if ($type == 'event' || $type == 'condition') { + return entity_access('view', 'comment'); + } +} + +/** + * Event handler support comment bundle event settings. + */ +class RulesCommentEventHandler extends RulesEventHandlerEntityBundle { + + /** + * Returns the label to use for the bundle property. + * + * @return string + * Returns the label to use for the bundle property. + */ + protected function getBundlePropertyLabel() { + return t('type'); + } + +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/data.eval.inc b/modules/data.eval.inc new file mode 100644 index 0000000..ca9d587 --- /dev/null +++ b/modules/data.eval.inc @@ -0,0 +1,464 @@ +set($value); + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException('Unable to modify data "@selector": ' . $e->getMessage(), array('@selector' => $settings['data:select'])); + } + // Save changes if a property of a variable has been changed. + if (strpos($element->settings['data:select'], ':') !== FALSE) { + $info = $wrapper->info(); + // We always have to save the changes in the parent entity. E.g. when the + // node author is changed, we don't want to save the author but the node. + $state->saveChanges(implode(':', explode(':', $settings['data:select'], -1)), $info['parent']); + } + } + else { + // A not wrapped variable (e.g. a number) is being updated. Just overwrite + // the variable with the new value. + return array('data' => $value); + } +} + +/** + * Info alter callback for the data_set action. + */ +function rules_action_data_set_info_alter(&$element_info, $element) { + $element->settings += array('data:select' => NULL); + if ($wrapper = $element->applyDataSelector($element->settings['data:select'])) { + $info = $wrapper->info(); + $element_info['parameter']['value']['type'] = $wrapper->type(); + $element_info['parameter']['value']['options list'] = !empty($info['options list']) ? 'rules_data_selector_options_list' : FALSE; + } +} + +/** + * Action: Calculate a value. + */ +function rules_action_data_calc($input1, $op, $input2, $settings, $state, $element) { + $info = $element->pluginParameterInfo(); + // Make sure to apply date offsets intelligently. + if ($info['input_1']['type'] == 'date' && $info['input_2']['type'] == 'duration') { + $input2 = ($op == '-') ? $input2 * -1 : $input2; + return array('result' => (int) RulesDateOffsetProcessor::applyOffset($input1, $input2)); + } + + switch ($op) { + case '+': + $result = $input1 + $input2; + break; + + case '-': + $result = $input1 - $input2; + break; + + case '*': + $result = $input1 * $input2; + break; + + case '/': + $result = $input1 / $input2; + break; + + case 'min': + $result = min($input1, $input2); + break; + + case 'max': + $result = max($input1, $input2); + break; + } + if (isset($result)) { + // Ensure results are valid integer values if necessary. + $variables = $element->providesVariables(); + $var_info = reset($variables); + if ($var_info['type'] == 'integer') { + $result = (int) $result; + } + return array('result' => $result); + } +} + +/** + * Info alter callback for the data_calc action. + */ +function rules_action_data_calc_info_alter(&$element_info, RulesPlugin $element) { + if ($info = $element->getArgumentInfo('input_1')) { + // Only allow durations as offset for date values. + if ($info['type'] == 'date') { + $element_info['parameter']['input_2']['type'] = 'duration'; + } + // Specify the data type of the result. + $element_info['provides']['result']['type'] = $info['type']; + + if ($info['type'] == 'integer' && ($info2 = $element->getArgumentInfo('input_2')) && $info2['type'] == 'decimal') { + $element_info['provides']['result']['type'] = 'decimal'; + } + // A division with two integers results in a decimal. + elseif (isset($element->settings['op']) && $element->settings['op'] == '/') { + $element_info['provides']['result']['type'] = 'decimal'; + } + } +} + +/** + * Action: Add a list item. + */ +function rules_action_data_list_add($list, $item, $unique = FALSE, $pos = 'end', $settings, $state) { + // Optionally, only add the list item if it is not yet contained. + if ($unique && rules_condition_data_list_contains($list, $item, $settings, $state)) { + return; + } + + switch ($pos) { + case 'start': + array_unshift($list, $item); + break; + + default: + $list[] = $item; + break; + } + return array('list' => $list); +} + +/** + * Info alteration callback for the "Add and Remove a list item" actions. + */ +function rules_data_list_info_alter(&$element_info, RulesAbstractPlugin $element) { + // Update the required type for the list item if it is known. + $element->settings += array('list:select' => NULL); + if ($wrapper = $element->applyDataSelector($element->settings['list:select'])) { + if ($type = entity_property_list_extract_type($wrapper->type())) { + $info = $wrapper->info(); + $element_info['parameter']['item']['type'] = $type; + $element_info['parameter']['item']['options list'] = !empty($info['options list']) ? 'rules_data_selector_options_list' : FALSE; + } + } +} + +/** + * Action: Remove a list item. + */ +function rules_action_data_list_remove($list, $item) { + foreach (array_keys($list, $item) as $key) { + unset($list[$key]); + } + return array('list' => $list); +} + +/** + * Action: Add variable. + */ +function rules_action_variable_add($args, $element) { + return array('variable_added' => $args['value']); +} + +/** + * Info alteration callback for variable add action. + */ +function rules_action_variable_add_info_alter(&$element_info, RulesAbstractPlugin $element) { + if (isset($element->settings['type']) && $type = $element->settings['type']) { + $cache = rules_get_cache(); + $type_info = $cache['data_info'][$type]; + $element_info['parameter']['value']['type'] = $type; + $element_info['provides']['variable_added']['type'] = $type; + + // For lists, we default to an empty list so subsequent actions can add + // items. + if (entity_property_list_extract_type($type)) { + $element_info['parameter']['value']['default value'] = array(); + } + } +} + +/** + * Action: Convert a value. + */ +function rules_action_data_convert($arguments, RulesPlugin $element, $state) { + + $value_info = $element->getArgumentInfo('value'); + $from_type = $value_info['type']; + $target_type = $arguments['type']; + + // First apply the rounding behavior if given. + if (isset($arguments['rounding_behavior'])) { + switch ($arguments['rounding_behavior']) { + case 'up': + $arguments['value'] = ceil($arguments['value']); + break; + + case 'down': + $arguments['value'] = floor($arguments['value']); + break; + + default: + case 'round': + $arguments['value'] = round($arguments['value']); + break; + } + } + + switch ($target_type) { + case 'decimal': + $result = floatval($arguments['value']); + break; + + case 'integer': + $result = intval($arguments['value']); + break; + + case 'text': + $result = strval($arguments['value']); + break; + + case 'token': + $result = strval($arguments['value']); + break; + } + + return array('conversion_result' => $result); +} + +/** + * Info alteration callback for variable add action. + */ +function rules_action_data_convert_info_alter(&$element_info, RulesAbstractPlugin $element) { + + if (isset($element->settings['type']) && $type = $element->settings['type']) { + $element_info['provides']['conversion_result']['type'] = $type; + + // Only support the rounding behavior option for integers. + if ($type == 'integer') { + $element_info['parameter']['rounding_behavior'] = array( + 'type' => 'token', + 'label' => t('Rounding behavior'), + 'description' => t('The rounding behavior the conversion should use.'), + 'options list' => 'rules_action_data_convert_rounding_behavior_options', + 'restriction' => 'input', + 'default value' => 'round', + 'optional' => TRUE, + ); + } + else { + unset($element_info['parameter']['rounding_behavior']); + } + + // Configure compatible source-types: + switch ($type) { + case 'integer': + $sources = array('decimal', 'text', 'token', 'uri', 'date', 'duration', 'boolean'); + break; + + case 'decimal': + $sources = array('integer', 'text', 'token', 'uri', 'date', 'duration', 'boolean'); + break; + + case 'text': + $sources = array('integer', 'decimal', 'token', 'uri', 'date', 'duration', 'boolean'); + break; + + case 'token': + $sources = array('integer', 'decimal', 'text', 'uri', 'date', 'duration', 'boolean'); + break; + } + $element_info['parameter']['value']['type'] = $sources; + } +} + +/** + * Action: Create data. + */ +function rules_action_data_create($args, $element) { + $type = $args['type']; + $values = array(); + foreach ($element->pluginParameterInfo() as $name => $info) { + if ($name != 'type') { + // Remove the parameter name prefix 'param_'. + $values[substr($name, 6)] = $args[$name]; + } + } + $cache = rules_get_cache(); + $type_info = $cache['data_info'][$type]; + if (isset($type_info['creation callback'])) { + try { + $data = $type_info['creation callback']($values, $type); + return array('data_created' => $data); + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException('Unable to create @data": ' . $e->getMessage(), array('@data' => $type), $element); + } + } + else { + throw new RulesEvaluationException('Unable to create @data, no creation callback found.', array('@data' => $type), $element, RulesLog::ERROR); + } +} + +/** + * Info alteration callback for data create action. + */ +function rules_action_data_create_info_alter(&$element_info, RulesAbstractPlugin $element) { + if (!empty($element->settings['type'])) { + $type = $element->settings['type']; + $cache = rules_get_cache(); + $type_info = $cache['data_info'][$type]; + if (isset($type_info['property info'])) { + // Add the data type's properties as parameters. + foreach ($type_info['property info'] as $property => $property_info) { + // Prefix parameter names to avoid name clashes with + // existing parameters. + $element_info['parameter']['param_' . $property] = array_intersect_key($property_info, array_flip(array('type', 'label', 'allow null'))); + if (empty($property_info['required'])) { + $element_info['parameter']['param_' . $property]['optional'] = TRUE; + $element_info['parameter']['param_' . $property]['allow null'] = TRUE; + } + } + } + $element_info['provides']['data_created']['type'] = $type; + } +} + +/** + * Creation callback for array structured data. + */ +function rules_action_data_create_array($values = array(), $type) { + // $values is an array already, so we can just pass it to the wrapper. + return rules_wrap_data($values, array('type' => $type)); +} + +/** + * Condition: Compare data. + */ +function rules_condition_data_is($data, $op, $value) { + switch ($op) { + default: + case '==': + // In case both values evaluate to FALSE, further differentiate between + // NULL values and values evaluating to FALSE. + if (!$data && !$value) { + return (isset($data) && isset($value)) || (!isset($data) && !isset($value)); + } + return $data == $value; + + case '<': + return $data < $value; + + case '>': + return $data > $value; + + // Note: This is deprecated by the text comparison condition and IN below. + case 'contains': + return is_string($data) && strpos($data, $value) !== FALSE || is_array($data) && in_array($value, $data); + + case 'IN': + return is_array($value) && in_array($data, $value); + } +} + +/** + * Info alteration callback for the data_is condition. + * + * If we check the bundle property of a variable, add an assertion so that later + * evaluated elements can make use of this information. + */ +function rules_condition_data_is_info_alter(&$element_info, RulesAbstractPlugin $element) { + $element->settings += array('data:select' => NULL, 'op' => '=='); + if ($wrapper = $element->applyDataSelector($element->settings['data:select'])) { + $info = $wrapper->info(); + $element_info['parameter']['value']['type'] = $element->settings['op'] == 'IN' ? 'list<' . $wrapper->type() . '>' : $wrapper->type(); + $element_info['parameter']['value']['options list'] = !empty($info['options list']) ? 'rules_data_selector_options_list' : FALSE; + } +} + +/** + * Condition: List contains. + */ +function rules_condition_data_list_contains($list, $item, $settings, $state) { + $wrapper = $state->currentArguments['item']; + if ($wrapper instanceof EntityStructureWrapper && $id = $wrapper->getIdentifier()) { + // Check for equal items using the identifier if there is one. + foreach ($state->currentArguments['list'] as $i) { + if ($i->getIdentifier() == $id) { + return TRUE; + } + } + return FALSE; + } + return in_array($item, $list); +} + +/** + * Condition: List count comparison. + */ +function rules_condition_data_list_count_is($list, $op = '==', $value) { + switch ($op) { + case '==': + return count($list) == $value; + + case '<': + return count($list) < $value; + + case '>': + return count($list) > $value; + } +} + +/** + * Condition: Data value is empty. + */ +function rules_condition_data_is_empty($data) { + // Note that some primitive variables might not be wrapped at all. + if ($data instanceof EntityMetadataWrapper) { + try { + // We cannot use the dataAvailable() method from the wrapper because it + // is protected, so we catch possible exceptions with the value() method. + $value = $data->value(); + return empty($value); + } + catch (EntityMetadataWrapperException $e) { + // An exception means that the wrapper is somehow broken and we treat + // that as empty. + return TRUE; + } + } + return empty($data); +} + +/** + * Condition: Textual comparison. + */ +function rules_data_text_comparison($text, $text2, $op = 'contains') { + switch ($op) { + case 'contains': + return strpos($text, $text2) !== FALSE; + + case 'starts': + return strpos($text, $text2) === 0; + + case 'ends': + return strrpos($text, $text2) === (strlen($text) - strlen($text2)); + + case 'regex': + return (bool) preg_match('/' . str_replace('/', '\\/', $text2) . '/', $text); + } +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/data.rules.inc b/modules/data.rules.inc new file mode 100644 index 0000000..6bb21fc --- /dev/null +++ b/modules/data.rules.inc @@ -0,0 +1,759 @@ + array( + 'label' => t('Data'), + 'equals group' => t('Data'), + 'weight' => -50, + ), + ); +} + +/** + * Implements hook_rules_file_info() on behalf of the pseudo data module. + * + * @see rules_core_modules() + */ +function rules_data_file_info() { + return array('modules/data.eval'); +} + +/** + * Implements hook_rules_action_info() on behalf of the pseudo data module. + * + * @see rules_core_modules() + */ +function rules_data_action_info() { + $return['data_set'] = array( + 'label' => t('Set a data value'), + 'parameter' => array( + 'data' => array( + 'type' => '*', + 'label' => t('Data'), + 'description' => t('Specifies the data to be modified using a data selector, e.g. "node:author:name".'), + 'restriction' => 'selector', + 'wrapped' => TRUE, + 'allow null' => TRUE, + ), + 'value' => array( + 'type' => '*', + 'label' => t('Value'), + 'description' => t('The new value to set for the specified data.'), + 'allow null' => TRUE, + 'optional' => TRUE, + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_set', + ); + $return['data_calc'] = array( + 'label' => t('Calculate a value'), + 'parameter' => array( + 'input_1' => array( + 'type' => array('decimal', 'date'), + 'label' => t('Input value 1'), + 'description' => t('The first input value for the calculation.'), + ), + 'op' => array( + 'type' => 'text', + 'label' => t('Operator'), + 'description' => t('The calculation operator.'), + 'options list' => 'rules_action_data_calc_operator_options', + 'restriction' => 'input', + 'default value' => '+', + ), + 'input_2' => array( + 'type' => 'decimal', + 'label' => t('Input value 2'), + 'description' => t('The second input value.'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_calc', + 'provides' => array( + 'result' => array( + 'type' => 'unknown', + 'label' => t('Calculation result'), + ), + ), + ); + $return['list_add'] = array( + 'label' => t('Add an item to a list'), + 'parameter' => array( + 'list' => array( + 'type' => 'list', + 'label' => t('List', array(), array('context' => 'data_types')), + 'description' => t('The data list, to which an item is to be added.'), + 'restriction' => 'selector', + 'allow null' => TRUE, + 'save' => TRUE, + ), + 'item' => array( + 'type' => 'unknown', + 'label' => t('Item to add'), + ), + 'unique' => array( + 'type' => 'boolean', + 'label' => t('Enforce uniqueness'), + 'description' => t('Only add the item to the list if it is not yet contained.'), + 'optional' => TRUE, + 'default value' => FALSE, + ), + 'pos' => array( + 'type' => 'text', + 'label' => t('Insert position'), + 'optional' => TRUE, + 'default value' => 'end', + 'options list' => 'rules_action_data_list_add_positions', + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_list_add', + 'callbacks' => array( + 'info_alter' => 'rules_data_list_info_alter', + 'form_alter' => 'rules_data_list_form_alter', + ), + ); + $return['list_remove'] = array( + 'label' => t('Remove an item from a list'), + 'parameter' => array( + 'list' => array( + 'type' => 'list', + 'label' => t('List', array(), array('context' => 'data_types')), + 'description' => t('The data list for which an item is to be removed.'), + 'restriction' => 'selector', + 'save' => TRUE, + ), + 'item' => array( + 'type' => 'unknown', + 'label' => t('Item to remove'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_list_remove', + 'callbacks' => array( + 'info_alter' => 'rules_data_list_info_alter', + 'form_alter' => 'rules_data_list_form_alter', + ), + ); + $return['variable_add'] = array( + 'label' => t('Add a variable'), + 'named parameter' => TRUE, + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Type'), + 'options list' => 'rules_data_action_variable_add_options', + 'description' => t('Specifies the type of the variable that should be added.'), + 'restriction' => 'input', + ), + 'value' => array( + 'type' => 'unknown', + 'label' => t('Value'), + 'optional' => TRUE, + 'description' => t('Optionally, specify the initial value of the variable.'), + ), + ), + 'provides' => array( + 'variable_added' => array( + 'type' => 'unknown', + 'label' => t('Added variable'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_variable_add', + 'callbacks' => array( + 'form_alter' => 'rules_action_type_form_alter', + 'validate' => 'rules_action_create_type_validate', + ), + ); + + if (rules_data_action_data_create_options()) { + $return['data_create'] = array( + 'label' => t('Create a data structure'), + 'named parameter' => TRUE, + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Type'), + 'options list' => 'rules_data_action_data_create_options', + 'description' => t('Specifies the type of the data structure that should be created.'), + 'restriction' => 'input', + ), + // Further needed parameters depend on the type. + ), + 'provides' => array( + 'data_created' => array( + 'type' => 'unknown', + 'label' => t('Created data'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_create', + 'callbacks' => array( + 'form_alter' => 'rules_action_type_form_alter', + 'validate' => 'rules_action_create_type_validate', + ), + ); + } + $return['data_convert'] = array( + 'label' => t('Convert data type'), + 'parameter' => array( + 'type' => array( + 'type' => 'token', + 'label' => t('Target type'), + 'description' => t('The data type to convert a value to.'), + 'options list' => 'rules_action_data_convert_types_options', + 'restriction' => 'input', + ), + 'value' => array( + 'type' => array('decimal', 'integer', 'text', 'token'), + 'label' => t('Value to convert'), + 'default mode' => 'selector', + ), + ), + 'provides' => array( + 'conversion_result' => array( + 'type' => 'unknown', + 'label' => t('Conversion result'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_action_data_convert', + 'named parameter' => TRUE, + 'callbacks' => array( + 'form_alter' => 'rules_action_type_form_alter', + ), + ); + return $return; +} + +/** + * Data conversation action: Options list callback for the target type. + */ +function rules_action_data_convert_types_options(RulesPlugin $element, $param_name) { + return array( + 'decimal' => t('Decimal'), + 'integer' => t('Integer'), + 'text' => t('Text'), + 'token' => t('Token'), + ); +} + +/** + * Data conversation action: Options list callback for rounding behavior. + */ +function rules_action_data_convert_rounding_behavior_options(RulesPlugin $element, $param_name) { + return array( + 'down' => t('Always down (9.5 -> 9)'), + 'round' => t('Round, half up (9.5 -> 10)'), + 'up' => t('Always up (9.5 -> 10)'), + ); +} + +/** + * Customize access check for data set action. + */ +function rules_action_data_set_access(RulesAbstractPlugin $element) { + if (isset($element->settings['data:select']) && $wrapper = $element->applyDataSelector($element->settings['data:select'])) { + return $wrapper instanceof EntityMetadataWrapper && $wrapper->access('edit'); + } +} + +/** + * Custom validation callback for the data set action. + */ +function rules_action_data_set_validate(RulesAbstractPlugin $element) { + $element->settings += array('data:select' => NULL); + $info = $element->applyDataSelector($element->settings['data:select'])->info(); + if (strpos($element->settings['data:select'], ':') !== FALSE && empty($info['setter callback'])) { + throw new RulesIntegrityException(t("The selected data property doesn't support writing."), array($element, 'parameter', 'data')); + } +} + +/** + * Form alter callback for the data_set action. + */ +function rules_action_data_set_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + if (!empty($options['init']) && !isset($form_state['rules_element_step'])) { + $form['negate']['#access'] = FALSE; + unset($form['parameter']['value']); + unset($form['parameter']['language']); + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Continue'), + '#limit_validation_errors' => array(array('parameter', 'data')), + '#submit' => array('rules_form_submit_rebuild'), + ); + $form_state['rules_element_step'] = 'data_value'; + // Clear the parameter mode for the value parameter, so its gets the proper + // default value based upon the type of the selected data on rebuild. + unset($form_state['parameter_mode']['value']); + } + else { + // Change the data parameter to be not editable. + $form['parameter']['data']['settings']['#access'] = FALSE; + // @todo Improve display. + $form['parameter']['data']['info'] = array( + '#prefix' => '

', + '#markup' => t('Selected data: %selector', array('%selector' => $element->settings['data:select'])), + '#suffix' => '

', + ); + } +} + +/** + * Form alter callback for the data calculation action. + */ +function rules_action_data_calc_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + + $form['reload'] = array( + '#weight' => 5, + '#type' => 'submit', + '#name' => 'reload', + '#value' => t('Reload form'), + '#limit_validation_errors' => array(array('parameter', 'input_1')), + '#submit' => array('rules_form_submit_rebuild'), + '#ajax' => rules_ui_form_default_ajax(), + ); +} + +/** + * Validate callback for entity create, add variable and data create actions. + */ +function rules_action_create_type_validate($element) { + if (!isset($element->settings['type'])) { + throw new RulesIntegrityException(t('Invalid type specified.'), array($element, 'parameter', 'type')); + } +} + +/** + * Form alter callback for the list add and remove actions. + * + * Use multiple steps to configure the action to update the item configuration + * form once we know the data type. + * + * @see rules_data_list_info_alter() + */ +function rules_data_list_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + if (!empty($options['init']) && !isset($form_state['rules_element_step'])) { + unset($form['parameter']['item'], $form['parameter']['pos']); + $form_state['rules_element_step'] = 1; + $form['negate']['#access'] = FALSE; + $form['parameter']['unique']['#access'] = FALSE; + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Continue'), + '#limit_validation_errors' => array(array('parameter', 'list')), + '#submit' => array('rules_form_submit_rebuild'), + ); + } + else { + // Change the list parameter to be not editable any more. + $form['parameter']['list']['settings']['#access'] = FALSE; + $form['parameter']['list']['info'] = array( + '#prefix' => '

', + '#markup' => t('Selected list: %selector', array('%selector' => $element->settings['list:select'])), + '#suffix' => '

', + ); + } +} + +/** + * Form alter callback for actions relying on the entity type or the data type. + */ +function rules_action_type_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + $first_step = empty($element->settings['type']); + $form['reload'] = array( + '#weight' => 5, + '#type' => 'submit', + '#name' => 'reload', + '#value' => $first_step ? t('Continue') : t('Reload form'), + '#limit_validation_errors' => array(array('parameter', 'type')), + '#submit' => array('rules_action_type_form_submit_rebuild'), + '#ajax' => rules_ui_form_default_ajax(), + ); + // Use ajax and trigger as the reload button. + $form['parameter']['type']['settings']['type']['#ajax'] = $form['reload']['#ajax'] + array( + 'event' => 'change', + 'trigger_as' => array('name' => 'reload'), + ); + + if ($first_step) { + // In the first step show only the type select. + foreach (element_children($form['parameter']) as $key) { + if ($key != 'type') { + unset($form['parameter'][$key]); + } + } + unset($form['submit']); + unset($form['provides']); + // Disable #ajax for the first step as it has troubles with lazy-loaded JS. + // @todo Re-enable once JS lazy-loading is fixed in core. + unset($form['parameter']['type']['settings']['type']['#ajax']); + unset($form['reload']['#ajax']); + } + else { + // Hide the reload button in case js is enabled and it's not the first step. + $form['reload']['#attributes'] = array('class' => array('rules-hide-js')); + } +} + +/** + * FAPI submit callback for reloading the type form for entities or data types. + */ +function rules_action_type_form_submit_rebuild($form, &$form_state) { + rules_form_submit_rebuild($form, $form_state); + // Clear the parameter modes for the parameters, so they get the proper + // default values based upon the data types on rebuild. + $form_state['parameter_mode'] = array(); +} + +/** + * Options list callback for possible insertion positions. + */ +function rules_action_data_list_add_positions() { + return array( + 'end' => t('Append the item to the end.'), + 'start' => t('Prepend the item to the front.'), + ); +} + +/** + * Options list callback for variable add action. + */ +function rules_data_action_variable_add_options() { + return RulesPluginUI::getOptions('data'); +} + +/** + * Options list callback for the data calculation action. + */ +function rules_action_data_calc_operator_options(RulesPlugin $element, $param_name) { + $options = array( + '+' => '( + )', + '-' => '( - )', + '*' => '( * )', + '/' => '( / )', + 'min' => 'min', + 'max' => 'max', + ); + // Only show +/- in case a date has been selected. + if (($info = $element->getArgumentInfo('input_1')) && $info['type'] == 'date') { + unset($options['*']); + unset($options['/']); + unset($options['min']); + unset($options['max']); + } + return $options; +} + +/** + * Options list callback for data create action. + */ +function rules_data_action_data_create_options() { + $cache = rules_get_cache(); + $data_info = $cache['data_info']; + $entity_info = entity_get_info(); + // Remove entities. + $data_info = array_diff_key($data_info, $entity_info); + $options = array(); + foreach ($data_info as $type => $properties) { + if (isset($properties['creation callback'])) { + // Add data types with creation callback only. + $options[$type] = $properties['label']; + } + } + natcasesort($options); + return $options; +} + +/** + * Implements hook_rules_condition_info() on behalf of the pseudo data module. + * + * @see rules_core_modules() + */ +function rules_data_condition_info() { + return array( + 'data_is' => array( + 'label' => t('Data comparison'), + 'parameter' => array( + 'data' => array( + 'type' => '*', + 'label' => t('Data to compare'), + 'description' => t('The data to be compared, specified by using a data selector, e.g. "node:author:name".'), + 'allow null' => TRUE, + ), + 'op' => array( + 'type' => 'text', + 'label' => t('Operator'), + 'description' => t('The comparison operator.'), + 'optional' => TRUE, + 'default value' => '==', + 'options list' => 'rules_condition_data_is_operator_options', + 'restriction' => 'input', + ), + 'value' => array( + 'type' => '*', + 'label' => t('Data value'), + 'description' => t('The value to compare the data with.'), + 'allow null' => TRUE, + ), + ), + 'group' => t('Data'), + 'base' => 'rules_condition_data_is', + ), + 'data_is_empty' => array( + 'label' => t('Data value is empty'), + 'parameter' => array( + 'data' => array( + 'type' => '*', + 'label' => t('Data to check'), + 'description' => t('The data to be checked to be empty, specified by using a data selector, e.g. "node:author:name".'), + 'allow null' => TRUE, + 'wrapped' => TRUE, + ), + ), + 'group' => t('Data'), + 'base' => 'rules_condition_data_is_empty', + ), + 'list_contains' => array( + 'label' => t('List contains item'), + 'parameter' => array( + 'list' => array( + 'type' => 'list', + 'label' => t('List', array(), array('context' => 'data_types')), + 'restriction' => 'selector', + ), + 'item' => array( + 'type' => 'unknown', + 'label' => t('Item'), + 'description' => t('The item to check for.'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_condition_data_list_contains', + 'callbacks' => array( + 'info_alter' => 'rules_data_list_info_alter', + 'form_alter' => 'rules_data_list_form_alter', + ), + ), + 'list_count_is' => array( + 'label' => t('List count comparison'), + 'parameter' => array( + 'list' => array( + 'type' => 'list', + 'label' => t('List to check'), + 'description' => t('A multi value data element to have its count compared, specified by using a data selector, eg node:author:roles.'), + ), + 'op' => array( + 'type' => 'text', + 'label' => t('Operator'), + 'description' => t('The comparison operator.'), + 'optional' => TRUE, + 'default value' => '==', + 'options list' => 'rules_condition_data_list_count_is_operator_options', + 'restriction' => 'input', + ), + 'value' => array( + 'type' => 'integer', + 'label' => t('Count'), + 'description' => t('The count to compare the data count with.'), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_condition_data_list_count_is', + ), + 'text_matches' => array( + 'label' => t('Text comparison'), + 'parameter' => array( + 'text' => array( + 'type' => 'text', + 'label' => t('Text'), + 'restriction' => 'selector', + ), + 'match' => array( + 'type' => 'text', + 'label' => t('Matching text'), + ), + 'operation' => array( + 'type' => 'text', + 'label' => t('Comparison operation'), + 'options list' => 'rules_data_text_comparison_operation_list', + 'restriction' => 'input', + 'default value' => 'contains', + 'optional' => TRUE, + 'description' => t('In case the comparison operation @regex is selected, the matching pattern will be interpreted as a regular expression. Tip: RegExr: Online Regular Expression Testing Tool is helpful for learning, writing, and testing Regular Expressions.', array('@regex-wikipedia' => 'http://en.wikipedia.org/wiki/Regular_expression', '@RegExr' => 'http://gskinner.com/RegExr/', '@regex' => t('regular expression'))), + ), + ), + 'group' => t('Data'), + 'base' => 'rules_data_text_comparison', + ), + ); +} + +/** + * Asserts the bundle of entities, if it's compared. + * + * If the bundle is compared, add the metadata assertion so other elements + * can make use of properties specific to the bundle. + */ +function rules_condition_data_is_assertions($element) { + // Assert the bundle of entities, if it's compared. + if ($wrapper = $element->applyDataSelector($element->settings['data:select'])) { + $info = $wrapper->info(); + if (isset($info['parent']) && $info['parent'] instanceof EntityDrupalWrapper) { + $entity_info = $info['parent']->entityInfo(); + if (isset($entity_info['entity keys']['bundle']) && $entity_info['entity keys']['bundle'] == $info['name']) { + // Assert that the entity is of bundle $value. + $value = is_array($element->settings['value']) ? $element->settings['value'] : array($element->settings['value']); + // Chop of the last part of the selector. + $parts = explode(':', $element->settings['data:select'], -1); + return array(implode(':', $parts) => array('bundle' => $value)); + } + } + } +} + +/** + * Form alter callback for the condition data_is. + * + * Use multiple steps to configure the condition as the needed type of the value + * depends on the selected data. + */ +function rules_condition_data_is_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + if (!empty($options['init']) && !isset($form_state['rules_element_step'])) { + unset($form['parameter']['op'], $form['parameter']['value']); + $form['negate']['#access'] = FALSE; + $form_state['rules_element_step'] = 'data_value'; + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Continue'), + '#limit_validation_errors' => array(array('parameter', 'data'), array('parameter', 'op')), + '#submit' => array('rules_form_submit_rebuild'), + ); + // Clear the parameter mode for the value parameter, so its gets the proper + // default value based upon the type of the the selected data on rebuild. + unset($form_state['parameter_mode']['value']); + } + else { + // Change the data parameter to be not editable. + $form['parameter']['data']['settings']['#access'] = FALSE; + // @todo Improve display. + $form['parameter']['data']['info'] = array( + '#prefix' => '

', + '#markup' => t('Selected data: %selector', array('%selector' => $element->settings['data:select'])), + '#suffix' => '

', + ); + + // Limit the operations to what makes sense for the selected data type. + $info = $element->pluginParameterInfo(); + $data_info = $info['value']; + if ($element->settings['op'] == 'IN') { + $data_info['type'] = entity_property_list_extract_type($data_info['type']); + } + + if (!RulesData::typesMatch($data_info, array('type' => array('decimal', 'date')))) { + $options =& $form['parameter']['op']['settings']['op']['#options']; + unset($options['<'], $options['>']); + } + // Remove 'contains' if it is not selected, as it is deprecated by the + // text comparison condition. + if ($element->settings['op'] != 'contains') { + unset($form['parameter']['op']['settings']['op']['#options']['contains']); + } + + // Auto-refresh the form if the operation is changed, so the input form + // changes in case "is one of" requires a list value. + $form['parameter']['op']['settings']['op']['#ajax'] = rules_ui_form_default_ajax() + array( + 'trigger_as' => array('name' => 'reload'), + ); + // Provide a reload button for non-JS users. + $form['reload'] = array( + '#type' => 'submit', + '#value' => t('Reload form'), + '#limit_validation_errors' => array(array('parameter', 'data'), array('parameter', 'op')), + '#submit' => array('rules_form_submit_rebuild'), + '#ajax' => rules_ui_form_default_ajax(), + '#weight' => 5, + ); + // Hide the reload button in case JS is enabled. + $form['reload']['#attributes'] = array('class' => array('rules-hide-js')); + } +} + +/** + * Provides configuration help for the data_is condition. + */ +function rules_condition_data_is_help() { + return array('#markup' => t('Compare two data values of the same type with each other.')); +} + +/** + * Options list callback for condition data_is. + */ +function rules_condition_data_is_operator_options() { + return array( + '==' => t('equals'), + 'IN' => t('is one of'), + '<' => t('is lower than'), + '>' => t('is greater than'), + // Note: This is deprecated by the text comparison condition. + 'contains' => t('contains'), + ); +} + +/** + * Options list callback for condition text_matches. + */ +function rules_data_text_comparison_operation_list() { + return array( + 'contains' => t('contains'), + 'starts' => t('starts with'), + 'ends' => t('ends with'), + 'regex' => t('regular expression'), + ); +} + +/** + * Returns the options list as specified by the selected property of the first parameter. + * + * @see rules_data_list_info_alter() + * @see rules_action_data_set_info_alter() + * @see rules_condition_data_is_info_alter() + */ +function rules_data_selector_options_list(RulesAbstractPlugin $element) { + $name = rules_array_key($element->pluginParameterInfo()); + // If the selected data property has an option list, make use of it. + if (isset($element->settings[$name . ':select']) && $wrapper = $element->applyDataSelector($element->settings[$name . ':select'])) { + return $wrapper->optionsList($element instanceof RulesActionInterface ? 'edit' : 'view'); + } +} + +/** + * Options list callback for condition list_count_is. + */ +function rules_condition_data_list_count_is_operator_options() { + return array( + '==' => t('equals'), + '<' => t('is lower than'), + '>' => t('is greater than'), + ); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/entity.eval.inc b/modules/entity.eval.inc new file mode 100644 index 0000000..73be318 --- /dev/null +++ b/modules/entity.eval.inc @@ -0,0 +1,186 @@ + $revision); + } + + $return = entity_load($type, array($id), isset($conditions) ? $conditions : array()); + $entity = reset($return); + if (!$entity) { + throw new RulesEvaluationException('Unable to load @entity with id "@id"', array('@id' => $id, '@entity' => $type)); + } + return array('entity_fetched' => $entity); +} + +/** + * Info alteration callback for the entity fetch action. + */ +function rules_action_entity_fetch_info_alter(&$element_info, RulesAbstractPlugin $element) { + $element->settings += array('type' => NULL); + $info = entity_get_info($element->settings['type']); + + // Fix the type of the identifier. + $element_info['parameter']['id']['type'] = isset($info['entity keys']['name']) ? 'text' : 'integer'; + + // Add an optional revision parameter, if supported. + if (!empty($info['entity keys']['revision'])) { + $element_info['parameter']['revision_id'] = array( + 'type' => 'integer', + 'label' => t('Revision identifier'), + 'optional' => TRUE, + ); + } + $element_info['provides']['entity_fetched']['type'] = $element->settings['type']; +} + +/** + * Action: Query entities. + */ +function rules_action_entity_query($type, $property, $value, $limit) { + $return = entity_property_query($type, $property, $value, $limit); + return array('entity_fetched' => array_values($return)); +} + +/** + * Info alteration callback for the entity query action. + */ +function rules_action_entity_query_info_alter(&$element_info, RulesAbstractPlugin $element) { + $element->settings += array('type' => NULL, 'property' => NULL); + if ($element->settings['type']) { + $element_info['parameter']['property']['options list'] = 'rules_action_entity_query_property_options_list'; + + if ($element->settings['property']) { + $wrapper = rules_get_entity_metadata_wrapper_all_properties($element); + if (isset($wrapper->{$element->settings['property']}) && $property = $wrapper->{$element->settings['property']}) { + $property_type = $property->type(); + // If the cardinality of the property > 1, i.e. of type 'list<{type}>', + // we will also accept a parameter of type {type}. + if (substr($property_type, 0, strlen('list<')) === 'list<' && substr($property_type, -strlen('>')) === '>') { + $property_type = array($property_type, substr($property_type, strlen('list<'), strlen($property_type) - strlen('list<>'))); + } + $element_info['parameter']['value']['type'] = $property_type; + $element_info['parameter']['value']['options list'] = $property->optionsList() ? 'rules_action_entity_query_value_options_list' : FALSE; + } + } + } + $element_info['provides']['entity_fetched']['type'] = 'list<' . $element->settings['type'] . '>'; +} + +/** + * Action: Create entities. + */ +function rules_action_entity_create($args, $element) { + $values = array(); + foreach ($element->pluginParameterInfo() as $name => $info) { + if ($name != 'type') { + // Remove the parameter name prefix 'param_'. + $values[substr($name, 6)] = $args[$name]; + } + } + try { + $data = entity_property_values_create_entity($args['type'], $values); + return array('entity_created' => $data); + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException('Unable to create entity @type": ' . $e->getMessage(), array('@type' => $args['type']), $element); + } +} + +/** + * Info alteration callback for the entity create action. + */ +function rules_action_entity_create_info_alter(&$element_info, RulesAbstractPlugin $element) { + if (!empty($element->settings['type']) && entity_get_info($element->settings['type'])) { + $wrapper = entity_metadata_wrapper($element->settings['type']); + // Add the data type's needed parameter for loading to the parameter info. + foreach ($wrapper as $name => $child) { + $info = $child->info(); + if (!empty($info['required'])) { + $info += array('type' => 'text'); + // Prefix parameter names to avoid name clashes + // with existing parameters. + $element_info['parameter']['param_' . $name] = array_intersect_key($info, array_flip(array('type', 'label', 'description'))); + $element_info['parameter']['param_' . $name]['options list'] = $child->optionsList() ? 'rules_action_entity_parameter_options_list' : FALSE; + } + } + $element_info['provides']['entity_created']['type'] = $element->settings['type']; + if (($bundleKey = $wrapper->entityKey('bundle')) && isset($element->settings['param_' . $bundleKey])) { + $element_info['provides']['entity_created']['bundle'] = $element->settings['param_' . $bundleKey]; + } + } +} + +/** + * Action: Save entities. + */ +function rules_action_entity_save($wrapper, $immediate = FALSE, $settings, $state, $element) { + $state->saveChanges($settings['data:select'], $wrapper, $immediate); +} + +/** + * Action: Delete entities. + */ +function rules_action_entity_delete($wrapper, $settings, $state, $element) { + try { + $wrapper->delete(); + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException($e->getMessage(), array(), $element); + } +} + +/** + * Condition: Entity is new. + */ +function rules_condition_entity_is_new($wrapper, $settings, $state, $element) { + return !$wrapper->getIdentifier() || !empty($wrapper->value()->is_new); +} + +/** + * Condition: Entity has field. + */ +function rules_condition_entity_has_field($wrapper, $field_name, $settings, $state) { + return isset($wrapper->$field_name) || isset($wrapper->value()->$field_name); +} + +/** + * Condition: Entity is of type. + */ +function rules_condition_entity_is_of_type($wrapper, $type) { + return $wrapper->type() == $type; +} + +/** + * Condition: Entity is of type and bundle. + */ +function rules_condition_entity_is_of_bundle($wrapper, $type, $bundles) { + return $wrapper->type() == $type && in_array($wrapper->getBundle(), $bundles); +} + +/** + * Condition: User has access to field. + */ +function rules_condition_entity_field_access(EntityDrupalWrapper $wrapper, $field_name, $op, $account = NULL) { + $field = field_info_field($field_name); + return !empty($field) && field_access($op, $field, $wrapper->type(), $wrapper->value(), $account = NULL); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/entity.rules.inc b/modules/entity.rules.inc new file mode 100644 index 0000000..daeb8d2 --- /dev/null +++ b/modules/entity.rules.inc @@ -0,0 +1,591 @@ + array( + 'label' => t('Entities'), + 'equals group' => t('Entities'), + 'weight' => -50, + ), + ); +} + +/** + * Implements hook_rules_action_info() on behalf of the entity module. + * + * @see rules_core_modules() + */ +function rules_entity_action_info() { + if (rules_entity_action_type_options('entity_fetch')) { + $return['entity_fetch'] = array( + 'label' => t('Fetch entity by id'), + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Entity type'), + 'options list' => 'rules_entity_action_type_options', + 'description' => t('Specifies the type of entity that should be fetched.'), + 'restriction' => 'input', + ), + 'id' => array('type' => 'unknown', 'label' => t('Identifier')), + ), + 'provides' => array( + 'entity_fetched' => array('type' => 'unknown', 'label' => t('Fetched entity')), + ), + 'group' => t('Entities'), + 'access callback' => 'rules_entity_action_access', + 'base' => 'rules_action_entity_fetch', + 'callbacks' => array( + 'access' => 'rules_action_entity_createfetch_access', + 'form_alter' => 'rules_action_type_form_alter', + ), + ); + $return['entity_query'] = array( + 'label' => t('Fetch entity by property'), + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Entity type'), + 'options list' => 'rules_entity_action_type_options', + 'description' => t('Specifies the type of the entity that should be fetched.'), + 'restriction' => 'input', + ), + 'property' => array( + 'type' => 'text', + 'label' => t('Property'), + 'description' => t('The property by which the entity is to be selected.'), + 'restriction' => 'input', + ), + 'value' => array( + 'type' => 'unknown', + 'label' => t('Value'), + 'description' => t('The property value of the entity to be fetched.'), + ), + 'limit' => array( + 'type' => 'integer', + 'label' => t('Limit result count'), + 'description' => t('Limit the maximum number of fetched entities.'), + 'optional' => TRUE, + 'default value' => '10', + ), + ), + 'provides' => array( + 'entity_fetched' => array('type' => 'list', 'label' => t('Fetched entity')), + ), + 'group' => t('Entities'), + 'access callback' => 'rules_entity_action_access', + 'base' => 'rules_action_entity_query', + 'callbacks' => array( + 'form_alter' => 'rules_action_type_form_alter', + ), + ); + } + + if (rules_entity_action_type_options('entity_create')) { + $return['entity_create'] = array( + 'label' => t('Create a new entity'), + 'named parameter' => TRUE, + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Entity type'), + 'options list' => 'rules_entity_action_type_options', + 'description' => t('Specifies the type of the entity that should be created.'), + 'restriction' => 'input', + ), + // Further needed parameter depends on the type. + ), + 'provides' => array( + 'entity_created' => array( + 'type' => 'unknown', + 'label' => t('Created entity'), + 'save' => TRUE, + ), + ), + 'group' => t('Entities'), + 'access callback' => 'rules_entity_action_access', + 'base' => 'rules_action_entity_create', + 'callbacks' => array( + 'access' => 'rules_action_entity_createfetch_access', + 'form_alter' => 'rules_action_type_form_alter', + 'validate' => 'rules_action_create_type_validate', + ), + ); + } + + $return['entity_save'] = array( + 'label' => t('Save entity'), + 'parameter' => array( + 'data' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity, which should be saved permanently.'), + 'restriction' => 'selector', + 'wrapped' => TRUE, + ), + 'immediate' => array( + 'type' => 'boolean', + 'label' => t('Force saving immediately'), + 'description' => t('Usually saving is postponed till the end of the evaluation, so that multiple saves can be fold into one. If this set, saving is forced to happen immediately.'), + 'default value' => FALSE, + 'optional' => TRUE, + 'restriction' => 'input', + ), + ), + 'group' => t('Entities'), + 'access callback' => 'rules_entity_action_access', + 'base' => 'rules_action_entity_save', + 'callbacks' => array( + 'access' => 'rules_action_entity_savedelete_access', + ), + ); + + $return['entity_delete'] = array( + 'label' => t('Delete entity'), + 'parameter' => array( + 'data' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity, which should be deleted permanently.'), + 'restriction' => 'selector', + 'wrapped' => TRUE, + ), + ), + 'group' => t('Entities'), + 'access callback' => 'rules_entity_action_access', + 'base' => 'rules_action_entity_delete', + 'callbacks' => array( + 'access' => 'rules_action_entity_savedelete_access', + ), + ); + return $return; +} + +/** + * Custom access callback for data create and fetch action. + */ +function rules_action_entity_createfetch_access(RulesAbstractPlugin $element) { + $op = $element->getElementName() == 'entity_create' ? 'create' : 'view'; + return entity_access($op, $element->settings['type']); +} + +/** + * Custom access callback for the data query action. + */ +function rules_action_entity_query_access(RulesAbstractPlugin $element) { + if (!rules_action_entity_createfetch_access($element)) { + return FALSE; + } + $properties = entity_get_all_property_info($element->settings['type']); + if (isset($element->settings['property']) && isset($properties[$element->settings['property']]['access callback'])) { + return call_user_func($properties[$element->settings['property']]['access callback'], 'view', $element->settings['property'], $element->settings['type'], NULL, NULL); + } + return TRUE; +} + +/** + * Options list callback for a parameter of entity_create. + */ +function rules_action_entity_parameter_options_list(RulesPlugin $element, $param_name) { + // Remove the parameter name prefix 'param_'. + $property_name = substr($param_name, 6); + $wrapper = entity_metadata_wrapper($element->settings['type']); + // The possible values of the "value" parameter are those of the data param. + return $wrapper->$property_name->optionsList(); +} + +/** + * Custom access callback for data save and delete action. + */ +function rules_action_entity_savedelete_access(RulesAbstractPlugin $element) { + if ($wrapper = $element->applyDataSelector($element->settings['data:select'])) { + $op = $element->getElementName() == 'entity_save' ? 'save' : 'delete'; + return $wrapper instanceof EntityDrupalWrapper && $wrapper->entityAccess($op); + } + return FALSE; +} + +/** + * Returns the options list for choosing a property of an entity type. + */ +function rules_action_entity_query_property_options_list(RulesAbstractPlugin $element) { + $element->settings += array('type' => NULL); + if ($element->settings['type']) { + $properties = entity_get_all_property_info($element->settings['type']); + return rules_extract_property($properties, 'label'); + } +} + +/** + * Returns the options list specified for the chosen property. + */ +function rules_action_entity_query_value_options_list(RulesAbstractPlugin $element) { + // Get the possible values for the selected property. + $element->settings += array('type' => NULL, 'property' => NULL); + if ($element->settings['type'] && $element->settings['property']) { + $wrapper = rules_get_entity_metadata_wrapper_all_properties($element); + + if (isset($wrapper->{$element->settings['property']}) && $property = $wrapper->{$element->settings['property']}) { + return $property->optionsList('view'); + } + } +} + +/** + * Options list callback for data actions. + * + * @param $element + * The element to return options for. + * @param $param + * The name of the parameter to return options for. + */ +function rules_entity_action_type_options($element, $name = NULL) { + // We allow calling this function with just the element name too. That way + // we ease manual re-use. + $name = is_object($element) ? $element->getElementName() : $element; + return ($name == 'entity_create') ? rules_entity_type_options('create') : rules_entity_type_options(); +} + +/** + * Returns options containing entity types having the given key set in the info. + * + * Additionally, we exclude all entity types that are marked as configuration. + */ +function rules_entity_type_options($key = NULL) { + $info = entity_get_info(); + $types = array(); + foreach ($info as $type => $entity_info) { + if (empty($entity_info['configuration']) && empty($entity_info['exportable'])) { + if (!isset($key) || entity_type_supports($type, $key)) { + $types[$type] = $entity_info['label']; + } + } + } + return $types; +} + +/** + * Options list callback for getting a list of possible entity bundles. + * + * @param $element + * The element to return options for. + */ +function rules_entity_bundle_options(RulesAbstractPlugin $element) { + $bundles = array(); + if (isset($element->settings['type'])) { + $entity_info = entity_get_info($element->settings['type']); + foreach ($entity_info['bundles'] as $bundle_name => $bundle_info) { + $bundles[$bundle_name] = $bundle_info['label']; + } + } + return $bundles; +} + +/** + * Entity actions access callback. + * + * Returns TRUE if at least one type is available for configuring the action. + */ +function rules_entity_action_access($type, $name) { + if ($name == 'entity_fetch' || $name == 'entity_create' || $name == 'entity_query') { + $types = array_keys(rules_entity_action_type_options($name)); + $op = $name == 'entity_create' ? 'create' : 'view'; + } + elseif ($name == 'entity_save' || $name == 'entity_delete') { + $types = array_keys(entity_get_info()); + $op = $name == 'entity_save' ? 'save' : 'delete'; + } + foreach ($types as $key => $type) { + if (!entity_access($op, $type)) { + unset($types[$key]); + } + } + return !empty($types); +} + +/** + * Implements hook_rules_condition_info() on behalf of the entity module. + * + * @see rules_core_modules() + */ +function rules_entity_condition_info() { + return array( + 'entity_is_new' => array( + 'label' => t('Entity is new'), + 'parameter' => array( + 'entity' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity for which to evaluate the condition.'), + 'restriction' => 'selector', + ), + ), + 'group' => t('Entities'), + 'base' => 'rules_condition_entity_is_new', + ), + 'entity_has_field' => array( + 'label' => t('Entity has field'), + 'parameter' => array( + 'entity' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity for which to evaluate the condition.'), + 'restriction' => 'selector', + ), + 'field' => array( + 'type' => 'text', + 'label' => t('Field'), + 'description' => t('The name of the field to check for.'), + 'options list' => 'rules_condition_entity_has_field_options', + 'restriction' => 'input', + ), + ), + 'group' => t('Entities'), + 'base' => 'rules_condition_entity_has_field', + ), + 'entity_is_of_type' => array( + 'label' => t('Entity is of type'), + 'parameter' => array( + 'entity' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity for which to evaluate the condition.'), + ), + 'type' => array( + 'type' => 'token', + 'label' => t('Entity type'), + 'description' => t('The entity type to check for.'), + 'options list' => 'rules_entity_action_type_options', + 'restriction' => 'input', + ), + ), + 'group' => t('Entities'), + 'base' => 'rules_condition_entity_is_of_type', + ), + 'entity_is_of_bundle' => array( + 'label' => t('Entity is of bundle'), + 'parameter' => array( + 'entity' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity for which to evaluate the condition.'), + ), + 'type' => array( + 'type' => 'token', + 'label' => t('Entity type'), + 'description' => t('The type of the checked entity.'), + 'options list' => 'rules_entity_action_type_options', + 'restriction' => 'input', + ), + 'bundle' => array( + 'type' => 'list', + 'label' => t('Entity bundle'), + 'description' => t('The condition is met if the entity is of one of the selected bundles.'), + 'options list' => 'rules_entity_bundle_options', + 'restriction' => 'input', + ), + ), + 'group' => t('Entities'), + 'base' => 'rules_condition_entity_is_of_bundle', + ), + 'entity_field_access' => array( + 'label' => t('User has field access'), + 'parameter' => array( + 'entity' => array( + 'type' => 'entity', + 'label' => t('Entity'), + 'description' => t('Specifies the entity for which to evaluate the condition.'), + 'restriction' => 'selector', + 'wrapped' => TRUE, + ), + 'field' => array( + 'type' => 'token', + 'label' => t('Field name'), + 'description' => t('The name of the field to check for.'), + 'options list' => 'rules_condition_entity_has_field_options', + 'restriction' => 'input', + ), + 'op' => array( + 'type' => 'text', + 'label' => t('Access operation'), + 'options list' => 'rules_condition_entity_field_access_op_options', + 'restriction' => 'input', + 'optional' => TRUE, + 'default value' => 'view', + ), + 'account' => array( + 'type' => 'user', + 'label' => t('User account'), + 'description' => t('Specifies the user account for which to check access. If left empty, the currently logged in user will be used.'), + 'restriction' => 'selector', + 'optional' => TRUE, + 'default value' => NULL, + ), + ), + 'group' => t('Entities'), + 'base' => 'rules_condition_entity_field_access', + ), + ); +} + +/** + * Help callback for condition entity_is_new. + */ +function rules_condition_entity_is_new_help() { + return t('This condition determines whether the specified entity has just been created and has not yet been saved to the database.'); +} + +/** + * Returns options for choosing a field for the selected entity. + */ +function rules_condition_entity_has_field_options(RulesAbstractPlugin $element) { + // The field_info_field_map() function was introduced in Drupal 7.22. See + // https://www.drupal.org/node/1915646. + if (function_exists('field_info_field_map')) { + $fields = field_info_field_map(); + } + else { + $fields = field_info_fields(); + } + $field_list = drupal_map_assoc(array_keys($fields)); + ksort($field_list); + return $field_list; +} + +/** + * Returns options for choosing a field_access() operation. + */ +function rules_condition_entity_field_access_op_options(RulesAbstractPlugin $element) { + return array( + 'view' => t('View'), + 'edit' => t('Edit'), + ); +} + +/** + * Assert that the entity has the field, if there is metadata for the field. + */ +function rules_condition_entity_has_field_assertions($element) { + // Assert the field is there if the condition matches. + if ($wrapper = $element->applyDataSelector($element->settings['entity:select'])) { + $type = $wrapper->type(); + $field_property = $element->settings['field']; + // Get all possible properties and check whether we have one for the field. + $properties = entity_get_all_property_info($type == 'entity' ? NULL : $type); + + if (isset($properties[$field_property])) { + $assertion = array('property info' => array($field_property => $properties[$field_property])); + return array($element->settings['entity:select'] => $assertion); + } + } +} + +/** + * Assert the selected entity type. + */ +function rules_condition_entity_is_of_type_assertions($element) { + if ($type = $element->settings['type']) { + return array('entity' => array('type' => $type)); + } +} + +/** + * Assert the selected entity type and bundle. + */ +function rules_condition_entity_is_of_bundle_assertions($element) { + if ($bundle = $element->settings['bundle']) { + $assertions = array(); + $assertions['entity']['type'] = $element->settings['type']; + $assertions['entity']['bundle'] = $bundle; + return $assertions; + } +} + +/** + * Process callback for the condition entity_is_of_bundle. + */ +function rules_condition_entity_is_of_bundle_process(RulesAbstractPlugin $element) { + // If we know the entity type, auto-populate it. + if (($info = $element->getArgumentInfo('entity')) && $info['type'] != 'entity') { + $element->settings['type'] = $info['type']; + } +} + +/** + * Form alter callback for the condition entity_is_of_bundle. + * + * Use multiple steps to configure the condition as the needed bundle field list + * depends on the selected entity type. + */ +function rules_condition_entity_is_of_bundle_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + if (empty($element->settings['entity:select'])) { + $step = 1; + } + elseif (empty($element->settings['type'])) { + $step = 2; + } + else { + $step = 3; + } + + $form['reload'] = array( + '#weight' => $form['submit']['#weight'] + 1, + '#type' => 'submit', + '#name' => 'reload', + '#value' => $step != 3 ? t('Continue') : t('Reload form'), + '#limit_validation_errors' => array(array('parameter', 'entity'), array('parameter', 'type')), + '#submit' => array('rules_form_submit_rebuild'), + '#ajax' => rules_ui_form_default_ajax('fade'), + '#attributes' => array('class' => array('rules-hide-js')), + ); + // Use ajax and trigger as the reload button. + $form['parameter']['type']['settings']['type']['#ajax'] = $form['reload']['#ajax'] + array( + 'event' => 'change', + 'trigger_as' => array('name' => 'reload'), + ); + + switch ($step) { + case 1: + $form['reload']['#limit_validation_errors'] = array(array('parameter', 'entity')); + unset($form['parameter']['type']); + unset($form['reload']['#attributes']['class']); + // NO break. + case 2: + $form['negate']['#access'] = FALSE; + unset($form['parameter']['bundle']); + unset($form['submit']); + break; + + case 3: + if (($info = $element->getArgumentInfo('entity')) && $info['type'] != 'entity') { + // Hide the entity type parameter if not needed. + unset($form['parameter']['type']); + } + break; + } +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/events.inc b/modules/events.inc new file mode 100644 index 0000000..04e43ee --- /dev/null +++ b/modules/events.inc @@ -0,0 +1,225 @@ +original)) { + return $entity->original; + } +} + +/* + * Generic entity events, used for core-entities for which we provide Rules + * integration only. We are implementing the generic-entity hooks instead of + * the entity-type specific hooks to ensure we come last. + * See https://www.drupal.org/node/1211946 for details. + */ + +/** + * Implements hook_entity_view(). + */ +function rules_entity_view($entity, $type, $view_mode, $langcode) { + switch ($type) { + case 'comment': + rules_invoke_event('comment_view--' . $entity->node_type, $entity, $view_mode); + rules_invoke_event('comment_view', $entity, $view_mode); + break; + + case 'node': + rules_invoke_event('node_view--' . $entity->type, $entity, $view_mode); + rules_invoke_event('node_view', $entity, $view_mode); + break; + + case 'user': + rules_invoke_event('user_view', $entity, $view_mode); + break; + } +} + +/** + * Implements hook_entity_presave(). + */ +function rules_entity_presave($entity, $type) { + switch ($type) { + case 'comment': + rules_invoke_event('comment_presave--' . $entity->node_type, $entity); + rules_invoke_event('comment_presave', $entity); + break; + + case 'node': + rules_invoke_event('node_presave--' . $entity->type, $entity); + rules_invoke_event('node_presave', $entity); + break; + + case 'taxonomy_term': + rules_invoke_event('taxonomy_term_presave--' . $entity->vocabulary_machine_name, $entity); + rules_invoke_event('taxonomy_term_presave', $entity); + break; + + case 'taxonomy_vocabulary': + case 'user': + rules_invoke_event($type . '_presave', $entity); + break; + } +} + +/** + * Implements hook_entity_update(). + */ +function rules_entity_update($entity, $type) { + switch ($type) { + case 'comment': + rules_invoke_event('comment_update--' . $entity->node_type, $entity); + rules_invoke_event('comment_update', $entity); + break; + + case 'node': + rules_invoke_event('node_update--' . $entity->type, $entity); + rules_invoke_event('node_update', $entity); + break; + + case 'taxonomy_term': + rules_invoke_event('taxonomy_term_update--' . $entity->vocabulary_machine_name, $entity); + rules_invoke_event('taxonomy_term_update', $entity); + break; + + case 'taxonomy_vocabulary': + case 'user': + rules_invoke_event($type . '_update', $entity); + break; + } +} + +/** + * Implements hook_entity_insert(). + */ +function rules_entity_insert($entity, $type) { + switch ($type) { + case 'comment': + rules_invoke_event('comment_insert--' . $entity->node_type, $entity); + rules_invoke_event('comment_insert', $entity); + break; + + case 'node': + rules_invoke_event('node_insert--' . $entity->type, $entity); + rules_invoke_event('node_insert', $entity); + break; + + case 'taxonomy_term': + rules_invoke_event('taxonomy_term_insert--' . $entity->vocabulary_machine_name, $entity); + rules_invoke_event('taxonomy_term_insert', $entity); + break; + + case 'taxonomy_vocabulary': + case 'user': + rules_invoke_event($type . '_insert', $entity); + break; + } +} + +/** + * Implements hook_entity_delete(). + */ +function rules_entity_delete($entity, $type) { + switch ($type) { + case 'comment': + rules_invoke_event('comment_delete--' . $entity->node_type, $entity); + rules_invoke_event('comment_delete', $entity); + break; + + case 'node': + rules_invoke_event('node_delete--' . $entity->type, $entity); + rules_invoke_event('node_delete', $entity); + break; + + case 'taxonomy_term': + rules_invoke_event('taxonomy_term_delete--' . $entity->vocabulary_machine_name, $entity); + rules_invoke_event('taxonomy_term_delete', $entity); + break; + + case 'taxonomy_vocabulary': + case 'user': + rules_invoke_event($type . '_delete', $entity); + break; + } +} + +/** + * Implements hook_user_login(). + */ +function rules_user_login(&$edit, $account) { + rules_invoke_event('user_login', $account); +} + +/** + * Implements hook_user_logout(). + */ +function rules_user_logout($account) { + rules_invoke_event('user_logout', $account); +} + +/* + * System events. + * + * Note that rules_init() is the main module file is used to + * invoke the init event. + */ + +/** + * Implements hook_cron(). + */ +function rules_cron() { + rules_invoke_event('cron'); +} + +/** + * Implements hook_watchdog(). + */ +function rules_watchdog($log_entry) { + rules_invoke_event('watchdog', $log_entry); +} + +/** + * Getter callback for the log entry message property. + */ +function rules_system_log_get_message($log_entry) { + return t($log_entry['message'], (array) $log_entry['variables']); +} + +/** + * Gets all view modes of an entity for an entity_view event. + */ +function rules_get_entity_view_modes($name, $var_info) { + // Read the entity type from a special key out of the variable info. + $entity_type = $var_info['options list entity type']; + $info = entity_get_info($entity_type); + foreach ($info['view modes'] as $mode => $mode_info) { + $modes[$mode] = $mode_info['label']; + } + return $modes; +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/node.eval.inc b/modules/node.eval.inc new file mode 100644 index 0000000..63278d9 --- /dev/null +++ b/modules/node.eval.inc @@ -0,0 +1,143 @@ + array( + 'node' => array('type' => 'node', 'label' => t('Content')), + ), + 'category' => 'node', + 'access callback' => 'rules_node_integration_access', + ); + } + +} + +/** + * Condition: Check for selected content types. + */ +class RulesNodeConditionType extends RulesNodeConditionBase { + + /** + * Defines the condition. + */ + public static function getInfo() { + $info = self::defaults() + array( + 'name' => 'node_is_of_type', + 'label' => t('Content is of type'), + 'help' => t('Evaluates to TRUE if the given content is of one of the selected content types.'), + ); + $info['parameter']['type'] = array( + 'type' => 'list', + 'label' => t('Content types'), + 'options list' => 'node_type_get_names', + 'description' => t('The content type(s) to check for.'), + 'restriction' => 'input', + ); + return $info; + } + + /** + * Executes the condition. + */ + public function execute($node, $types) { + return in_array($node->type, $types); + } + + /** + * Provides the content type of a node as asserted metadata. + */ + public function assertions() { + return array('node' => array('bundle' => $this->element->settings['type'])); + } + +} + +/** + * Condition: Check if the node is published. + */ +class RulesNodeConditionPublished extends RulesNodeConditionBase { + + /** + * Defines the condition. + */ + public static function getInfo() { + return self::defaults() + array( + 'name' => 'node_is_published', + 'label' => t('Content is published'), + ); + } + + /** + * Executes the condition. + */ + public function execute($node) { + return $node->status == 1; + } + +} + +/** + * Condition: Check if the node is sticky. + */ +class RulesNodeConditionSticky extends RulesNodeConditionBase { + + /** + * Defines the condition. + */ + public static function getInfo() { + return self::defaults() + array( + 'name' => 'node_is_sticky', + 'label' => t('Content is sticky'), + ); + } + + /** + * Executes the condition. + */ + public function execute($node) { + return $node->sticky == 1; + } + +} + +/** + * Condition: Check if the node is promoted to the frontpage. + */ +class RulesNodeConditionPromoted extends RulesNodeConditionBase { + + /** + * Defines the condition. + */ + public static function getInfo() { + return self::defaults() + array( + 'name' => 'node_is_promoted', + 'label' => t('Content is promoted to frontpage'), + ); + } + + /** + * Executes the condition. + */ + public function execute($node) { + return $node->promote == 1; + } + +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/node.rules.inc b/modules/node.rules.inc new file mode 100644 index 0000000..40e4bb3 --- /dev/null +++ b/modules/node.rules.inc @@ -0,0 +1,163 @@ + array( + 'label' => t('Node'), + 'equals group' => t('Node'), + ), + ); +} + +/** + * Implements hook_rules_file_info() on behalf of the node module. + */ +function rules_node_file_info() { + return array('modules/node.eval'); +} + +/** + * Implements hook_rules_event_info() on behalf of the node module. + */ +function rules_node_event_info() { + $items = array( + 'node_insert' => array( + 'label' => t('After saving new content'), + 'category' => 'node', + 'variables' => rules_events_node_variables(t('created content')), + 'access callback' => 'rules_node_integration_access', + 'class' => 'RulesNodeEventHandler', + ), + 'node_update' => array( + 'label' => t('After updating existing content'), + 'category' => 'node', + 'variables' => rules_events_node_variables(t('updated content'), TRUE), + 'access callback' => 'rules_node_integration_access', + 'class' => 'RulesNodeEventHandler', + ), + 'node_presave' => array( + 'label' => t('Before saving content'), + 'category' => 'node', + 'variables' => rules_events_node_variables(t('saved content'), TRUE), + 'access callback' => 'rules_node_integration_access', + 'class' => 'RulesNodeEventHandler', + ), + 'node_view' => array( + 'label' => t('Content is viewed'), + 'category' => 'node', + 'help' => t("Note that if drupal's page cache is enabled, this event won't be generated for pages served from cache."), + 'variables' => rules_events_node_variables(t('viewed content')) + array( + 'view_mode' => array( + 'type' => 'text', + 'label' => t('view mode'), + 'options list' => 'rules_get_entity_view_modes', + // Add the entity-type for the options list callback. + 'options list entity type' => 'node', + ), + ), + 'access callback' => 'rules_node_integration_access', + 'class' => 'RulesNodeEventHandler', + ), + 'node_delete' => array( + 'label' => t('After deleting content'), + 'category' => 'node', + 'variables' => rules_events_node_variables(t('deleted content')), + 'access callback' => 'rules_node_integration_access', + 'class' => 'RulesNodeEventHandler', + ), + ); + // Specify that on presave the node is saved anyway. + $items['node_presave']['variables']['node']['skip save'] = TRUE; + return $items; +} + +/** + * Returns some parameter suitable for using it with a node. + */ +function rules_events_node_variables($node_label, $update = FALSE) { + $args = array( + 'node' => array('type' => 'node', 'label' => $node_label), + ); + if ($update) { + $args += array( + 'node_unchanged' => array( + 'type' => 'node', + 'label' => t('unchanged content'), + 'handler' => 'rules_events_entity_unchanged', + ), + ); + } + return $args; +} + +/** + * Implements hook_rules_action_info() on behalf of the node module. + */ +function rules_node_action_info() { + $defaults = array( + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content'), 'save' => TRUE), + ), + 'category' => 'node', + 'access callback' => 'rules_node_admin_access', + ); + // Add support for hand-picked core actions. + $core_actions = node_action_info(); + $actions = array('node_publish_action', 'node_unpublish_action', 'node_make_sticky_action', 'node_make_unsticky_action', 'node_promote_action', 'node_unpromote_action'); + foreach ($actions as $base) { + $action_name = str_replace('_action', '', $base); + $items[$action_name] = $defaults + array( + 'label' => $core_actions[$base]['label'], + 'base' => $base, + ); + } + return $items; +} + +/** + * Node integration access callback. + */ +function rules_node_integration_access($type, $name) { + if ($type == 'event' || $type == 'condition') { + return entity_access('view', 'node'); + } +} + +/** + * Node integration admin access callback. + */ +function rules_node_admin_access() { + return user_access('administer nodes'); +} + +/** + * Event handler support node bundle event settings. + */ +class RulesNodeEventHandler extends RulesEventHandlerEntityBundle { + + /** + * Returns the label to use for the bundle property. + * + * @return string + */ + protected function getBundlePropertyLabel() { + return t('type'); + } + +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/path.eval.inc b/modules/path.eval.inc new file mode 100644 index 0000000..536abef --- /dev/null +++ b/modules/path.eval.inc @@ -0,0 +1,152 @@ + $source, 'language' => $langcode)); + } + elseif (!$source) { + path_delete(array('alias' => $alias, 'language' => $langcode)); + } + // Only set the alias if the alias is not taken yet. + elseif (!path_load(array('alias' => $alias, 'language' => $langcode))) { + // Update the existing path or create a new one. + if ($path = path_load(array('source' => $source, 'language' => $langcode))) { + $path['alias'] = $alias; + } + else { + $path = array('source' => $source, 'alias' => $alias, 'language' => $langcode); + } + path_save($path); + } + else { + rules_log('The configured alias %alias already exists. Aborting.', array('%alias' => $alias)); + } +} + +/** + * Action Implementation: Set the URL alias for a node. + */ +function rules_action_node_path_alias($node, $alias) { + $langcode = isset($node->language) ? $node->language : LANGUAGE_NONE; + // Only set the alias if the alias is not taken yet. + if (($path = path_load(array('alias' => $alias, 'language' => $langcode))) && (empty($node->path['pid']) || $node->path['pid'] != $path['pid'])) { + rules_log('The configured alias %alias already exists. Aborting.', array('%alias' => $alias)); + return FALSE; + } + $node->path['alias'] = $alias; +} + +/** + * Action Implementation: Set the URL alias for a node. + */ +function rules_action_taxonomy_term_path_alias($term, $alias) { + // Only set the alias if the alias is not taken yet. + if (($path = path_load(array('alias' => $alias, 'language' => LANGUAGE_NONE))) && (empty($term->path['pid']) || $term->path['pid'] != $path['pid'])) { + rules_log('The configured alias %alias already exists. Aborting.', array('%alias' => $alias)); + return FALSE; + } + $term->path['alias'] = $alias; +} + +/** + * Condition implementation: Check if the path has an alias. + */ +function rules_condition_path_has_alias($source, $langcode = LANGUAGE_NONE) { + return (bool) drupal_lookup_path('alias', $source, $langcode); +} + +/** + * Condition implementation: Check if the URL alias exists. + */ +function rules_condition_path_alias_exists($alias, $langcode = LANGUAGE_NONE) { + return (bool) drupal_lookup_path('source', $alias, $langcode); +} + +/** + * Cleans the given path. + * + * A path is cleaned by replacing non ASCII characters in the path with the + * replacement character. + * + * Path cleaning may be customized by overriding the configuration variables: + * @code rules_clean_path @endcode, + * @code rules_path_replacement_char @endcode and + * @code rules_path_transliteration @endcode + * in the site's settings.php file. + */ +function rules_path_default_cleaning_method($path) { + $replace = variable_get('rules_path_replacement_char', '-'); + if ($replace) { + // If the transliteration module is enabled, transliterate the alias first. + if (module_exists('transliteration') && variable_get('rules_path_transliteration', TRUE)) { + $path = transliteration_get($path); + } + + $array = variable_get('rules_clean_path', array('/[^a-zA-Z0-9\-_]+/', $replace)); + $array[2] = $path; + // Replace it and remove trailing and leading replacement characters. + $output = trim(call_user_func_array('preg_replace', $array), $replace); + + if (variable_get('rules_path_lower_case', TRUE)) { + $output = drupal_strtolower($output); + } + return $output; + } + else { + return $path; + } +} + +/** + * Cleans the given string so it can be used as part of a URL path. + */ +function rules_clean_path($path) { + $function = variable_get('rules_path_cleaning_callback', 'rules_path_default_cleaning_method'); + if (!function_exists($function)) { + rules_log('An invalid URL path cleaning callback has been configured. Falling back to the default cleaning method.', array(), RulesLog::WARN); + $function = 'rules_path_default_cleaning_method'; + } + return $function($path); +} + +/** + * CTools path cleaning callback. + * + * @see rules_admin_settings() + */ +function rules_path_clean_ctools($path) { + // Make use of the CTools cleanstring implementation. + ctools_include('cleanstring'); + $settings = array( + 'separator' => variable_get('rules_path_replacement_char', '-'), + 'transliterate' => module_exists('transliteration') && variable_get('rules_path_transliteration', TRUE), + 'lower case' => variable_get('rules_path_lower_case', TRUE), + ); + return ctools_cleanstring($path, $settings); +} + +/** + * Pathauto path cleaning callback. + * + * @see rules_admin_settings() + */ +function rules_path_clean_pathauto($path) { + module_load_include('inc', 'pathauto'); + return pathauto_cleanstring($path); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/path.rules.inc b/modules/path.rules.inc new file mode 100644 index 0000000..4694def --- /dev/null +++ b/modules/path.rules.inc @@ -0,0 +1,173 @@ + array( + 'label' => t('Create or delete any URL alias'), + 'group' => t('Path'), + 'parameter' => array( + 'source' => array( + 'type' => 'text', + 'label' => t('Existing system path'), + 'description' => t('Specifies the existing path you wish to alias. For example: node/28, forum/1, taxonomy/term/1+2.') . ' ' . t('Leave it empty to delete URL aliases pointing to the given path alias.'), + 'optional' => TRUE, + ), + 'alias' => array( + 'type' => 'text', + 'label' => t('URL alias'), + 'description' => t('Specify an alternative path by which this data can be accessed. For example, "about" for an about page. Use a relative path and do not add a trailing slash.') . ' ' . t('Leave it empty to delete URL aliases pointing to the given system path.'), + 'optional' => TRUE, + 'cleaning callback' => 'rules_path_clean_replacement_values', + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('If specified, the language for which the URL alias applies.'), + 'options list' => 'entity_metadata_language_list', + 'optional' => TRUE, + 'default value' => LANGUAGE_NONE, + ), + ), + 'base' => 'rules_action_path_alias', + 'callbacks' => array('dependencies' => 'rules_path_dependencies'), + 'access callback' => 'rules_path_integration_access', + ), + 'node_path_alias' => array( + 'label' => t("Create or delete a content's URL alias"), + 'group' => t('Path'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Content'), + 'save' => TRUE, + ), + 'alias' => array( + 'type' => 'text', + 'label' => t('URL alias'), + 'description' => t('Specify an alternative path by which the content can be accessed. For example, "about" for an about page. Use a relative path and do not add a trailing slash.') . ' ' . t('Leave it empty to delete the URL alias.'), + 'optional' => TRUE, + 'cleaning callback' => 'rules_path_clean_replacement_values', + ), + ), + 'base' => 'rules_action_node_path_alias', + 'callbacks' => array('dependencies' => 'rules_path_dependencies'), + 'access callback' => 'rules_path_integration_access', + ), + 'taxonomy_term_path_alias' => array( + 'label' => t("Create or delete a taxonomy term's URL alias"), + 'group' => t('Path'), + 'parameter' => array( + 'node' => array( + 'type' => 'taxonomy_term', + 'label' => t('Taxonomy term'), + 'save' => TRUE, + ), + 'alias' => array( + 'type' => 'text', + 'label' => t('URL alias'), + 'description' => t('Specify an alternative path by which the term can be accessed. For example, "content/drupal" for a Drupal term. Use a relative path and do not add a trailing slash.') . ' ' . t('Leave it empty to delete the URL alias.'), + 'optional' => TRUE, + 'cleaning callback' => 'rules_path_clean_replacement_values', + ), + ), + 'base' => 'rules_action_node_path_alias', + 'callbacks' => array('dependencies' => 'rules_path_dependencies'), + 'access callback' => 'rules_path_integration_access', + ), + ); +} + +/** + * Callback to specify the path module as dependency. + */ +function rules_path_dependencies() { + return array('path'); +} + +/** + * Path integration access callback. + */ +function rules_path_integration_access($type, $name) { + if ($type == 'action' && $name == 'path_alias') { + return user_access('administer url aliases'); + } + return user_access('create url aliases'); +} + +/** + * Implements hook_rules_condition_info() on behalf of the path module. + */ +function rules_path_condition_info() { + return array( + 'path_has_alias' => array( + 'label' => t('Path has URL alias'), + 'group' => t('Path'), + 'parameter' => array( + 'source' => array( + 'type' => 'text', + 'label' => t('Existing system path'), + 'description' => t('Specifies the existing path you wish to check for. For example: node/28, forum/1, taxonomy/term/1+2.'), + 'optional' => TRUE, + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('If specified, the language for which the URL alias applies.'), + 'options list' => 'entity_metadata_language_list', + 'optional' => TRUE, + 'default value' => LANGUAGE_NONE, + ), + ), + 'base' => 'rules_condition_path_has_alias', + 'callbacks' => array('dependencies' => 'rules_path_dependencies'), + 'access callback' => 'rules_path_integration_access', + ), + 'path_alias_exists' => array( + 'label' => t('URL alias exists'), + 'group' => t('Path'), + 'parameter' => array( + 'alias' => array( + 'type' => 'text', + 'label' => t('URL alias'), + 'description' => t('Specify the URL alias to check for. For example, "about" for an about page.'), + 'optional' => TRUE, + 'cleaning callback' => 'rules_path_clean_replacement_values', + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('If specified, the language for which the URL alias applies.'), + 'options list' => 'entity_metadata_language_list', + 'optional' => TRUE, + 'default value' => LANGUAGE_NONE, + ), + ), + 'base' => 'rules_condition_path_alias_exists', + 'callbacks' => array('dependencies' => 'rules_path_dependencies'), + 'access callback' => 'rules_path_integration_access', + ), + ); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/php.eval.inc b/modules/php.eval.inc new file mode 100644 index 0000000..27b5ed0 --- /dev/null +++ b/modules/php.eval.inc @@ -0,0 +1,184 @@ + $info) { + if (strpos($text, '$' . $name) !== FALSE) { + $used_vars[] = $name; + } + } + return $used_vars; + } + } + + /** + * Overrides RulesDataInputEvaluator::prepare(). + */ + public function prepare($text, $var_info) { + // A returned NULL skips the evaluator. + $this->setting = self::getUsedVars($text, $var_info); + } + + /** + * Evaluates PHP code contained in $text. + * + * This method doesn't apply $options, thus the PHP code is responsible for + * behaving appropriately. + */ + public function evaluate($text, $options, RulesState $state) { + $vars['eval_options'] = $options; + foreach ($this->setting as $key => $var_name) { + $vars[$var_name] = $state->get($var_name); + } + return rules_php_eval($text, rules_unwrap_data($vars)); + } + + /** + * Overrides RulesDataInputEvaluator::help(). + */ + public static function help($var_info) { + module_load_include('inc', 'rules', 'rules/modules/php.rules'); + + $render = array( + '#type' => 'fieldset', + '#title' => t('PHP Evaluation'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + ) + rules_php_evaluator_help($var_info); + + return $render; + } + +} + +/** + * A data processor using PHP. + */ +class RulesPHPDataProcessor extends RulesDataProcessor { + + /** + * Overrides RulesDataProcessor::form(). + */ + protected static function form($settings, $var_info) { + $settings += array('code' => ''); + $form = array( + '#type' => 'fieldset', + '#title' => t('PHP evaluation'), + '#collapsible' => TRUE, + '#collapsed' => empty($settings['code']), + '#description' => t('Enter PHP code to process the selected argument value.'), + ); + $form['code'] = array( + '#type' => 'textarea', + '#title' => t('Code'), + '#description' => t('Enter PHP code without <?php ?> delimiters that returns the processed value. The selected value is available in the variable $value. Example: %code', array('%code' => 'return $value + 1;')), + '#default_value' => $settings['code'], + '#weight' => 5, + ); + return $form; + } + + /** + * Overrides RulesDataProcessor::editAccess(). + */ + public function editAccess() { + return parent::editAccess() && (user_access('use PHP for settings') || drupal_is_cli()); + } + + /** + * Overrides RulesDataProcessor::process(). + */ + public function process($value, $info, RulesState $state, RulesPlugin $element) { + $value = isset($this->processor) ? $this->processor->process($value, $info, $state, $element) : $value; + return rules_php_eval_return($this->setting['code'], array('value' => $value)); + } + +} + +/** + * Action and condition callback: Execute PHP code. + */ +function rules_execute_php_eval($code, $settings, $state, $element) { + $data = array(); + if (!empty($settings['used_vars'])) { + foreach ($settings['used_vars'] as $key => $var_name) { + $data[$var_name] = $state->get($var_name); + } + } + return rules_php_eval_return($code, rules_unwrap_data($data)); +} + +/** + * Evaluates the given PHP code, with the given variables defined. + * + * @param string $code + * The PHP code to run, including + * @param array $arguments + * Array containing variables to be extracted to the code. + * + * @return + * The output of the php code. + */ +function rules_php_eval($code, $arguments = array()) { + extract($arguments); + + ob_start(); + print eval('?>' . $code); + $output = ob_get_contents(); + ob_end_clean(); + + return $output; +} + +/** + * Evaluates the given PHP code, with the given variables defined. + * + * This is like rules_php_eval(), but does return the returned data from + * the PHP code. + * + * @param string $code + * The PHP code to run, without + * @param array $arguments + * Array containing variables to be extracted to the code. + * + * @return + * The return value of the evaled code. + */ +function rules_php_eval_return($code, $arguments = array()) { + extract($arguments); + return eval($code); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/php.rules.inc b/modules/php.rules.inc new file mode 100644 index 0000000..e8e0669 --- /dev/null +++ b/modules/php.rules.inc @@ -0,0 +1,159 @@ + array( + 'class' => 'RulesPHPEvaluator', + 'type' => array('text', 'uri'), + 'weight' => -10, + 'module' => 'php', + ), + ); +} + +/** + * Implements hook_rules_data_processor_info() on behalf of the php module. + */ +function rules_php_data_processor_info() { + return array( + 'php' => array( + 'class' => 'RulesPHPDataProcessor', + 'type' => array('text', 'token', 'decimal', 'integer', 'date', 'duration', 'boolean', 'uri'), + 'weight' => 10, + 'module' => 'php', + ), + ); +} + +/** + * Implements hook_rules_action_info() on behalf of the php module. + */ +function rules_php_action_info() { + return array( + 'php_eval' => array( + 'label' => t('Execute custom PHP code'), + 'group' => t('PHP'), + 'parameter' => array( + 'code' => array( + 'restriction' => 'input', + 'type' => 'text', + 'label' => t('PHP code'), + 'description' => t('Enter PHP code without <?php ?> delimiters.'), + ), + ), + 'base' => 'rules_execute_php_eval', + 'access callback' => 'rules_php_integration_access', + ), + ); +} + +/** + * Alter the form for improved UX. + */ +function rules_execute_php_eval_form_alter(&$form, &$form_state) { + // Remove the PHP evaluation help to avoid confusion whether settings['used_vars'] = RulesPHPEvaluator::getUsedVars('settings['code'], $element->availableVariables()); +} + +/** + * Specify the php module as dependency. + */ +function rules_execute_php_eval_dependencies() { + return array('php'); +} + +/** + * PHP integration access callback. + */ +function rules_php_integration_access() { + return user_access('use PHP for settings'); +} + +/** + * Implements hook_rules_condition_info() on behalf of the PHP module. + */ +function rules_php_condition_info() { + return array( + 'php_eval' => array( + 'label' => t('Execute custom PHP code'), + 'group' => t('PHP'), + 'parameter' => array( + 'code' => array( + 'restriction' => 'input', + 'type' => 'text', + 'label' => t('PHP code'), + 'description' => t('Enter PHP code without <?php ?> delimiters that returns a boolean value; e.g. @code.', array('@code' => "return arg(0) == 'node';")), + ), + ), + 'base' => 'rules_execute_php_eval', + 'access callback' => 'rules_php_integration_access', + ), + ); +} + +/** + * Generates help for the PHP actions, conditions and input evaluator. + */ +function rules_php_evaluator_help($var_info, $action_help = FALSE) { + $render['top'] = array( + '#prefix' => '

', + '#suffix' => '

', + '#markup' => t('PHP code inside of <?php ?> delimiters will be evaluated and replaced by its output. E.g. <? echo 1+1?> will be replaced by 2.') . ' ' . t('Furthermore you can make use of the following variables:'), + ); + $render['vars'] = array( + '#theme' => 'table', + '#header' => array(t('Variable name'), t('Type'), t('Description')), + '#attributes' => array('class' => array('rules-php-help')), + ); + + $cache = rules_get_cache(); + foreach ($var_info as $name => $info) { + $row = array(); + $row[] = '$' . check_plain($name); + $label = isset($cache['data_info'][$info['type']]['label']) ? $cache['data_info'][$info['type']]['label'] : $info['type']; + $row[] = check_plain(drupal_ucfirst($label)); + $row[] = check_plain($info['label']); + $render['vars']['#rows'][] = $row; + } + + if ($action_help) { + $render['updated_help'] = array( + '#prefix' => '

', + '#suffix' => '

', + '#markup' => t("If you want to change a variable just return an array of new variable values, e.g.: !code", array('!code' => '
return array("node" => $node);
')), + ); + } + return $render; +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/rules_core.eval.inc b/modules/rules_core.eval.inc new file mode 100644 index 0000000..bbea0ff --- /dev/null +++ b/modules/rules_core.eval.inc @@ -0,0 +1,280 @@ +info(); + $state = $arguments['state']; + $wrapped_args = $state->currentArguments; + + if ($component = rules_get_cache('comp_' . $info['#config_name'])) { + $replacements = array('%label' => $component->label(), '@plugin' => $component->plugin()); + // Handle recursion prevention. + if ($state->isBlocked($component)) { + return rules_log('Not evaluating @plugin %label to prevent recursion.', $replacements, RulesLog::INFO, $component); + } + $state->block($component); + rules_log('Evaluating @plugin %label.', $replacements, RulesLog::INFO, $component, TRUE); + module_invoke_all('rules_config_execute', $component); + + // Manually create a new evaluation state and evaluate the component. + $args = array_intersect_key($wrapped_args, $component->parameterInfo()); + $new_state = $component->setUpState($wrapped_args); + $return = $component->evaluate($new_state); + + // Care for the right return value in case we have to provide vars. + if ($component instanceof RulesActionInterface && !empty($info['provides'])) { + $return = array(); + foreach ($info['provides'] as $var => $var_info) { + $return[$var] = $new_state->get($var); + } + } + + // Now merge the info about to be saved variables in the parent state. + $state->mergeSaveVariables($new_state, $component, $element->settings); + $state->unblock($component); + + // Cleanup the state, what saves not mergeable variables now. + $new_state->cleanup(); + rules_log('Finished evaluation of @plugin %label.', $replacements, RulesLog::INFO, $component, FALSE); + return $return; + } + else { + throw new RulesEvaluationException('Unable to get the component %name', array('%name' => $info['#config_name']), $element, RulesLog::ERROR); + } +} + +/** + * A class implementing a rules input evaluator processing date input. + * + * This is needed to treat relative date inputs for strtotime() correctly. + * Consider for example "now". + */ +class RulesDateInputEvaluator extends RulesDataInputEvaluator { + + const DATE_REGEX_LOOSE = '/^(\d{4})-?(\d{2})-?(\d{2})([T\s]?(\d{2}):?(\d{2}):?(\d{2})?)?$/'; + + /** + * Overrides RulesDataInputEvaluator::prepare(). + */ + public function prepare($text, $var_info) { + if (is_numeric($text)) { + // Let rules skip this input evaluators in case it's already a timestamp. + $this->setting = NULL; + } + } + + /** + * Overrides RulesDataInputEvaluator::evaluate(). + */ + public function evaluate($text, $options, RulesState $state) { + return self::gmstrtotime($text); + } + + /** + * Convert a time string to a GMT (UTC) unix timestamp. + */ + public static function gmstrtotime($date) { + // Pass the current timestamp in UTC to ensure the retrieved time is UTC. + return strtotime($date, time()); + } + + /** + * Determine whether the given date string specifies a fixed date. + */ + public static function isFixedDateString($date) { + return is_string($date) && preg_match(self::DATE_REGEX_LOOSE, $date); + } + +} + +/** + * A class implementing a rules input evaluator processing URI inputs. + * + * Makes sure URIs are absolute and path aliases get applied. + */ +class RulesURIInputEvaluator extends RulesDataInputEvaluator { + + /** + * Overrides RulesDataInputEvaluator::prepare(). + */ + public function prepare($uri, $var_info) { + if (!isset($this->processor) && valid_url($uri, TRUE)) { + // Only process if another evaluator is used or the url is not absolute. + $this->setting = NULL; + } + } + + /** + * Overrides RulesDataInputEvaluator::evaluate(). + */ + public function evaluate($uri, $options, RulesState $state) { + if (!url_is_external($uri)) { + // Extract the path and build the URL using the url() function, so URL + // aliases are applied and query parameters and fragments get handled. + $url = drupal_parse_url($uri); + $url_options = array('absolute' => TRUE); + $url_options['query'] = $url['query']; + $url_options['fragment'] = $url['fragment']; + return url($url['path'], $url_options); + } + elseif (valid_url($uri)) { + return $uri; + } + throw new RulesEvaluationException('Input evaluation generated an invalid URI.', array(), NULL, RulesLog::WARN); + } + +} + +/** + * A data processor for applying date offsets. + */ +class RulesDateOffsetProcessor extends RulesDataProcessor { + + /** + * Overrides RulesDataProcessor::form(). + */ + protected static function form($settings, $var_info) { + $settings += array('value' => ''); + $form = array( + '#type' => 'fieldset', + '#title' => t('Add offset'), + '#collapsible' => TRUE, + '#collapsed' => empty($settings['value']), + '#description' => t('Add an offset to the selected date.'), + ); + $form['value'] = array( + '#type' => 'rules_duration', + '#title' => t('Offset'), + '#description' => t('Note that you can also specify negative numbers.'), + '#default_value' => $settings['value'], + '#weight' => 5, + ); + return $form; + } + + /** + * Overrides RulesDataProcessor::process(). + */ + public function process($value, $info, RulesState $state, RulesPlugin $element) { + $value = isset($this->processor) ? $this->processor->process($value, $info, $state, $element) : $value; + return RulesDateOffsetProcessor::applyOffset($value, $this->setting['value']); + } + + /** + * Intelligently applies the given date offset in seconds. + * + * Intelligently apply duration values > 1 day, i.e. convert the duration + * to its biggest possible unit (months, days) and apply it to the date with + * the given unit. That's necessary as the number of days in a month + * differs, as well as the number of hours for a day (on DST changes). + */ + public static function applyOffset($timestamp, $offset) { + if (abs($offset) >= 86400) { + + // Get the days out of the seconds. + $days = intval($offset / 86400); + $sec = $offset % 86400; + // Get the months out of the number of days. + $months = intval($days / 30); + $days = $days % 30; + + // Apply the offset using the DateTime::modify and convert it back to a + // timestamp. + $date = date_create("@$timestamp"); + $date->modify("$months months $days days $sec seconds"); + return $date->format('U'); + } + else { + return $timestamp + $offset; + } + } + +} + +/** + * A data processor for applying numerical offsets. + */ +class RulesNumericOffsetProcessor extends RulesDataProcessor { + + /** + * Overrides RulesDataProcessor::form(). + */ + protected static function form($settings, $var_info) { + $settings += array('value' => ''); + $form = array( + '#type' => 'fieldset', + '#title' => t('Add offset'), + '#collapsible' => TRUE, + '#collapsed' => empty($settings['value']), + '#description' => t('Add an offset to the selected number. E.g. an offset of "1" adds 1 to the number before it is passed on as argument.'), + ); + $form['value'] = array( + '#type' => 'textfield', + '#title' => t('Offset'), + '#description' => t('Note that you can also specify negative numbers.'), + '#default_value' => $settings['value'], + '#element_validate' => array('rules_ui_element_integer_validate'), + '#weight' => 5, + ); + return $form; + } + + /** + * Overrides RulesDataProcessor::process(). + */ + public function process($value, $info, RulesState $state, RulesPlugin $element) { + $value = isset($this->processor) ? $this->processor->process($value, $info, $state, $element) : $value; + return $value + $this->setting['value']; + } + +} + +/** + * A custom wrapper class for vocabularies. + * + * This class is capable of loading vocabularies by machine name. + */ +class RulesTaxonomyVocabularyWrapper extends EntityDrupalWrapper { + + /** + * Overridden to support identifying vocabularies by machine names. + */ + protected function setEntity($data) { + if (isset($data) && $data !== FALSE && !is_object($data) && !is_numeric($data)) { + // The vocabulary name has been passed. + parent::setEntity(taxonomy_vocabulary_machine_name_load($data)); + } + else { + parent::setEntity($data); + } + } + + /** + * Overridden to permit machine names as values. + */ + public function validate($value) { + if (isset($value) && is_string($value)) { + return TRUE; + } + return parent::validate($value); + } + +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/rules_core.rules.inc b/modules/rules_core.rules.inc new file mode 100644 index 0000000..ae81cd4 --- /dev/null +++ b/modules/rules_core.rules.inc @@ -0,0 +1,341 @@ + array( + 'label' => t('Components'), + 'equals group' => t('Components'), + 'weight' => 50, + ), + ); +} + +/** + * Implements hook_rules_file_info() on behalf of the pseudo rules_core module. + * + * @see rules_core_modules() + */ +function rules_rules_core_file_info() { + return array('modules/rules_core.eval'); +} + +/** + * Implements hook_rules_data_info() on behalf of the pseudo rules_core module. + * + * @see rules_core_modules() + */ +function rules_rules_core_data_info() { + $return = array( + 'text' => array( + 'label' => t('text'), + 'ui class' => 'RulesDataUIText', + 'token type' => 'rules_text', + ), + 'token' => array( + 'label' => t('text token'), + 'parent' => 'text', + 'ui class' => 'RulesDataUITextToken', + 'token type' => 'rules_token', + ), + // A formatted text as used by entity metadata. + 'text_formatted' => array( + 'label' => t('formatted text'), + 'ui class' => 'RulesDataUITextFormatted', + 'wrap' => TRUE, + 'property info' => entity_property_text_formatted_info(), + ), + 'decimal' => array( + 'label' => t('decimal number'), + 'parent' => 'text', + 'ui class' => 'RulesDataUIDecimal', + 'token type' => 'rules_decimal', + ), + 'integer' => array( + 'label' => t('integer'), + 'class' => 'RulesIntegerWrapper', + 'parent' => 'decimal', + 'ui class' => 'RulesDataUIInteger', + 'token type' => 'rules_integer', + ), + 'date' => array( + 'label' => t('date'), + 'ui class' => 'RulesDataUIDate', + 'token type' => 'rules_date', + ), + 'duration' => array( + 'label' => t('duration'), + 'parent' => 'integer', + 'ui class' => 'RulesDataUIDuration', + 'token type' => 'rules_duration', + ), + 'boolean' => array( + 'label' => t('truth value'), + 'ui class' => 'RulesDataUIBoolean', + 'token type' => 'rules_boolean', + ), + 'uri' => array( + 'label' => t('URI'), + 'parent' => 'text', + // Clean inserted tokens with "rawurlencode". + 'cleaning callback' => 'rawurlencode', + 'ui class' => 'RulesDataUIURI', + 'token type' => 'rules_uri', + ), + 'list' => array( + 'label' => t('list', array(), array('context' => 'data_types')), + 'wrap' => TRUE, + 'group' => t('List', array(), array('context' => 'data_types')), + ), + 'list' => array( + 'label' => t('list of text'), + 'ui class' => 'RulesDataUIListText', + 'wrap' => TRUE, + 'group' => t('List', array(), array('context' => 'data_types')), + ), + 'list' => array( + 'label' => t('list of integer'), + 'ui class' => 'RulesDataUIListInteger', + 'wrap' => TRUE, + 'group' => t('List', array(), array('context' => 'data_types')), + ), + 'list' => array( + 'label' => t('list of text tokens'), + 'ui class' => 'RulesDataUIListToken', + 'wrap' => TRUE, + 'group' => t('List', array(), array('context' => 'data_types')), + ), + 'entity' => array( + 'label' => t('any entity'), + 'group' => t('Entity'), + 'is wrapped' => TRUE, + ), + 'ip_address' => array( + 'label' => t('IP Address'), + 'parent' => 'text', + 'ui class' => 'RulesDataUIIPAddress', + 'token type' => 'rules_text', + ), + ); + foreach (entity_get_info() as $type => $info) { + if (!empty($info['label'])) { + $return[$type] = array( + 'label' => strtolower($info['label'][0]) . substr($info['label'], 1), + 'parent' => 'entity', + 'wrap' => TRUE, + 'group' => t('Entity'), + 'ui class' => empty($info['exportable']) ? 'RulesDataUIEntity' : 'RulesDataUIEntityExportable', + ); + // If this entity type serves as bundle for another one, provide an + // options list for selecting a bundle entity. + if (!empty($info['bundle of'])) { + $return[$type]['ui class'] = 'RulesDataUIBundleEntity'; + } + } + } + + if (module_exists('taxonomy')) { + // For exportability identify vocabularies by name. + $return['taxonomy_vocabulary']['wrapper class'] = 'RulesTaxonomyVocabularyWrapper'; + $return['taxonomy_vocabulary']['ui class'] = 'RulesDataUITaxonomyVocabulary'; + } + + return $return; +} + +/** + * Implements hook_rules_data_info_alter() on behalf of the pseudo rules_core module. + * + * Makes sure there is a list data type for each type registered. + * + * @see rules_rules_data_info_alter() + */ +function rules_rules_core_data_info_alter(&$data_info) { + foreach ($data_info as $type => $info) { + if (!entity_property_list_extract_type($type)) { + $list_type = "list<$type>"; + if (!isset($data_info[$list_type])) { + $data_info[$list_type] = array( + 'label' => t('list of @type_label items', array('@type_label' => $info['label'])), + 'wrap' => TRUE, + 'group' => t('List', array(), array('context' => 'data_types')), + ); + if (isset($info['parent']) && $info['parent'] == 'entity') { + $data_info[$list_type]['ui class'] = 'RulesDataUIListEntity'; + } + } + } + } +} + +/** + * Implements hook_rules_evaluator_info() on behalf of the pseudo rules_core + * module. + * + * @see rules_core_modules() + */ +function rules_rules_core_evaluator_info() { + return array( + // Process strtotime() inputs to timestamps. + 'date' => array( + 'class' => 'RulesDateInputEvaluator', + 'type' => 'date', + 'weight' => -10, + ), + // Post-process any input value to absolute URIs. + 'uri' => array( + 'class' => 'RulesURIInputEvaluator', + 'type' => 'uri', + 'weight' => 50, + ), + ); +} + +/** + * Implements hook_rules_data_processor_info() on behalf of the pseudo + * rules_core module. + * + * @see rules_core_modules() + */ +function rules_rules_core_data_processor_info() { + return array( + 'date_offset' => array( + 'class' => 'RulesDateOffsetProcessor', + 'type' => 'date', + 'weight' => -2, + ), + 'num_offset' => array( + 'class' => 'RulesNumericOffsetProcessor', + 'type' => array('integer', 'decimal'), + 'weight' => -2, + ), + ); +} + +/** + * Implements hook_rules_condition_info() on behalf of the pseudo rules_core + * module. + * + * @see rules_core_modules() + */ +function rules_rules_core_condition_info() { + $defaults = array( + 'group' => t('Components'), + 'base' => 'rules_element_invoke_component', + 'named parameter' => TRUE, + 'access callback' => 'rules_element_invoke_component_access_callback', + ); + $items = array(); + foreach (rules_get_components(FALSE, 'condition') as $name => $config) { + $items['component_' . $name] = $defaults + array( + 'label' => $config->plugin() . ': ' . drupal_ucfirst($config->label()), + 'parameter' => $config->parameterInfo(), + ); + $items['component_' . $name]['#config_name'] = $name; + } + return $items; +} + +/** + * Implements hook_rules_action_info() on behalf of the pseudo rules_core + * module. + * + * @see rules_core_modules() + */ +function rules_rules_core_action_info() { + $defaults = array( + 'group' => t('Components'), + 'base' => 'rules_element_invoke_component', + 'named parameter' => TRUE, + 'access callback' => 'rules_element_invoke_component_access_callback', + ); + $items = array(); + foreach (rules_get_components(FALSE, 'action') as $name => $config) { + $items['component_' . $name] = $defaults + array( + 'label' => $config->plugin() . ': ' . drupal_ucfirst($config->label()), + 'parameter' => $config->parameterInfo(), + 'provides' => $config->providesVariables(), + ); + $items['component_' . $name]['#config_name'] = $name; + } + return $items; +} + +/** + * Implements RulesPluginUIInterface::operations() for the action. + */ +function rules_element_invoke_component_operations(RulesPlugin $element) { + $defaults = $element->extender('RulesPluginUI')->operations(); + $info = $element->info(); + + // Add an operation for editing the component. + $defaults['#links']['component'] = array( + 'title' => t('edit component'), + 'href' => RulesPluginUI::path($info['#config_name']), + ); + return $defaults; +} + +/** + * Validate callback to make sure the invoked component exists and is not dirty. + * + * @see rules_scheduler_action_schedule_validate() + */ +function rules_element_invoke_component_validate(RulesPlugin $element) { + $info = $element->info(); + $component = rules_config_load($info['#config_name']); + // Check if a component exists. + if (!$component) { + throw new RulesIntegrityException(t('The component %config does not exist.', array('%config' => $info['#config_name'])), $element); + } + // Check if a component is marked as dirty. + rules_config_update_dirty_flag($component); + if (!empty($component->dirty)) { + throw new RulesIntegrityException(t('The utilized component %config fails the integrity check.', array('%config' => $info['#config_name'])), $element); + } +} + +/** + * Implements the features export callback of the RulesPluginFeaturesIntegrationInterface. + */ +function rules_element_invoke_component_features_export(&$export, &$pipe, $module_name = '', $element) { + // Add the used component to the pipe. + $info = $element->info(); + $pipe['rules_config'][] = $info['#config_name']; +} + +/** + * Access callback for the invoke component condition/action. + */ +function rules_element_invoke_component_access_callback($type, $name) { + // Cut of the leading 'component_' from the action name. + $component = rules_config_load(substr($name, 10)); + + if (!$component) { + // Missing component. + return FALSE; + } + // If access is not exposed for this component, default to component access. + if (empty($component->access_exposed)) { + return $component->access(); + } + // Apply the permissions. + return user_access('bypass rules access') || user_access("use Rules component $component->name"); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/system.eval.inc b/modules/system.eval.inc new file mode 100644 index 0000000..f18548a --- /dev/null +++ b/modules/system.eval.inc @@ -0,0 +1,305 @@ + $title) { + // Skip empty titles. + if ($title = trim($title)) { + // Output plaintext instead of a link if there is a title without a path. + $path = trim($paths[$i]); + if (!empty($paths[$i]) && $paths[$i] != '') { + $trail[] = l($title, trim($paths[$i])); + } + else { + $trail[] = check_plain($title); + } + } + } + drupal_set_breadcrumb($trail); +} + +/** + * Action Implementation: Send mail. + */ +function rules_action_mail($to, $subject, $message, $from = NULL, $langcode, $settings, RulesState $state, RulesPlugin $element) { + $to = str_replace(array("\r", "\n"), '', $to); + $from = !empty($from) ? str_replace(array("\r", "\n"), '', $from) : NULL; + $params = array( + 'subject' => $subject, + 'message' => $message, + 'langcode' => $langcode, + ); + // Set a unique key for this mail. + $name = isset($element->root()->name) ? $element->root()->name : 'unnamed'; + $key = 'rules_action_mail_' . $name . '_' . $element->elementId(); + $languages = language_list(); + $language = isset($languages[$langcode]) ? $languages[$langcode] : language_default(); + + $message = drupal_mail('rules', $key, $to, $language, $params, $from); + if ($message['result']) { + watchdog('rules', 'Successfully sent email to %recipient', array('%recipient' => $to)); + } +} + +/** + * Action: Send mail to all users of a specific role group(s). + */ +function rules_action_mail_to_users_of_role($roles, $subject, $message, $from = NULL, $settings, RulesState $state, RulesPlugin $element) { + $from = !empty($from) ? str_replace(array("\r", "\n"), '', $from) : NULL; + + // All authenticated users, which is everybody. + if (in_array(DRUPAL_AUTHENTICATED_RID, $roles)) { + $result = db_query('SELECT mail FROM {users} WHERE uid > 0'); + } + else { + // Avoid sending emails to members of two or more target role groups. + $result = db_query('SELECT DISTINCT u.mail FROM {users} u INNER JOIN {users_roles} r ON u.uid = r.uid WHERE r.rid IN (:rids)', array(':rids' => $roles)); + } + + // Now, actually send the mails. + $params = array( + 'subject' => $subject, + 'message' => $message, + ); + // Set a unique key for this mail. + $name = isset($element->root()->name) ? $element->root()->name : 'unnamed'; + $key = 'rules_action_mail_to_users_of_role_' . $name . '_' . $element->elementId(); + $languages = language_list(); + + $message = array('result' => TRUE); + foreach ($result as $row) { + $message = drupal_mail('rules', $key, $row->mail, language_default(), $params, $from); + // If $message['result'] is FALSE, then it's likely that email sending is + // failing at the moment, and we should just abort sending any more. If + // however, $message['result'] is NULL, then it's likely that a module has + // aborted sending this particular email to this particular user, and we + // should just keep on sending emails to the other users. + // For more information on the result value, see drupal_mail(). + if ($message['result'] === FALSE) { + break; + } + } + if ($message['result'] !== FALSE) { + $role_names = array_intersect_key(user_roles(TRUE), array_flip($roles)); + watchdog('rules', 'Successfully sent email to the role(s) %roles.', array('%roles' => implode(', ', $role_names))); + } +} + +/** + * Implements hook_mail(). + * + * Sets the message subject and body as configured. + */ +function rules_mail($key, &$message, $params) { + + $message['subject'] .= str_replace(array("\r", "\n"), '', $params['subject']); + $message['body'][] = $params['message']; +} + +/** + * Action: Block an IP address. + */ +function rules_action_block_ip($ip_address = NULL) { + if (empty($ip_address)) { + $ip_address = ip_address(); + } + db_insert('blocked_ips')->fields(array('ip' => $ip_address))->execute(); + watchdog('rules', 'Banned IP address %ip', array('%ip' => $ip_address)); +} + +/** + * A class implementing a rules input evaluator processing tokens. + */ +class RulesTokenEvaluator extends RulesDataInputEvaluator { + + /** + * Overrides RulesDataInputEvaluator::prepare(). + */ + public function prepare($text, $var_info) { + $text = is_array($text) ? implode('', $text) : $text; + // Skip this evaluator if there are no tokens. + $this->setting = token_scan($text) ? TRUE : NULL; + } + + /** + * Evaluate tokens. + * + * We replace the tokens on our own as we cannot use token_replace(), because + * token usually assumes that $data['node'] is a of type node, which doesn't + * hold in general in our case. + * So we properly map variable names to variable data types and then run the + * replacement ourself. + */ + public function evaluate($text, $options, RulesState $state) { + $var_info = $state->varInfo(); + $options += array('sanitize' => FALSE); + + $replacements = array(); + $data = array(); + // We also support replacing tokens in a list of textual values. + $whole_text = is_array($text) ? implode('', $text) : $text; + foreach (token_scan($whole_text) as $var_name => $tokens) { + $var_name = str_replace('-', '_', $var_name); + if (isset($var_info[$var_name]) && ($token_type = _rules_system_token_map_type($var_info[$var_name]['type']))) { + // We have to key $data with the type token uses for the variable. + $data = rules_unwrap_data(array($token_type => $state->get($var_name)), array($token_type => $var_info[$var_name])); + $replacements += token_generate($token_type, $tokens, $data, $options); + } + else { + $replacements += token_generate($var_name, $tokens, array(), $options); + } + // Remove tokens if no replacement value is found. As token_replace() does + // if 'clear' is set. + $replacements += array_fill_keys($tokens, ''); + } + + // Optionally clean the list of replacement values. + if (!empty($options['callback']) && function_exists($options['callback'])) { + $function = $options['callback']; + $function($replacements, $data, $options); + } + + // Actually apply the replacements. + $tokens = array_keys($replacements); + $values = array_values($replacements); + if (is_array($text)) { + foreach ($text as $i => $text_item) { + $text[$i] = str_replace($tokens, $values, $text_item); + } + return $text; + } + return str_replace($tokens, $values, $text); + } + + /** + * Create documentation about the available replacement patterns. + * + * @param array $var_info + * Array with the available variables. + * + * @return array + * Renderable array with the replacement pattern documentation. + */ + public static function help($var_info) { + $render = array( + '#type' => 'fieldset', + '#title' => t('Replacement patterns'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#description' => t('Note that token replacements containing chained objects – such as [node:author:uid] – are not listed here, but are still available. The data selection input mode may help you find more complex replacement patterns. See the online documentation for more information about complex replacement patterns.', + array('@url' => rules_external_help('chained-tokens'))), + ); + + $token_info = token_info(); + foreach ($var_info as $name => $info) { + $token_types[$name] = _rules_system_token_map_type($info['type']); + } + + foreach ($token_types as $name => $token_type) { + if (isset($token_info['types'][$token_type])) { + $render[$name] = array( + '#theme' => 'table', + '#header' => array(t('Token'), t('Label'), t('Description')), + '#prefix' => '

' . t('Replacement patterns for %label', array('%label' => $var_info[$name]['label'])) . '

', + ); + foreach ($token_info['tokens'][$token_type] as $token => $info) { + $token = '[' . str_replace('_', '-', $name) . ':' . $token . ']'; + $render[$name]['#rows'][$token] = array( + check_plain($token), + check_plain($info['name']), + check_plain($info['description']), + ); + } + } + } + return $render; + } + +} + +/** + * Looks for a token type mapping. Defaults to passing through the type. + */ +function _rules_system_token_map_type($type) { + $entity_info = entity_get_info(); + if (isset($entity_info[$type]['token type'])) { + return $entity_info[$type]['token type']; + } + $cache = rules_get_cache(); + if (isset($cache['data_info'][$type]['token type'])) { + return $cache['data_info'][$type]['token type']; + } + return $type; +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/system.rules.inc b/modules/system.rules.inc new file mode 100644 index 0000000..bf90e78 --- /dev/null +++ b/modules/system.rules.inc @@ -0,0 +1,345 @@ + array( + 'label' => t('Drupal is initializing'), + 'group' => t('System'), + 'help' => t("Be aware that some actions might initialize the theme system. After that, it's impossible for any module to change the used theme."), + 'access callback' => 'rules_system_integration_access', + ), + 'cron' => array( + 'label' => t('Cron maintenance tasks are performed'), + 'group' => t('System'), + 'access callback' => 'rules_system_integration_access', + ), + 'watchdog' => array( + 'label' => t('System log entry is created'), + 'variables' => array( + 'log_entry' => array( + 'type' => 'log_entry', + 'label' => t('Log entry'), + ), + ), + 'group' => t('System'), + 'access callback' => 'rules_system_integration_access', + ), + ); +} + +/** + * Implements hook_rules_data_info() on behalf of the system module. + * + * @see rules_core_modules() + */ +function rules_system_data_info() { + return array( + 'log_entry' => array( + 'label' => t('Watchdog log entry'), + 'wrap' => TRUE, + 'property info' => _rules_system_watchdog_log_entry_info(), + ), + ); +} + +/** + * Defines property info for watchdog log entries. + * + * Used by the log entry data type to provide a useful metadata wrapper. + */ +function _rules_system_watchdog_log_entry_info() { + return array( + 'type' => array( + 'type' => 'text', + 'label' => t('The category to which this message belongs'), + ), + 'message' => array( + 'type' => 'text', + 'label' => t('Log message'), + 'getter callback' => 'rules_system_log_get_message', + 'sanitized' => TRUE, + ), + 'severity' => array( + 'type' => 'integer', + 'label' => t('Severity'), + 'options list' => 'watchdog_severity_levels', + ), + 'request_uri' => array( + 'type' => 'uri', + 'label' => t('Request uri'), + ), + 'link' => array( + 'type' => 'text', + 'label' => t('An associated, HTML formatted link'), + ), + ); +} + +/** + * Implements hook_rules_action_info() on behalf of the system module. + */ +function rules_system_action_info() { + return array( + 'drupal_message' => array( + 'label' => t('Show a message on the site'), + 'group' => t('System'), + 'parameter' => array( + 'message' => array( + 'type' => 'text', + 'label' => t('Message'), + 'sanitize' => TRUE, + 'translatable' => TRUE, + ), + 'type' => array( + 'type' => 'token', + 'label' => t('Message type'), + 'options list' => 'rules_action_drupal_message_types', + 'default value' => 'status', + 'optional' => TRUE, + ), + 'repeat' => array( + 'type' => 'boolean', + 'label' => t('Repeat message'), + 'description' => t("If disabled and the message has been already shown, then the message won't be repeated."), + 'default value' => TRUE, + 'optional' => TRUE, + 'restriction' => 'input', + ), + ), + 'base' => 'rules_action_drupal_message', + 'access callback' => 'rules_system_integration_access', + ), + 'drupal_watchdog' => array( + 'label' => t('Logs a message to the system dblog'), + 'group' => t('System'), + 'parameter' => array( + 'type' => array( + 'type' => 'text', + 'label' => t('Type'), + 'description' => t('The category to which this message belongs, displayed in the first column of the dblog.'), + ), + 'message' => array( + 'type' => 'text', + 'label' => t('Message'), + 'description' => t('The text of the message.'), + 'translatable' => TRUE, + ), + 'severity' => array( + 'type' => 'token', + 'label' => t('Severity'), + 'options list' => 'watchdog_severity_levels', + 'default value' => WATCHDOG_NOTICE, + 'optional' => TRUE, + ), + 'link_text' => array( + 'type' => 'text', + 'label' => t('Link text'), + 'description' => t('The text to display for the link (optional)'), + 'optional' => TRUE, + ), + 'link_path' => array( + 'type' => 'uri', + 'label' => t('Link path'), + 'description' => t('A Drupal path or path alias. Enter (optional) queries after "?" and (optional) anchor after "#".'), + 'optional' => TRUE, + ), + ), + 'base' => 'rules_action_drupal_watchdog', + 'access callback' => 'rules_system_integration_access', + ), + 'redirect' => array( + 'label' => t('Page redirect'), + 'group' => t('System'), + 'parameter' => array( + 'url' => array( + 'type' => 'uri', + 'label' => t('URL'), + 'description' => t('A Drupal path, path alias, or external URL to redirect to. Enter (optional) queries after "?" and (optional) anchor after "#".'), + ), + 'force' => array( + 'type' => 'boolean', + 'label' => t('Force redirect'), + 'restriction' => 'input', + 'description' => t("Force the redirect even if another destination parameter is present. Per default Drupal would redirect to the path given as destination parameter, in case it is set. Usually the destination parameter is set by appending it to the URL, e.g. !example_url", array('!example_url' => 'http://example.com/user/login?destination=node/2')), + 'optional' => TRUE, + 'default value' => TRUE, + ), + 'destination' => array( + 'type' => 'boolean', + 'label' => t('Append destination parameter'), + 'restriction' => 'input', + 'description' => t('Whether to append a destination parameter to the URL, so another redirect issued later on would lead back to the origin page.'), + 'optional' => TRUE, + 'default value' => FALSE, + ), + ), + 'base' => 'rules_action_drupal_goto', + 'access callback' => 'rules_system_integration_access', + ), + 'breadcrumb_set' => array( + 'label' => t('Set breadcrumb'), + 'group' => t('System'), + 'parameter' => array( + 'titles' => array( + 'type' => 'list', + 'label' => t('Titles'), + 'description' => t('A list of titles for the breadcrumb links.'), + 'translatable' => TRUE, + ), + 'paths' => array( + 'type' => 'list', + 'label' => t('Paths'), + 'description' => t('A list of Drupal paths for the breadcrumb links, matching the order of the titles.'), + ), + ), + 'base' => 'rules_action_breadcrumb_set', + 'access callback' => 'rules_system_integration_access', + ), + 'mail' => array( + 'label' => t('Send mail'), + 'group' => t('System'), + 'parameter' => array( + 'to' => array( + 'type' => 'text', + 'label' => t('To'), + 'description' => t('The e-mail address or addresses where the message will be sent to. The formatting of this string must comply with RFC 2822.'), + ), + 'subject' => array( + 'type' => 'text', + 'label' => t('Subject'), + 'description' => t("The mail's subject."), + 'translatable' => TRUE, + ), + 'message' => array( + 'type' => 'text', + 'label' => t('Message'), + 'description' => t("The mail's message body."), + 'translatable' => TRUE, + ), + 'from' => array( + 'type' => 'text', + 'label' => t('From'), + 'description' => t("The mail's from address. Leave it empty to use the site-wide configured address."), + 'optional' => TRUE, + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('If specified, the language used for getting the mail message and subject.'), + 'options list' => 'entity_metadata_language_list', + 'optional' => TRUE, + 'default value' => LANGUAGE_NONE, + 'default mode' => 'selector', + ), + ), + 'base' => 'rules_action_mail', + 'access callback' => 'rules_system_integration_access', + ), + 'mail_to_users_of_role' => array( + 'label' => t('Send mail to all users of a role'), + 'group' => t('System'), + 'parameter' => array( + 'roles' => array( + 'type' => 'list', + 'label' => t('Roles'), + 'options list' => 'entity_metadata_user_roles', + 'description' => t('Select the roles whose users should receive the mail.'), + ), + 'subject' => array( + 'type' => 'text', + 'label' => t('Subject'), + 'description' => t("The mail's subject."), + ), + 'message' => array( + 'type' => 'text', + 'label' => t('Message'), + 'description' => t("The mail's message body."), + ), + 'from' => array( + 'type' => 'text', + 'label' => t('From'), + 'description' => t("The mail's from address. Leave it empty to use the site-wide configured address."), + 'optional' => TRUE, + ), + ), + 'base' => 'rules_action_mail_to_users_of_role', + 'access callback' => 'rules_system_integration_access', + ), + 'block_ip' => array( + 'label' => t('Block IP address'), + 'group' => t('System'), + 'parameter' => array( + 'ip_address' => array( + 'type' => 'ip_address', + 'label' => t('IP address'), + 'description' => t('If not provided, the IP address of the current user will be used.'), + 'optional' => TRUE, + 'default value' => NULL, + ), + ), + 'base' => 'rules_action_block_ip', + 'access callback' => 'rules_system_integration_access', + ), + ); +} + +/** + * Help callback for the "Send mail to users of a role" action. + */ +function rules_action_mail_to_users_of_role_help() { + return t('WARNING: This may cause problems if there are too many users of these roles on your site, as your server may not be able to handle all the mail requests all at once.'); +} + +/** + * System integration access callback. + */ +function rules_system_integration_access($type, $name) { + return user_access('administer site configuration'); +} + +/** + * Options list callback defining drupal_message types. + */ +function rules_action_drupal_message_types() { + return array( + 'status' => t('Status'), + 'warning' => t('Warning'), + 'error' => t('Error'), + ); +} + +/** + * Implements hook_rules_evaluator_info() on behalf of the system module. + */ +function rules_system_evaluator_info() { + return array( + 'token' => array( + 'class' => 'RulesTokenEvaluator', + 'type' => array('text', 'uri', 'list', 'list'), + 'weight' => 0, + ), + ); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/taxonomy.rules.inc b/modules/taxonomy.rules.inc new file mode 100644 index 0000000..c032a2d --- /dev/null +++ b/modules/taxonomy.rules.inc @@ -0,0 +1,150 @@ + t('Taxonomy'), + 'access callback' => 'rules_taxonomy_term_integration_access', + 'module' => 'taxonomy', + 'class' => 'RulesTaxonomyEventHandler', + ); + $defaults_vocab = array( + 'group' => t('Taxonomy'), + 'access callback' => 'rules_taxonomy_vocabulary_integration_access', + 'module' => 'taxonomy', + ); + return array( + 'taxonomy_term_insert' => $defaults_term + array( + 'label' => t('After saving a new term'), + 'variables' => array( + 'term' => array('type' => 'taxonomy_term', 'label' => t('created term')), + ), + ), + 'taxonomy_term_update' => $defaults_term + array( + 'label' => t('After updating an existing term'), + 'variables' => array( + 'term' => array('type' => 'taxonomy_term', 'label' => t('updated term')), + 'term_unchanged' => array( + 'type' => 'taxonomy_term', + 'label' => t('unchanged term'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'taxonomy_term_presave' => $defaults_term + array( + 'label' => t('Before saving a taxonomy term'), + 'variables' => array( + 'term' => array( + 'type' => 'taxonomy_term', + 'label' => t('saved term'), + 'skip save' => TRUE, + ), + 'term_unchanged' => array( + 'type' => 'taxonomy_term', + 'label' => t('unchanged term'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'taxonomy_term_delete' => $defaults_term + array( + 'label' => t('After deleting a term'), + 'variables' => array( + 'term' => array('type' => 'taxonomy_term', 'label' => t('deleted term')), + ), + ), + 'taxonomy_vocabulary_insert' => $defaults_vocab + array( + 'label' => t('After saving a new vocabulary'), + 'variables' => array( + 'vocabulary' => array('type' => 'taxonomy_vocabulary', 'label' => t('created vocabulary')), + ), + ), + 'taxonomy_vocabulary_update' => $defaults_vocab + array( + 'label' => t('After updating an existing vocabulary'), + 'variables' => array( + 'vocabulary' => array( + 'type' => 'taxonomy_vocabulary', + 'label' => t('updated vocabulary'), + ), + 'vocabulary_unchanged' => array( + 'type' => 'taxonomy_vocabulary', + 'label' => t('unchanged vocabulary'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'taxonomy_vocabulary_presave' => $defaults_vocab + array( + 'label' => t('Before saving a vocabulary'), + 'variables' => array( + 'vocabulary' => array( + 'type' => 'taxonomy_vocabulary', + 'label' => t('saved vocabulary'), + 'skip save' => TRUE, + ), + 'vocabulary_unchanged' => array( + 'type' => 'taxonomy_vocabulary', + 'label' => t('unchanged vocabulary'), + 'handler' => 'rules_events_entity_unchanged', + ), + ), + ), + 'taxonomy_vocabulary_delete' => $defaults_vocab + array( + 'label' => t('After deleting a vocabulary'), + 'variables' => array( + 'vocabulary' => array( + 'type' => 'taxonomy_vocabulary', + 'label' => t('deleted vocabulary'), + ), + ), + ), + ); +} + +/** + * Taxonomy term integration access callback. + */ +function rules_taxonomy_term_integration_access($type, $name) { + if ($type == 'event' || $type == 'condition') { + return entity_access('view', 'taxonomy_term'); + } +} + +/** + * Taxonomy vocabulary integration access callback. + */ +function rules_taxonomy_vocabulary_integration_access($type, $name) { + if ($type == 'event' || $type == 'condition') { + return entity_access('view', 'taxonomy_vocabulary'); + } +} + +/** + * Event handler support taxonomy bundle event settings. + */ +class RulesTaxonomyEventHandler extends RulesEventHandlerEntityBundle { + + /** + * Returns the label to use for the bundle property. + * + * @return string + * The label to use for the bundle property. + */ + protected function getBundlePropertyLabel() { + return t('vocabulary'); + } + +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/user.eval.inc b/modules/user.eval.inc new file mode 100644 index 0000000..65352cb --- /dev/null +++ b/modules/user.eval.inc @@ -0,0 +1,135 @@ +roles[$rid])) { + return TRUE; + } + } + return FALSE; + + case 'AND': + foreach ($roles as $rid) { + if (!isset($account->roles[$rid])) { + return FALSE; + } + } + return TRUE; + } +} + +/** + * Condition: User is blocked. + */ +function rules_condition_user_is_blocked($account) { + return $account->status == 0; +} + +/** + * Action: Adds roles to a particular user. + */ +function rules_action_user_add_role($account, $roles) { + if ($account->uid || !empty($account->is_new)) { + // Get role list (minus the anonymous). + $role_list = user_roles(TRUE); + + foreach ($roles as $rid) { + $account->roles[$rid] = $role_list[$rid]; + } + if (!empty($account->is_new) && $account->uid) { + // user_save() inserts roles after invoking hook_user_insert() anyway, so + // we skip saving to avoid errors due saving them twice. + return FALSE; + } + } + else { + return FALSE; + } +} + +/** + * Action: Remove roles from a given user. + */ +function rules_action_user_remove_role($account, $roles) { + if ($account->uid || !empty($account->is_new)) { + foreach ($roles as $rid) { + // If the user has this role, remove it. + if (isset($account->roles[$rid])) { + unset($account->roles[$rid]); + } + } + if (!empty($account->is_new) && $account->uid) { + // user_save() inserts roles after invoking hook_user_insert() anyway, so + // we skip saving to avoid errors due saving them twice. + return FALSE; + } + } + else { + return FALSE; + } +} + +/** + * Action: Block a user. + */ +function rules_action_user_block($account) { + $account->status = 0; + drupal_session_destroy_uid($account->uid); +} + +/** + * Action: Unblock a user. + */ +function rules_action_user_unblock($account) { + $account->status = 1; +} + +/** + * Action: Send a user account e-mail. + */ +function rules_action_user_send_account_email($account, $email_type) { + // If we received an authenticated user account... + if (!empty($account->uid)) { + module_load_include('inc', 'rules', 'modules/user.rules'); + $types = rules_user_account_email_options_list(); + + // Attempt to send the account e-mail. + // This code is adapted from _user_mail_notify(). + $params = array('account' => $account); + $language = user_preferred_language($account); + $mail = drupal_mail('user', $email_type, $account->mail, $language, $params); + if ($email_type == 'register_pending_approval') { + // If a user registered requiring admin approval, notify the admin, too. + // We use the site default language for this. + drupal_mail('user', 'register_pending_approval_admin', variable_get('site_mail', ini_get('sendmail_from')), language_default(), $params); + } + + $result = empty($mail) ? NULL : $mail['result']; + + // Log the success or failure. + if ($result) { + watchdog('rules', '%type e-mail sent to %recipient.', array('%type' => $types[$email_type], '%recipient' => $account->mail)); + } + else { + watchdog('rules', 'Failed to send %type e-mail to %recipient.', array('%type' => $types[$email_type], '%recipient' => $account->mail)); + } + } +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/modules/user.rules.inc b/modules/user.rules.inc new file mode 100644 index 0000000..2d69f8a --- /dev/null +++ b/modules/user.rules.inc @@ -0,0 +1,277 @@ + array( + 'label' => t('After saving a new user account'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('registered user')), + ), + 'access callback' => 'rules_user_integration_access', + ), + 'user_update' => array( + 'label' => t('After updating an existing user account'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('updated user')), + 'account_unchanged' => array('type' => 'user', 'label' => t('unchanged user'), 'handler' => 'rules_events_entity_unchanged'), + ), + 'access callback' => 'rules_user_integration_access', + ), + 'user_presave' => array( + 'label' => t('Before saving a user account'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('saved user'), 'skip save' => TRUE), + 'account_unchanged' => array('type' => 'user', 'label' => t('unchanged user'), 'handler' => 'rules_events_entity_unchanged'), + ), + 'access callback' => 'rules_user_integration_access', + ), + 'user_view' => array( + 'label' => t('User account page is viewed'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('viewed user')), + 'view_mode' => array( + 'type' => 'text', + 'label' => t('view mode'), + 'options list' => 'rules_get_entity_view_modes', + // Add the entity-type for the options list callback. + 'options list entity type' => 'user', + ), + ), + 'access callback' => 'rules_user_integration_access', + 'help' => t("Note that if drupal's page cache is enabled, this event won't be generated for pages served from cache."), + ), + 'user_delete' => array( + 'label' => t('After a user account has been deleted'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('deleted user')), + ), + 'access callback' => 'rules_user_integration_access', + ), + 'user_login' => array( + 'label' => t('User has logged in'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('logged in user')), + ), + 'access callback' => 'rules_user_integration_access', + ), + 'user_logout' => array( + 'label' => t('User has logged out'), + 'group' => t('User'), + 'variables' => array( + 'account' => array('type' => 'user', 'label' => t('logged out user')), + ), + 'access callback' => 'rules_user_integration_access', + ), + ); +} + +/** + * Options list for user cancel methods. + * + * @todo Use for providing a user_cancel action. + */ +function rules_user_cancel_methods() { + module_load_include('inc', 'user', 'user.pages'); + foreach (user_cancel_methods() as $method => $form) { + $methods[$method] = $form['#title']; + } + return $methods; +} + +/** + * User integration access callback. + */ +function rules_user_integration_access($type, $name) { + if ($type == 'event' || $type == 'condition') { + return entity_access('view', 'user'); + } + // Else return admin access. + return user_access('administer users'); +} + +/** + * Implements hook_rules_condition_info() on behalf of the user module. + */ +function rules_user_condition_info() { + return array( + 'user_has_role' => array( + 'label' => t('User has role(s)'), + 'parameter' => array( + 'account' => array('type' => 'user', 'label' => t('User')), + 'roles' => array( + 'type' => 'list', + 'label' => t('Roles'), + 'options list' => 'rules_user_roles_options_list', + ), + 'operation' => array( + 'type' => 'text', + 'label' => t('Match roles'), + 'options list' => 'rules_user_condition_operations', + 'restriction' => 'input', + 'optional' => TRUE, + 'default value' => 'AND', + 'description' => t('If matching against all selected roles, the user must have all the roles selected.'), + ), + ), + 'group' => t('User'), + 'access callback' => 'rules_user_integration_access', + 'base' => 'rules_condition_user_has_role', + ), + 'user_is_blocked' => array( + 'label' => t('User is blocked'), + 'parameter' => array( + 'account' => array('type' => 'user', 'label' => t('User')), + ), + 'group' => t('User'), + 'access callback' => 'rules_user_integration_access', + 'base' => 'rules_condition_user_is_blocked', + ), + ); +} + +/** + * User has role condition help callback. + */ +function rules_condition_user_has_role_help() { + return t('Whether the user has the selected role(s).'); +} + +/** + * Options list callback for the operation parameter of condition user has role. + */ +function rules_user_condition_operations() { + return array( + 'AND' => t('all'), + 'OR' => t('any'), + ); +} + +/** + * Implements hook_rules_action_info() on behalf of the user module. + */ +function rules_user_action_info() { + $defaults = array( + 'parameter' => array( + 'account' => array( + 'type' => 'user', + 'label' => t('User'), + 'description' => t('The user whose roles should be changed.'), + 'save' => TRUE, + ), + 'roles' => array( + 'type' => 'list', + 'label' => t('Roles'), + 'options list' => 'rules_user_roles_options_list', + ), + ), + 'group' => t('User'), + 'access callback' => 'rules_user_role_change_access', + ); + $items['user_add_role'] = $defaults + array( + 'label' => t('Add user role'), + 'base' => 'rules_action_user_add_role', + ); + $items['user_remove_role'] = $defaults + array( + 'label' => t('Remove user role'), + 'base' => 'rules_action_user_remove_role', + ); + $defaults = array( + 'parameter' => array( + 'account' => array( + 'type' => 'user', + 'label' => t('User'), + 'save' => TRUE, + ), + ), + 'group' => t('User'), + 'access callback' => 'rules_user_integration_access', + ); + $items['user_block'] = $defaults + array( + 'label' => t('Block a user'), + 'base' => 'rules_action_user_block', + ); + $items['user_unblock'] = $defaults + array( + 'label' => t('Unblock a user'), + 'base' => 'rules_action_user_unblock', + ); + $items['user_send_account_email'] = array( + 'label' => t('Send account e-mail'), + 'parameter' => array( + 'account' => array( + 'type' => 'user', + 'label' => t('Account'), + ), + 'email_type' => array( + 'type' => 'text', + 'label' => t('E-mail type'), + 'description' => t("Select the e-mail based on your site's account settings to send to the user."), + 'options list' => 'rules_user_account_email_options_list', + ), + ), + 'group' => t('User'), + 'base' => 'rules_action_user_send_account_email', + 'access callback' => 'rules_user_integration_access', + ); + return $items; +} + +/** + * User integration role actions access callback. + */ +function rules_user_role_change_access() { + return entity_metadata_user_roles() && user_access('administer permissions'); +} + +/** + * Options list callback for user roles. + */ +function rules_user_roles_options_list($element) { + return entity_metadata_user_roles('roles', array(), $element instanceof RulesConditionInterface ? 'view' : 'edit'); +} + +/** + * Options list callback for user account e-mail types. + * + * @see _user_mail_notify() + */ +function rules_user_account_email_options_list() { + return array( + 'register_admin_created' => t('Welcome (new user created by administrator)'), + 'register_no_approval_required' => t('Welcome (no approval required)'), + 'register_pending_approval' => t('Welcome (awaiting approval)'), + 'password_reset' => t('Password recovery'), + 'status_activated' => t('Account activation'), + 'status_blocked' => t('Account blocked'), + 'cancel_confirm' => t('Account cancellation confirmation'), + 'status_canceled' => t('Account canceled'), + ); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/rules.api.php b/rules.api.php new file mode 100644 index 0000000..23f52c5 --- /dev/null +++ b/rules.api.php @@ -0,0 +1,1103 @@ + as introduced by + * the entity metadata module, see hook_entity_property_info(). The special + * keyword '*' can be used when all types should be allowed. Required. + * - bundles: (optional) An array of bundle names. When the specified type is + * set to a single entity type, this may be used to restrict the allowed + * bundles. + * - description: (optional) If necessary, a further description of the + * parameter. + * - options list: (optional) A callback that returns an array of possible + * values for this parameter. The callback has to return an array as used + * by hook_options_list(). For an example implementation see + * rules_data_action_type_options(). + * - save: (optional) If this is set to TRUE, the parameter will be saved by + * rules when the rules evaluation ends. This is only supported for savable + * data types. If the action returns FALSE, saving is skipped. + * - optional: (optional) May be set to TRUE, when the parameter isn't + * required. + * - 'default value': (optional) The value to pass to the action, in case the + * parameter is optional and there is no specified value. + * - 'allow null': (optional) Usually Rules will not pass any NULL values as + * argument, but abort the evaluation if a NULL value is present. If set to + * TRUE, Rules will not abort and pass the NULL value through. Defaults to + * FALSE. + * - restriction: (optional) Restrict how the argument for this parameter may + * be provided. Supported values are 'selector' and 'input'. + * - default mode: (optional) Customize the default mode for providing the + * argument value for a parameter. Supported values are 'selector' and + * 'input'. The default depends on the required data type. + * - sanitize: (optional) Allows parameters of type 'text' to demand an + * already sanitized argument. If enabled, any user specified value won't be + * sanitized itself, but replacements applied by input evaluators are as + * well as values retrieved from selected data sources. + * - translatable: (optional) If set to TRUE, the provided argument value + * of the parameter is translatable via i18n String translation. This is + * applicable for textual parameters only, i.e. parameters of type 'text', + * 'token', 'list' and 'list'. Defaults to FALSE. + * - ui class: (optional) Allows overriding the UI class, which is used to + * generate the configuration UI of a parameter. Defaults to the UI class of + * the specified data type. + * - cleaning callback: (optional) A callback that input evaluators may use + * to clean inserted replacements; e.g. this is used by the token evaluator. + * - wrapped: (optional) Set this to TRUE in case the data should be passed + * wrapped. This only applies to wrapped data types, e.g. entities. + * Each 'provides' array may contain the following properties: + * - label: The label of the variable. Start capitalized. Required. + * - type: The rules data type of the variable. All types declared in + * hook_rules_data_info() may be specified. Types may be parametrized e.g. + * the types node or list are valid. + * - save: (optional) If this is set to TRUE, the provided variable is saved + * by rules when the rules evaluation ends. Only possible for savable data + * types. Defaults to FALSE. + * The module has to provide an implementation for each action, being a + * function named as specified in the 'base' key or for the execution callback. + * All other possible callbacks are optional. + * Supported action callbacks by rules are defined and documented in the + * RulesPluginImplInterface. However any module may extend the action plugin + * based upon a defined interface using hook_rules_plugin_info(). All methods + * defined in those interfaces can be overridden by the action implementation. + * The callback implementations for those interfaces may reside in any file + * specified in hook_rules_file_info(). + * + * @see hook_rules_file_info() + * @see rules_action_execution_callback() + * @see hook_rules_plugin_info() + * @see RulesPluginImplInterface + */ +function hook_rules_action_info() { + return array( + 'mail_user' => array( + 'label' => t('Send a mail to a user'), + 'parameter' => array( + 'user' => array('type' => 'user', 'label' => t('Recipient')), + ), + 'group' => t('System'), + 'base' => 'rules_action_mail_user', + 'callbacks' => array( + 'validate' => 'rules_action_custom_validation', + 'help' => 'rules_mail_help', + ), + ), + ); +} + +/** + * Define categories for Rules items, e.g. actions, conditions or events. + * + * Categories are similar to the previously used 'group' key in e.g. + * hook_rules_action_info(), but have a machine name and some more optional + * keys like a weight, or an icon. + * + * For best compatibility, modules may keep using the 'group' key for referring + * to categories. However, if a 'group' key and a 'category' is given the group + * will be treated as grouping in the given category (e.g. group "paypal" in + * category "commerce payment"). + * + * @return array + * An array of information about the module's provided categories. + * The array contains a sub-array for each category, with the category name as + * the key. Names may only contain lowercase alpha-numeric characters + * and underscores and should be prefixed with the providing module name. + * Possible attributes for each sub-array are: + * - label: The label of the category. Start capitalized. Required. + * - weight: (optional) A weight for sorting the category. Defaults to 0. + * - equals group: (optional) For BC, categories may be defined that equal + * a previously used 'group'. + * - icon: (optional) The file path of an icon to use, relative to the module + * or specified icon path. The icon should be a transparent SVG containing + * no colors (only #fff). See https://www.drupal.org/node/2090265 for + * instructions on how to create a suitable icon. + * Note that the icon is currently not used by Rules, however other UIs + * building upon Rules (like fluxkraft) do, and future releases of Rules + * might do as well. Consequently, the definition of an icon is optional. + * However, if both an icon font and icon is given, the icon is preferred. + * - icon path: (optional) The base path for the icon. Defaults to the + * providing module's directory. + * - icon font class: (optional) An icon font class referring to a suitable + * icon. Icon font class names should map to the ones as defined by Font + * Awesome, while themes might want to choose to provide another icon font. + * See http://fortawesome.github.io/Font-Awesome/cheatsheet/. + * - icon background color: (optional) The color used as icon background. + * Should have a high contrast to white. Defaults to #ddd. + */ +function hook_rules_category_info() { + return array( + 'rules_data' => array( + 'label' => t('Data'), + 'equals group' => t('Data'), + 'weight' => -50, + ), + 'fluxtwitter' => array( + 'label' => t('Twitter'), + 'icon font class' => 'icon-twitter', + 'icon font background color' => '#30a9fd', + ), + ); +} + +/** + * Specify files containing rules integration code. + * + * All files specified in that hook will be included when rules looks for + * existing callbacks for any plugin. Rules remembers which callback is found in + * which file and automatically includes the right file before it is executing + * a plugin method callback. The file yourmodule.rules.inc is added by default + * and need not be specified here. + * This allows you to add new include files only containing functions serving as + * plugin method callbacks in any file without having to care about file + * inclusion. + * + * @return array + * An array of file names without the file ending which defaults to '.inc'. + */ +function hook_rules_file_info() { + return array('yourmodule.rules-eval'); +} + +/** + * Specifies directories for class-based plugin handler discovery. + * + * Implementing this hook is not a requirement, it is just one option to load + * the files containing the classes during discovery - see + * rules_discover_plugins(). + * + * @return string|array + * A directory relative to the module directory, which holds the files + * containing rules plugin handlers, or multiple directories keyed by the + * module the directory is contained in. + * All files in those directories having a 'php' or 'inc' file extension will + * be loaded during discovery. Optionally, wildcards ('*') may be used to + * match multiple directories. + * + * @see rules_discover_plugins() + */ +function hook_rules_directory() { + return 'lib/Drupal/fluxtwitter/Rules/*'; +} + +/** + * The execution callback for an action. + * + * It should be placed in any file included by your module or in a file + * specified using hook_rules_file_info(). + * + * @param + * The callback gets arguments passed as described as parameter in + * hook_rules_action_info() as well as an array containing the action's + * configuration settings. + * + * @return array + * The action may return an array containing parameter or provided variables + * with their names as key. This is used update the value of a parameter or to + * provide the value for a provided variable. + * Apart from that any parameters which have the key 'save' set to TRUE will + * be remembered to be saved by rules unless the action returns FALSE. + * Conditions have to return a boolean value in any case. + * + * @see hook_rules_action_info() + * @see hook_rules_file_info() + */ +function rules_action_execution_callback($node, $title, $settings) { + $node->title = $title; + return array('node' => $node); +} + +/** + * Define rules conditions. + * + * This hook is required in order to add a new rules condition. It should be + * placed into the file MODULENAME.rules.inc, which gets automatically included + * when the hook is invoked. + * + * However, as an alternative to implementing this hook, class based plugin + * handlers may be provided by implementing RulesConditionHandlerInterface. See + * the interface for details. + * + * Adding conditions works exactly the same way as adding actions, with the + * exception that conditions can't provide variables and cannot save parameters. + * Thus the 'provides' attribute is not supported. Furthermore the condition + * implementation callback has to return a boolean value. + * + * @see hook_rules_action_info() + */ +function hook_rules_condition_info() { + return array( + 'rules_condition_text_compare' => array( + 'label' => t('Textual comparison'), + 'parameter' => array( + 'text1' => array('label' => t('Text 1'), 'type' => 'text'), + 'text2' => array('label' => t('Text 2'), 'type' => 'text'), + ), + 'group' => t('Rules'), + ), + ); +} + +/** + * Define rules events. + * + * This hook is required in order to add a new rules event. It should be + * placed into the file MODULENAME.rules.inc, which gets automatically included + * when the hook is invoked. + * The module has to invoke the event when it occurs using rules_invoke_event(). + * This function call has to happen outside of MODULENAME.rules.inc, + * usually it's invoked directly from the providing module but wrapped by a + * module_exists('rules') check. + * + * However, as an alternative to implementing this hook, class based event + * handlers may be provided by implementing RulesEventHandlerInterface. See + * the interface for details. + * + * @return array + * An array of information about the module's provided rules events. The array + * contains a sub-array for each event, with the event name as the key. The + * name may only contain lower case alpha-numeric characters and underscores + * and should be prefixed with the providing module name. Possible attributes + * for each sub-array are: + * - label: The label of the event. Start capitalized. Required. + * - group: A group for this element, used for grouping the events in the + * interface. Should start with a capital letter and be translated. + * Required. + * - class: (optional) An event handler class implementing the + * RulesEventHandlerInterface. If none is specified the + * RulesEventDefaultHandler class will be used. While the default event + * handler has no settings, custom event handlers may be implemented to + * to make an event configurable. See RulesEventHandlerInterface. + * - access callback: (optional) An callback, which has to return whether the + * currently logged in user is allowed to configure rules for this event. + * Access should be only granted, if the user at least may access all the + * variables provided by the event. + * - help: (optional) A help text for rules reaction on this event. + * - variables: (optional) An array describing all variables that are + * available for elements reacting on this event. Each variable has to be + * described by a sub-array with the possible attributes: + * - label: The label of the variable. Start capitalized. Required. + * - type: The rules data type of the variable. All types declared in + * hook_rules_data_info() or supported by hook_entity_property_info() may + * be specified. + * - bundle: (optional) If the type is an entity type, the bundle of the + * entity. + * - description: (optional) A description for the variable. + * - 'options list': (optional) A callback that returns an array of possible + * values for this variable as specified for entity properties at + * hook_entity_property_info(). + * - 'skip save': (optional) If the variable is saved after the event has + * occurred anyway, set this to TRUE. So rules won't save the variable a + * second time. Defaults to FALSE. + * - handler: (optional) A handler to load the actual variable value. This + * is useful for lazy loading variables. The handler gets all so far + * available variables passed in the order as defined. Also see + * https://www.drupal.org/node/884554. + * Note that for lazy-loading entities just the entity id may be passed + * as variable value, so a handler is not necessary in that case. + * + * @see rules_invoke_event() + */ +function hook_rules_event_info() { + $items = array( + 'node_insert' => array( + 'label' => t('After saving new content'), + 'group' => t('Node'), + 'variables' => rules_events_node_variables(t('created content')), + ), + 'node_update' => array( + 'label' => t('After updating existing content'), + 'group' => t('Node'), + 'variables' => rules_events_node_variables(t('updated content'), TRUE), + ), + 'node_presave' => array( + 'label' => t('Content is going to be saved'), + 'group' => t('Node'), + 'variables' => rules_events_node_variables(t('saved content'), TRUE), + ), + 'node_view' => array( + 'label' => t('Content is going to be viewed'), + 'group' => t('Node'), + 'variables' => rules_events_node_variables(t('viewed content')) + array( + 'view_mode' => array('type' => 'text', 'label' => t('view mode')), + ), + ), + 'node_delete' => array( + 'label' => t('After deleting content'), + 'group' => t('Node'), + 'variables' => rules_events_node_variables(t('deleted content')), + ), + ); + // Specify that on presave the node is saved anyway. + $items['node_presave']['variables']['node']['skip save'] = TRUE; + return $items; +} + +/** + * Define rules data types. + * + * This hook is required in order to add a new rules data type. It should be + * placed into the file MODULENAME.rules.inc, which gets automatically included + * when the hook is invoked. + * Rules builds upon the entity metadata module, thus to improve the support of + * your data in rules, make it an entity if possible and provide metadata about + * its properties and CRUD functions by integrating with the entity metadata + * module. + * For a list of data types defined by rules see rules_rules_core_data_info(). + * + * @return array + * An array of information about the module's provided data types. The array + * contains a sub-array for each data type, with the data type name as the + * key. The name may only contain lower case alpha-numeric characters and + * underscores and should be prefixed with the providing module name. Possible + * attributes for each sub-array are: + * - label: The label of the data type. Start uncapitalized. Required. + * - parent: (optional) A parent type may be set to specify a sub-type + * relationship, which will be only used for checking compatible types. E.g. + * the 'entity' data type is parent of the 'node' data type, thus a node may + * be also used for any action needing an 'entity' parameter. Can be set to + * any known rules data type. + * - ui class: (optional) Specify a class that is used to generate the + * configuration UI to configure parameters of this type. The given class + * must extend RulesDataUI and may implement the + * RulesDataDirectInputFormInterface in order to allow the direct data input + * configuration mode. For supporting selecting values from options lists, + * the UI class may implement RulesDataInputOptionsListInterface also. + * Defaults to RulesDataUI. + * - wrap: (optional) If set to TRUE, the data is wrapped internally using + * wrappers provided by the entity API module. This is required for entities + * and data structures to support selecting a property via the data selector + * and for intelligent saving. + * - is wrapped: (optional) In case the data wrapper is already wrapped when + * passed to Rules and Rules should not unwrap it when passing the data as + * argument, e.g. to an action, set this to TRUE. The default FALSE is fine + * in most cases. + * - wrapper class: (optional) Allows the specification of a custom wrapper + * class, which has to inherit from 'EntityMetadataWrapper'. If given Rules + * makes use of the class for wrapping the data of the given type. However + * note that if data is already wrapped when it is passed to Rules, the + * existing wrappers will be kept. + * For modules implementing identifiable data types being non-entities the + * class RulesIdentifiableDataWrapper is provided, which can be used as base + * for a custom wrapper class. See RulesIdentifiableDataWrapper for details. + * - property info: (optional) May be used for non-entity data structures to + * provide info about the data properties, such that data selectors via an + * entity metadata wrapper are supported. Specify an array as expected by + * the $info parameter of entity_metadata_wrapper(). + * - creation callback: (optional) If 'property info' is given, an optional + * callback that makes use of the property info to create a new instance of + * this data type. Entities should use hook_entity_info() to specify the + * 'creation callback' instead, as utilized by the entity API module. See + * rules_action_data_create_array() for an example callback. + * - property defaults: (optional) May be used for non-entity data structures + * to to provide property info defaults for the data properties. Specify an + * array as expected by entity_metadata_wrapper(). + * - group: (optional) A group for this element, used for grouping the data + * types in the interface. Should start with a capital letter and be + * translated. + * - token type: (optional) The type name as used by the token module. + * Defaults to the type name as used by rules. Use FALSE to let token ignore + * this type. + * - cleaning callback: (optional) A callback that input evaluators may use + * to clean inserted replacements; e.g. this is used by the token evaluator. + * + * @see entity_metadata_wrapper() + * @see hook_rules_data_info_alter() + * @see rules_rules_core_data_info() + */ +function hook_rules_data_info() { + return array( + 'node' => array( + 'label' => t('content'), + 'parent' => 'entity', + 'group' => t('Node'), + ), + // Formatted text as used by in hook_entity_property_info() for text fields. + 'text_formatted' => array( + 'label' => t('formatted text'), + 'ui class' => 'RulesDataUITextFormatted', + 'wrap' => TRUE, + 'property info' => entity_property_text_formatted_info(), + ), + ); +} + +/** + * Defines rules plugins. + * + * A rules configuration may consist of elements being instances of any rules + * plugin. This hook can be used to define new or to extend rules plugins. + * + * @return array + * An array of information about the module's provided rules plugins. The + * array contains a sub-array for each plugin, with the plugin name as the + * key. The name may only contain lower case alpha-numeric characters, + * underscores and spaces and should be prefixed with the providing module + * name. Possible attributes for + * each sub-array are: + * - label: A label for the plugin. Start capitalized. Required only for + * components (see below). + * - class: The implementation class. Has to extend the RulesPlugin class. + * - embeddable: A container class in which elements of those plugin may be + * embedded via the UI or FALSE to disallow embedding it via the UI. This + * has no implications on the API level though. Common classes that are + * used here are RulesConditionContainer and RulesActionContainer. + * - component: If set to TRUE, the rules admin UI will list elements of those + * plugin in the components UI and allows the creation of new components + * based upon this plugin. Optional. + * - extenders: This allows one to specify faces extenders, which may be used + * to dynamically implement interfaces. Optional. All extenders specified + * here are setup automatically by rules once the object is created. To + * specify set this to an array, where the keys are the implemented + * interfaces pointing to another array with the keys: + * - class: The class of the extender, implementing the FacesExtender + * and the specified interface. Either 'class' or 'methods' has to exist. + * - methods: An array of callbacks that implement the methods of the + * interface where the method names are the keys and the callback names + * the values. There has to be a callback for each defined method. + * - file: An optional array describing the file to include when a method + * of the interface is invoked. The array entries known are 'type', + * 'module', and 'name' matching the parameters of module_load_include(). + * Only 'module' is required as 'type' defaults to 'inc' and 'name' to + * NULL. + * - overrides: An optional array, which may be used to specify callbacks to + * override specific methods. For that the following keys are supported: + * - methods: As in the extenders array, but you may specify as many methods + * here as you like. + * - file: Optionally an array specifying a file to include for a method. + * For each method appearing in methods a file may be specified by using + * the method name as key and another array as value, which describes the + * file to include - looking like the file array supported by 'extenders'. + * - import keys: (optional) Embeddable plugins may specify an array of import + * keys, which the plugin make use for exporting. Defaults to the upper + * case plugin name, thus the key 'OR' in an export triggers the creation + * of the 'or' plugin. Note that only uppercase values are allowed, as + * lower case values are treated as action or condition exports. + * + * @see RulesPlugin + * @see hook_rules_plugin_info_alter() + */ +function hook_rules_plugin_info() { + return array( + 'or' => array( + 'label' => t('Condition set (OR)'), + 'class' => 'RulesOr', + 'embeddable' => 'RulesConditionContainer', + 'component' => TRUE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesConditionContainerUI', + ), + ), + ), + 'rule' => array( + 'class' => 'Rule', + 'embeddable' => 'RulesRuleSet', + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesRuleUI', + ), + ), + 'import keys' => array('DO', 'IF'), + ), + ); +} + +/** + * Declare provided rules input evaluators. + * + * The hook implementation should be placed into the file MODULENAME.rules.inc, + * which gets automatically included when the hook is invoked. + * For implementing an input evaluator a class has to be provided which + * extends the abstract RulesDataInputEvaluator class. Therefore the abstract + * methods prepare() and evaluate() have to be implemented, as well as access() + * and help() could be overridden in order to control access permissions or to + * provide some usage help. + * + * @return array + * An array of information about the module's provided input evaluators. The + * array contains a sub-array for each evaluator, with the evaluator name as + * the key. The name may only contain lower case alpha-numeric characters and + * underscores and should be prefixed with the providing module name. Possible + * attributes for each sub-array are: + * - class: The implementation class, which has to extend the + * RulesDataInputEvaluator class. Required. + * - weight: A weight for controlling the evaluation order of multiple + * evaluators. Required. + * - type: Optionally, the data types for which the input evaluator should be + * used. Defaults to 'text'. Multiple data types may be specified using an + * array. + * + * @see RulesDataInputEvaluator + * @see hook_rules_evaluator_info_alter() + */ +function hook_rules_evaluator_info() { + return array( + 'token' => array( + 'class' => 'RulesTokenEvaluator', + 'type' => array('text', 'uri'), + 'weight' => 0, + ), + ); +} + +/** + * Declare provided rules data processors. + * + * The hook implementation should be placed into the file MODULENAME.rules.inc, + * which gets automatically included when the hook is invoked. + * For implementing a data processors a class has to be provided which + * extends the abstract RulesDataProcessor class. Therefore the abstract + * method process() has to be implemented, but also the methods form() and + * access() could be overridden in order to provide a configuration form or + * to control access permissions. + * + * @return array + * An array of information about the module's provided data processors. The + * array contains a sub-array for each processor, with the processor name as + * the key. The name may only contain lower case alpha-numeric characters and + * underscores and should be prefixed with the providing module name, whereas + * 'select' is reserved as well. + * Possible attributes for each sub-array are: + * - class: The implementation class, which has to extend the + * RulesDataProcessor class. Required. + * - weight: A weight for controlling the processing order of multiple data + * processors. Required. + * - type: Optionally, the data types for which the data processor should be + * used. Defaults to 'text'. Multiple data types may be specified using an + * array. + * + * @see RulesDataProcessor + * @see hook_rules_data_processor_info_alter() + */ +function hook_rules_data_processor_info() { + return array( + 'date_offset' => array( + 'class' => 'RulesDateOffsetProcessor', + 'type' => 'date', + 'weight' => -2, + ), + ); +} + +/** + * Alter rules compatible actions. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $actions + * The items of all modules as returned from hook_rules_action_info(). + * + * @see hook_rules_action_info() + */ +function hook_rules_action_info_alter(&$actions) { + // The rules action is more powerful, so hide the core action. + unset($actions['rules_core_node_assign_owner_action']); + // We prefer handling saving by rules - not by the user. + unset($actions['rules_core_node_save_action']); +} + +/** + * Alter rules conditions. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $conditions + * The items of all modules as returned from hook_rules_condition_info(). + * + * @see hook_rules_condition_info() + */ +function hook_rules_condition_info_alter(&$conditions) { + // Change conditions. +} + +/** + * Alter rules events. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $events + * The items of all modules as returned from hook_rules_event_info(). + * + * @see hook_rules_event_info() + */ +function hook_rules_event_info_alter(&$events) { + // Change events. +} + +/** + * Alter rules data types. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $data_info + * The items of all modules as returned from hook_rules_data_info(). + * + * @see hook_rules_data_info() + */ +function hook_rules_data_info_alter(&$data_info) { + // Change data types. +} + +/** + * Alter rules plugin info. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $plugin_info + * The items of all modules as returned from hook_rules_plugin_info(). + * + * @see hook_rules_plugin_info() + */ +function hook_rules_plugin_info_alter(&$plugin_info) { + // Change plugin info. +} + +/** + * Alter rules input evaluator info. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $evaluator_info + * The items of all modules as returned from hook_rules_evaluator_info(). + * + * @see hook_rules_evaluator_info() + */ +function hook_rules_evaluator_info_alter(&$evaluator_info) { + // Change evaluator info. +} + +/** + * Alter rules data_processor info. + * + * The implementation should be placed into the file MODULENAME.rules.inc, which + * gets automatically included when the hook is invoked. + * + * @param $processor_info + * The items of all modules as returned from hook_rules_data_processor_info(). + * + * @see hook_rules_data_processor_info() + */ +function hook_rules_data_processor_info_alter(&$processor_info) { + // Change processor info. +} + +/** + * Act on rules configuration being loaded from the database. + * + * This hook is invoked during rules configuration loading, which is handled + * by entity_load(), via classes RulesEntityController and EntityCRUDController. + * + * @param array $configs + * An array of rules configurations being loaded, keyed by id. + */ +function hook_rules_config_load($configs) { + $result = db_query('SELECT id, foo FROM {mytable} WHERE id IN(:ids)', array(':ids' => array_keys($configs))); + foreach ($result as $record) { + $configs[$record->id]->foo = $record->foo; + } +} + +/** + * Respond to creation of a new rules configuration. + * + * This hook is invoked after the rules configuration is inserted into the + * the database. + * + * @param RulesPlugin $config + * The rules configuration that is being created. + */ +function hook_rules_config_insert($config) { + db_insert('mytable') + ->fields(array( + 'nid' => $config->id, + 'plugin' => $config->plugin, + )) + ->execute(); +} + +/** + * Act on a rules configuration being inserted or updated. + * + * This hook is invoked before the rules configuration is saved to the + * database. + * + * @param RulesPlugin $config + * The rules configuration that is being inserted or updated. + */ +function hook_rules_config_presave($config) { + if ($config->id && $config->owner == 'your_module') { + // Add custom condition. + $config->conditon(/* Your condition */); + } +} + +/** + * Respond to updates to a rules configuration. + * + * This hook is invoked after the configuration has been updated in the + * database. + * + * @param RulesPlugin $config + * The rules configuration that is being updated. + */ +function hook_rules_config_update($config) { + db_update('mytable') + ->fields(array('plugin' => $config->plugin)) + ->condition('id', $config->id) + ->execute(); +} + +/** + * Respond to rules configuration deletion. + * + * This hook is invoked after the configuration has been removed from the + * database. + * + * @param RulesPlugin $config + * The rules configuration that is being deleted. + */ +function hook_rules_config_delete($config) { + db_delete('mytable') + ->condition('id', $config->id) + ->execute(); +} + +/** + * Respond to rules configuration execution. + * + * This hook is invoked right before the rules configuration is executed. + * + * @param RulesPlugin $config + * The rules configuration that is being executed. + */ +function hook_rules_config_execute($config) { + +} + +/** + * Define default rules configurations. + * + * This hook is invoked when rules configurations are loaded. The implementation + * should be placed into the file MODULENAME.rules_defaults.inc, which gets + * automatically included when the hook is invoked. + * + * @return array + * An array of rules configurations with the configuration names as keys. + * + * @see hook_default_rules_configuration_alter() + * @see hook_rules_config_defaults_rebuild() + */ +function hook_default_rules_configuration() { + $rule = rules_reaction_rule(); + $rule->label = 'example default rule'; + // Add rules tags. + $rule->tags = array('Admin', 'Tag2'); + $rule->active = FALSE; + $rule->event('node_update') + ->condition(rules_condition('data_is', array('data:select' => 'node:status', 'value' => TRUE))->negate()) + ->condition('data_is', array('data:select' => 'node:type', 'value' => 'page')) + ->action('drupal_message', array('message' => 'A node has been updated.')); + + $configs['rules_test_default_1'] = $rule; + + return $configs; +} + +/** + * Alter default rules configurations. + * + * The implementation should be placed into the file + * MODULENAME.rules_defaults.inc, which gets automatically included when the + * hook is invoked. + * + * @param $configs + * The default configurations of all modules as returned from + * hook_default_rules_configuration(). + * + * @see hook_default_rules_configuration() + */ +function hook_default_rules_configuration_alter(&$configs) { + // Add custom condition. + $configs['foo']->condition('bar'); +} + +/** + * Act after rebuilding default configurations. + * + * This hook is invoked by the entity module after default rules configurations + * have been rebuilt; i.e. defaults have been saved to the database. + * + * @param array $rules_configs + * The array of default rules configurations which have been inserted or + * updated, keyed by name. + * @param array $originals + * An array of original rules configurations keyed by name; i.e. the rules + * configurations before the current defaults have been applied. For inserted + * rules configurations no original is available. + * + * @see hook_default_rules_configuration() + * @see entity_defaults_rebuild() + */ +function hook_rules_config_defaults_rebuild($rules_configs, $originals) { + // Once all defaults have been rebuilt, update all i18n strings at once. That + // way we build the rules cache once the rebuild is complete and avoid + // rebuilding caches for each updated rule. + foreach ($rules_configs as $name => $rule_config) { + if (empty($originals[$name])) { + rules_i18n_rules_config_insert($rule_config); + } + else { + rules_i18n_rules_config_update($rule_config, $originals[$name]); + } + } +} + +/** + * Alter rules components before execution. + * + * This hooks allows altering rules components before they are cached for later + * re-use. Use this hook only for altering the component in order to prepare + * re-use through rules_invoke_component() or the provided condition/action. + * Note that this hook is only invoked for any components cached for execution, + * but not for components that are programmatically created and executed on the + * fly (without saving them). + * + * @param $plugin + * The name of the component plugin. + * @param $component + * The component that is to be cached. + * + * @see rules_invoke_component() + */ +function hook_rules_component_alter($plugin, RulesPlugin $component) { + +} + +/** + * Alters event sets. + * + * This hooks allows altering rules event sets, which contain all rules that are + * triggered upon a specific event. Rules internally caches all rules associated + * to an event in an event set, which is cached for fast evaluation. This hook + * is invoked just before any event set is cached, thus it allows altering of + * the to be executed rules without the changes to appear in the UI, e.g. to add + * a further condition to some rules. + * + * @param $event_name + * The name of the event. + * @param $event_set + * The event set that is to be cached. + * + * @see rules_invoke_event() + */ +function hook_rules_event_set_alter($event_name, RulesEventSet $event_set) { + +} + +/** + * D6 to D7 upgrade procedure hook for mapping action or condition names. + * + * If for a module the action or condition name changed since Drupal 6, this + * "hook" can be implemented in order to map to the new name of the action or + * condition. + * + * This is no real hook, but a callback that is invoked for each Drupal 6 + * action or condition that is to be upgraded to Drupal 7. E.g. the function + * name called for the action "rules_action_set_node_title" would be + * "rules_action_set_node_title_upgrade_map_name". + * + * @param $element + * The element array of a configured condition or action which is to be + * upgraded. + * + * @return string + * The name of the action or condition which should be used. + */ +function hook_rules_action_base_upgrade_map_name($element) { + return 'data_set'; +} + +/** + * D6 to D7 upgrade process hook for mapping action or condition configuration. + * + * During upgrading Drupal 6 rule configurations to Drupal 7 Rules is taking + * care of upgrading the configuration of all known parameters, which only works + * if the parameter name has not changed. + * If something changed, this callback can be used to properly apply the + * configuration of the Drupal 6 action ($element) to the Drupal 7 version + * ($target). + * + * This is no real hook, but a callback that is invoked for each Drupal 6 + * action or condition that is to be upgraded to Drupal 7. E.g. the function + * name called for the action "rules_action_set_node_title" would be + * "rules_action_set_node_title_upgrade". + * + * @param $element + * The element array of a configured condition or action which is to be + * upgraded. + * @param $target + * The Drupal 7 version of the configured element. + * + * @see hook_rules_element_upgrade_alter() + */ +function hook_rules_action_base_upgrade($element, RulesPlugin $target) { + $target->settings['data:select'] = $element['#settings']['#argument map']['node'] . ':title'; + $target->settings['value'] = $element['#settings']['title']; +} + +/** + * D6 to D7 upgrade process hook for mapping action or condition configuration. + * + * A alter hook that is called after the action/condition specific callback for + * each element of a configuration that is upgraded. + * + * @param $element + * The element array of a configured condition or action which is to be + * upgraded. + * @param $target + * The Drupal 7 version of the configured element. + * + * @see hook_rules_action_base_upgrade() + */ +function hook_rules_element_upgrade_alter($element, $target) { + +} + +/** + * Allows modules to alter or to extend the provided Rules UI. + * + * Use this hook over the regular hook_menu_alter() as the Rules UI is re-used + * and embedded by modules. See rules_ui(). + * + * @param $items + * The menu items to alter. + * @param $base_path + * The base path of the Rules UI. + * @param $base_count + * The count of the directories contained in the base path. + */ +function hook_rules_ui_menu_alter(&$items, $base_path, $base_count) { + $items[$base_path . '/manage/%rules_config/schedule'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Schedule !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_scheduler_schedule_form', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'rules_scheduler.admin.inc', + 'file path' => drupal_get_path('module', 'rules_scheduler'), + ); +} + +/** + * Control access to Rules configurations. + * + * Modules may implement this hook if they want to have a say in whether or not + * a given user has access to perform a given operation on a Rules + * configuration. + * + * @param string $op + * The operation being performed. One of 'view', 'create', 'update' or + * 'delete'. + * @param $rules_config + * (optional) A Rules configuration to check access for. If nothing is given, + * access for all Rules configurations is determined. + * @param $account + * (optional) The user to check for. If no account is passed, access is + * determined for the current user. + * + * @return bool|null + * Return TRUE to grant access, FALSE to explicitly deny access. Return NULL + * or nothing to not affect the operation. + * Access is granted as soon as a module grants access and no one denies + * access. Thus if no module explicitly grants access, access will be denied. + * + * @see rules_config_access() + */ +function hook_rules_config_access($op, $rules_config = NULL, $account = NULL) { + // Instead of returning FALSE return nothing, so others still can grant + // access. + if (isset($rules_config) && $rules_config->owner == 'mymodule' && user_access('my modules permission')) { + return TRUE; + } +} + +/** + * @} End of "defgroup rules_hooks" + */ diff --git a/rules.drush.inc b/rules.drush.inc new file mode 100644 index 0000000..9d4c18d --- /dev/null +++ b/rules.drush.inc @@ -0,0 +1,252 @@ + 'List all the active and inactive rules for your site.', + 'drupal dependencies' => array('rules'), + 'aliases' => array('rules'), + 'outputformat' => array( + 'default' => 'table', + 'pipe-format' => 'list', + 'field-labels' => array( + 'rule' => dt('Rule'), + 'label' => dt('Label'), + 'event' => dt('Event'), + 'active' => dt('Active'), + 'status' => dt('Status'), + ), + 'output-data-type' => 'format-table', + ), + ); + $items['rules-enable'] = array( + 'description' => 'Enable a rule on your site.', + 'arguments' => array( + 'rule' => 'Rule name to enable.', + ), + 'drupal dependencies' => array('rules'), + 'aliases' => array('re'), + ); + $items['rules-disable'] = array( + 'description' => 'Disable a rule on your site.', + 'arguments' => array( + 'rule' => 'Rule name to export.', + ), + 'drupal dependencies' => array('rules'), + 'aliases' => array('rd'), + ); + $items['rules-revert'] = array( + 'description' => 'Revert a rule to its original state on your site.', + 'arguments' => array( + 'rule' => 'Rule name to revert.', + ), + 'drupal dependencies' => array('rules'), + ); + $items['rules-delete'] = array( + 'description' => 'Delete a rule on your site.', + 'arguments' => array( + 'rule' => 'Rules name to delete.', + ), + 'drupal dependencies' => array('rules'), + ); + $items['rules-export'] = array( + 'description' => 'Export a rule.', + 'arguments' => array( + 'rule' => 'Rules name to export.', + ), + 'drupal dependencies' => array('rules'), + ); + + return $items; +} + +/** + * Implements hook_drush_help(). + */ +function rules_drush_help($section) { + switch ($section) { + case 'drush:rules-list': + return dt('List all the rules on your site.'); + + case 'drush:rules-enable': + return dt('Enable/activate a rule on your site.'); + + case 'drush:rules-disable': + return dt('Disable/deactivate a rule on your site.'); + + case 'drush:rules-revert': + return dt('Revert a module-provided rule to its original state on your site.'); + + case 'drush:rules-delete': + return dt('Delete a rule on your site.'); + + case 'drush:rules-export': + return dt('Export a rule.'); + } +} + +/** + * Get a list of all rules. + */ +function drush_rules_list() { + $rules = rules_config_load_multiple(FALSE); + $rows = array(); + foreach ($rules as $rule) { + if (!empty($rule->name) && !empty($rule->label)) { + $events = array(); + $event_info = rules_fetch_data('event_info'); + if ($rule instanceof RulesTriggerableInterface) { + foreach ($rule->events() as $event_name) { + $event_info += array( + $event_name => array( + 'label' => dt('Unknown event "!event_name"', array('!event_name' => $event_name)), + ), + ); + $events[] = check_plain($event_info[$event_name]['label']); + } + } + $rows[$rule->name] = array( + 'rule' => $rule->name, + 'label' => $rule->label, + 'event' => implode(', ', $events), + 'active' => $rule->active ? dt('Enabled') : dt('Disabled'), + 'status' => $rule->status ? theme('entity_status', array('status' => $rule->status, 'html' => FALSE)) : '', + ); + } + } + if (version_compare(DRUSH_VERSION, '6.0', '<')) { + drush_print_table($rows, TRUE); + } + return $rows; +} + +/** + * Enable a rule on the site. + */ +function drush_rules_enable() { + $args = func_get_args(); + $rule_name = (!empty($args) && is_array($args)) ? array_shift($args) : ''; + if (empty($rule_name)) { + return drush_set_error('', 'No rule name given.'); + } + + $rule = rules_config_load($rule_name); + if (empty($rule)) { + return drush_set_error('', dt('Could not load rule named "!rule-name".', array('!rule-name' => $rule_name))); + } + + if (empty($rule->active)) { + $rule->active = TRUE; + $rule->save(); + drush_log(dt('The rule "!name" has been enabled.', array('!name' => $rule_name)), 'success'); + } + else { + drush_log(dt('The rule "!name" is already enabled.', array('!name' => $rule_name)), 'warning'); + } +} + +/** + * Disable a rule on the site. + */ +function drush_rules_disable() { + $args = func_get_args(); + $rule_name = (!empty($args) && is_array($args)) ? array_shift($args) : ''; + if (empty($rule_name)) { + return drush_set_error('', 'No rule name given.'); + } + + $rule = rules_config_load($rule_name); + if (empty($rule)) { + return drush_set_error('', dt('Could not load rule named "!rule-name".', array('!rule-name' => $rule_name))); + } + + if (!empty($rule->active)) { + $rule->active = FALSE; + $rule->save(); + drush_log(dt('The rule "!name" has been disabled.', array('!name' => $rule_name)), 'success'); + } + else { + drush_log(dt('The rule "!name" is already disabled.', array('!name' => $rule_name)), 'warning'); + } +} + +/** + * Reverts a rule on the site. + */ +function drush_rules_revert() { + $args = func_get_args(); + $rule_name = (!empty($args) && is_array($args)) ? array_shift($args) : ''; + if (empty($rule_name)) { + return drush_set_error('', 'No rule name given.'); + } + + $rule = rules_config_load($rule_name); + if (empty($rule)) { + return drush_set_error('', dt('Could not load rule named "!rule-name".', array('!rule-name' => $rule_name))); + } + + if (($rule->status & ENTITY_OVERRIDDEN) == ENTITY_OVERRIDDEN) { + if (drush_confirm(dt('Are you sure you want to revert the rule named "!rule-name"? This action cannot be undone.', array('!rule-name' => $rule_name)))) { + $rule->delete(); + drush_log(dt('The rule "!name" has been reverted to its default state.', array('!name' => $rule_name)), 'success'); + } + else { + drush_user_abort(); + } + } + else { + drush_log(dt('The rule "!name" has not been overridden and can\'t be reverted.', array('!name' => $rule_name)), 'warning'); + } +} + +/** + * Deletes a rule on the site. + */ +function drush_rules_delete() { + $args = func_get_args(); + $rule_name = (!empty($args) && is_array($args)) ? array_shift($args) : ''; + if (empty($rule_name)) { + return drush_set_error('', 'No rule name given.'); + } + + $rule = rules_config_load($rule_name); + if (empty($rule)) { + return drush_set_error('', dt('Could not load rule named "!rule-name".', array('!rule-name' => $rule_name))); + } + + if (drush_confirm(dt('Are you sure you want to delete the rule named "!rule-name"? This action cannot be undone.', array('!rule-name' => $rule_name)))) { + $rule->delete(); + drush_log(dt('The rule "!name" has been deleted.', array('!name' => $rule_name)), 'success'); + } + else { + drush_user_abort(); + } +} + +/** + * Exports a single rule. + */ +function drush_rules_export() { + $args = func_get_args(); + $rule_name = (!empty($args) && is_array($args)) ? array_shift($args) : ''; + if (empty($rule_name)) { + return drush_set_error('', dt('No rule name given.')); + } + + $rule = rules_config_load($rule_name); + if (empty($rule)) { + return drush_set_error('', dt('Could not load rule named "!rule-name".', array('!rule-name' => $rule_name))); + } + + drush_print($rule->export()); + drush_log(dt('The rule "!name" has been exported.', array('!name' => $rule_name)), 'success'); +} diff --git a/rules.features.inc b/rules.features.inc new file mode 100644 index 0000000..e3224b5 --- /dev/null +++ b/rules.features.inc @@ -0,0 +1,91 @@ +type, $data) as $name => $rules_config) { + // Add in the dependencies. + $export['dependencies'] += drupal_map_assoc($rules_config->dependencies()); + // Add in plugin / element specific additions. + $iterator = new RecursiveIteratorIterator($rules_config, RecursiveIteratorIterator::SELF_FIRST); + foreach ($iterator as $element) { + if ($element->facesAs('RulesPluginFeaturesIntegrationInterface')) { + // Directly use __call() so we can pass $export by reference. + $element->__call('features_export', array(&$export, &$pipe, $module_name)); + } + } + } + return $pipe; + } + +} + +/** + * Default extension callback used as default for the abstract plugin class. + * + * Actions and conditions may override this with an implementation which + * actually does something. + * + * @see RulesPluginFeaturesIntegrationInterface + */ +function rules_features_abstract_default_features_export(&$export, &$pipe, $module_name = '', $element) { + // Do nothing. +} + +/** + * Interface to give features access to the faces extensions mechanism. + * + * Interface that allows rules plugins or actions/conditions to customize the + * features export by implementing the interface using the faces extensions + * mechanism. + * + * @see hook_rules_plugin_info() + * @see hook_rules_action_info() + */ +interface RulesPluginFeaturesIntegrationInterface { + + /** + * Allows customizing the features export for a given rule element. + */ + public function features_export(&$export, &$pipe, $module_name = ''); + +} + +/** + * Interface for backwards compatibility with older versions of Rules. + * + * Mis-spelled interface provided so that contributed modules which were + * implementing the wrong spelling (corrected in Rules 7.x-2.12) will not stop + * working now that the interface is spelled correctly. + * + * @todo Remove this when we can be sure that no contributed modules are + * still using the wrong spelling. + */ +interface RulesPluginFeaturesIntegrationInterace extends RulesPluginFeaturesIntegrationInterface { +} diff --git a/rules.info b/rules.info new file mode 100644 index 0000000..823ffeb --- /dev/null +++ b/rules.info @@ -0,0 +1,36 @@ +name = Rules +description = React on events and conditionally evaluate actions. +package = Rules +core = 7.x +files[] = rules.features.inc +files[] = includes/faces.inc +files[] = includes/rules.core.inc +files[] = includes/rules.event.inc +files[] = includes/rules.processor.inc +files[] = includes/rules.plugins.inc +files[] = includes/rules.state.inc +files[] = modules/comment.rules.inc +files[] = modules/node.eval.inc +files[] = modules/node.rules.inc +files[] = modules/php.eval.inc +files[] = modules/rules_core.eval.inc +files[] = modules/system.eval.inc +files[] = modules/taxonomy.rules.inc +files[] = ui/ui.controller.inc +files[] = ui/ui.core.inc +files[] = ui/ui.data.inc +files[] = ui/ui.plugins.inc + +; Test cases +files[] = tests/rules.test +files[] = tests/rules_test.rules.inc + +dependencies[] = system (>= 7.40) +dependencies[] = entity +dependencies[] = entity:entity_token + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/rules.install b/rules.install new file mode 100644 index 0000000..b852bae --- /dev/null +++ b/rules.install @@ -0,0 +1,563 @@ +condition('name', 'rules_debug_region_%', 'LIKE') + ->execute(); + cache_clear_all('variables', 'cache_bootstrap'); +} + +/** + * Implements hook_schema(). + */ +function rules_schema() { + $schema['rules_config'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'The internal identifier for any configuration.', + ), + 'name' => array( + 'type' => 'varchar', + 'length' => '64', + 'not null' => TRUE, + 'description' => 'The name of the configuration.', + ), + 'label' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The label of the configuration.', + 'default' => 'unlabeled', + ), + 'plugin' => array( + 'type' => 'varchar', + 'length' => 127, + 'not null' => TRUE, + 'description' => 'The name of the plugin of this configuration.', + ), + 'active' => array( + 'description' => 'Boolean indicating whether the configuration is active. Usage depends on how the using module makes use of it.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 1, + ), + 'weight' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'Weight of the configuration. Usage depends on how the using module makes use of it.', + ), + 'status' => array( + 'type' => 'int', + 'not null' => TRUE, + // Set the default to ENTITY_CUSTOM without using the constant as it is + // not safe to use it at this point. + 'default' => 0x01, + 'size' => 'tiny', + 'description' => 'The exportable status of the entity.', + ), + 'dirty' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'Dirty configurations fail the integrity check, e.g. due to missing dependencies.', + ), + 'module' => array( + 'description' => 'The name of the providing module if the entity has been defined in code.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => FALSE, + ), + 'owner' => array( + 'description' => 'The name of the module via which the rule has been configured.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => 'rules', + ), + 'access_exposed' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'Whether to use a permission to control access for using components.', + ), + 'data' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'Everything else, serialized.', + ), + ), + 'primary key' => array('id'), + 'unique keys' => array( + 'name' => array('name'), + ), + 'indexes' => array( + 'plugin' => array('plugin', 'active'), + ), + ); + $schema['rules_trigger'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'event' => array( + 'type' => 'varchar', + 'length' => '127', + 'not null' => TRUE, + 'default' => '', + 'description' => 'The name of the event on which the configuration should be triggered.', + ), + ), + 'primary key' => array('id', 'event'), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + $schema['rules_tags'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'tag' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The tag string associated with this configuration', + ), + ), + 'primary key' => array('id', 'tag'), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + $schema['rules_dependencies'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'module' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The name of the module that is required for the configuration.', + ), + ), + 'primary key' => array('id', 'module'), + 'indexes' => array( + 'module' => array('module'), + ), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + $schema['cache_rules'] = drupal_get_schema_unprocessed('system', 'cache'); + $schema['cache_rules']['description'] = 'Cache table for the rules engine to store configured items.'; + return $schema; +} + +/** + * Upgrade from Rules 6.x-1.x to 7.x. + */ +function rules_update_7200() { + // Create the new db tables first. + $schema['rules_config'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'The internal identifier for any configuration.', + ), + 'name' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The name of the configuration.', + ), + 'label' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The label of the configuration.', + 'default' => 'unlabeled', + ), + 'plugin' => array( + 'type' => 'varchar', + 'length' => 127, + 'not null' => TRUE, + 'description' => 'The name of the plugin of this configuration.', + ), + 'active' => array( + 'description' => 'Boolean indicating whether the configuration is active. Usage depends on how the using module makes use of it.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 1, + ), + 'weight' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'Weight of the configuration. Usage depends on how the using module makes use of it.', + ), + 'status' => array( + 'type' => 'int', + 'not null' => TRUE, + // Set the default to ENTITY_CUSTOM without using the constant as it is + // not safe to use it at this point. + 'default' => 0x01, + 'size' => 'tiny', + 'description' => 'The exportable status of the entity.', + ), + 'module' => array( + 'description' => 'The name of the providing module if the entity has been defined in code.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => FALSE, + ), + 'data' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'Everything else, serialized.', + ), + ), + 'primary key' => array('id'), + 'unique keys' => array( + 'name' => array('name'), + ), + ); + $schema['rules_trigger'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'event' => array( + 'type' => 'varchar', + 'length' => '127', + 'not null' => TRUE, + 'default' => '', + 'description' => 'The name of the event on which the configuration should be triggered.', + ), + ), + 'primary key' => array('id', 'event'), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + db_create_table('rules_config', $schema['rules_config']); + db_create_table('rules_trigger', $schema['rules_trigger']); + // The cache table already exists, but changed. So re-create it. + db_drop_table('cache_rules'); + $schema['cache_rules'] = drupal_get_schema_unprocessed('system', 'cache'); + $schema['cache_rules']['description'] = 'Cache table for the rules engine to store configured items.'; + db_create_table('cache_rules', $schema['cache_rules']); + // Remove deprecated variables. + variable_del('rules_inactive_sets'); + variable_del('rules_show_fixed'); + variable_del('rules_hide_token_message'); + variable_del('rules_counter'); + + return t('The database tables for Rules 2.x have been created. The old tables from Rules 1.x are still available and contain your rules, which are not updated yet.'); +} + +/** + * Add in the exportable entity db columns as required by the entity API. + */ +function rules_update_7201() { + // Previously this was update 7200, so check whether we need to run it really. + // The update has been moved as 7200 needs to be the 6.x-7.x upgrade. + if (!db_field_exists('rules_config', 'status')) { + db_add_field('rules_config', 'status', array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => ENTITY_CUSTOM, + 'size' => 'tiny', + 'description' => 'The exportable status of the entity.', + )); + // The module column did already exist before. + } +} + +/** + * Add an index for the rules configuration plugin column. + */ +function rules_update_7202() { + db_add_index('rules_config', 'plugin', array('plugin')); +} + +/** + * Fix the length of the rules_config.name column. + */ +function rules_update_7203() { + db_drop_unique_key('rules_config', 'name'); + $keys = array( + 'unique keys' => array( + 'name' => array('name'), + ), + ); + db_change_field('rules_config', 'name', 'name', array( + 'type' => 'varchar', + 'length' => '64', + 'not null' => TRUE, + 'description' => 'The name of the configuration.', + ), $keys); +} + +/** + * Add a table for rules-config tags. + */ +function rules_update_7204() { + if (!db_table_exists('rules_tags')) { + $schema['rules_tags'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'tag' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The tag string associated with this configuration', + ), + ), + 'primary key' => array('id', 'tag'), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + db_create_table('rules_tags', $schema['rules_tags']); + } +} + +/** + * Add the rules_dependencies table and the rules_config.dirty column. + */ +function rules_update_7205() { + if (!db_table_exists('rules_dependencies')) { + $schema['rules_dependencies'] = array( + 'fields' => array( + 'id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The primary identifier of the configuration.', + ), + 'module' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => TRUE, + 'description' => 'The name of the module that is required for the configuration.', + ), + ), + 'primary key' => array('id', 'module'), + 'indexes' => array( + 'module' => array('module'), + ), + 'foreign keys' => array( + 'table' => 'rules_config', + 'columns' => array('id' => 'id'), + ), + ); + db_create_table('rules_dependencies', $schema['rules_dependencies']); + } + if (!db_field_exists('rules_config', 'dirty')) { + db_add_field('rules_config', 'dirty', array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + )); + } +} + +/** + * Flush all caches. + */ +function rules_update_7206() { + // The update system is going to flush all caches anyway, so nothing to do. +} + +/** + * Flush all caches. + */ +function rules_update_7207() { + // The update system is going to flush all caches anyway, so nothing to do. +} + +/** + * Flush all caches to update the data_is_empty condition info. + */ +function rules_update_7208() { + // The update system is going to flush all caches anyway, so nothing to do. +} + +/** + * Creates a flag that enables a permission for using components. + */ +function rules_update_7209() { + // Create a access exposed flag column. + db_add_field('rules_config', 'access_exposed', array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'Whether to use a permission to control access for using components.', + )); +} + +/** + * Deletes the unused rules_empty_sets variable. + */ +function rules_update_7210() { + variable_del('rules_empty_sets'); +} + +/** + * Creates the "owner" column. + */ +function rules_update_7211() { + // Create a owner column. + if (!db_field_exists('rules_config', 'owner')) { + db_add_field('rules_config', 'owner', array( + 'description' => 'The name of the module via which the rule has been configured.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => 'rules', + )); + } +} + +/** + * Make sure registry gets rebuilt to avoid upgrade troubles. + */ +function rules_update_7212() { + // Make sure module information gets refreshed and registry is rebuilt. + drupal_static_reset('system_rebuild_module_data'); + registry_rebuild(); +} + +/** + * Recover the "owner" property for broken configurations. + */ +function rules_update_7213() { + $rows = db_select('rules_config', 'c') + ->fields('c') + ->condition('status', ENTITY_OVERRIDDEN) + ->condition('owner', 'rules', '<>') + ->execute() + ->fetchAllAssoc('id'); + + foreach ($rows as $id => $row) { + if ($row->module == $row->owner) { + db_update('rules_config') + ->condition('id', $id) + ->fields(array('owner' => 'rules')) + ->execute(); + } + } +} + +/** + * Switch out the rules_event_whitelist variable for a cache equivalent. + */ +function rules_update_7214() { + // Enable Rules if currently disabled so that this update won't fail. + $disable_rules = FALSE; + if (!module_exists('rules')) { + module_enable(array('rules')); + $disable_rules = TRUE; + } + // Set new event_whitelist cache cid. + rules_set_cache('rules_event_whitelist', variable_get('rules_event_whitelist', array())); + // Delete old conf variable. + variable_del('rules_event_whitelist'); + // Avoid any missing class errors. + registry_rebuild(); + // Clear and rebuild Rules caches. + // See: rules_admin_settings_cache_rebuild_submit. + rules_clear_cache(); + rules_get_cache(); + _rules_rebuild_component_cache(); + RulesEventSet::rebuildEventCache(); + // Disable Rules again if it was disabled before this update started. + if ($disable_rules) { + module_disable(array('rules')); + } +} + +/** + * Add an index for retrieving active config of a certain plugin. + */ +function rules_update_7215() { + if (db_index_exists('rules_config', 'plugin')) { + db_drop_index('rules_config', 'plugin'); + } + db_add_index('rules_config', 'plugin', array('plugin', 'active')); +} diff --git a/rules.module b/rules.module new file mode 100644 index 0000000..d74347a --- /dev/null +++ b/rules.module @@ -0,0 +1,1774 @@ + $group), $implementations); + } +} + +/** + * Implements hook_menu_get_item_alter(). + */ +function rules_menu_get_item_alter() { + // Make sure that event invocation is enabled before menu items are loaded. + // But make sure later calls to menu_get_item() won't automatically re-enabled + // the rules invocation. + // Example: modules that implement hook_entity_ENTITY_TYPE_load() might want + // to invoke Rules events in that load hook, which is also invoked for menu + // item loading. Since this can happen even before hook_init() we need to make + // sure that firing Rules events is enabled at that point. A typical use case + // for this is Drupal Commerce with commerce_cart_commerce_order_load(). + if (!drupal_static('rules_init', FALSE)) { + rules_event_invocation_enabled(TRUE); + } +} + +/** + * Implements hook_init(). + */ +function rules_init() { + // See rules_menu_get_item_alter(). + $rules_init = &drupal_static(__FUNCTION__, FALSE); + $rules_init = TRUE; + // Enable event invocation once hook_init() was invoked for Rules. + rules_event_invocation_enabled(TRUE); + rules_invoke_event('init'); +} + +/** + * Returns an instance of the rules UI controller. + * + * This function is for convenience, to ease re-using the Rules UI. + * See the rules_admin.module for example usage. + * + * @return RulesUIController + */ +function rules_ui() { + $static = drupal_static(__FUNCTION__); + if (!isset($static)) { + $static = new RulesUIController(); + } + return $static; +} + +/** + * Returns a new rules action. + * + * @param $name + * The action's name. + * @param array $settings + * The action's settings array. + * + * @return RulesAction + */ +function rules_action($name, $settings = array()) { + return rules_plugin_factory('action', $name, $settings); +} + +/** + * Returns a new rules condition. + * + * @param $name + * The condition's name. + * @param array $settings + * The condition's settings array. + * + * @return RulesCondition + */ +function rules_condition($name, $settings = array()) { + return rules_plugin_factory('condition', $name, $settings); +} + +/** + * Creates a new rule. + * + * @param array $variables + * The array of variables to setup in the evaluation state, making them + * available for the configuration elements. Values for the variables need to + * be passed as argument when the rule is executed. Only Rule instances with + * no variables can be embedded in other configurations, e.g. rule sets. + * The array has to be keyed by variable name and contain a sub-array for each + * variable that has the same structure as the arrays used for describing + * parameters of an action, see hook_rules_action_info(). However, in addition + * to that the following keys are supported: + * - parameter: (optional) If set to FALSE, no parameter for the variable + * is created - thus no argument needs to be passed to the rule for the + * variable upon execution. As a consequence no value will be set + * initially, but the "Set data value" action may be used to do so. This is + * in particular useful for defining variables which can be provided to the + * caller (see $provides argument) but need not be passed in as parameter. + * @param array $provides + * The names of variables which should be provided to the caller. Only + * variables contained in $variables may be specified. + * + * @return Rule + */ +function rule($variables = NULL, $provides = array()) { + return rules_plugin_factory('rule', $variables, $provides); +} + +/** + * Creates a new reaction rule. + * + * @return RulesReactionRule + */ +function rules_reaction_rule() { + return rules_plugin_factory('reaction rule'); +} + +/** + * Creates a logical OR condition container. + * + * @param array $variables + * An optional array as for rule(). + * + * @return RulesOr + */ +function rules_or($variables = NULL) { + return rules_plugin_factory('or', $variables); +} + +/** + * Creates a logical AND condition container. + * + * @param array $variables + * An optional array as for rule(). + * + * @return RulesAnd + */ +function rules_and($variables = NULL) { + return rules_plugin_factory('and', $variables); +} + +/** + * Creates a loop. + * + * @param array $settings + * The loop settings, containing + * 'list:select': The data selector for the list to loop over. + * 'item:var': Optionally a name for the list item variable. + * 'item:label': Optionally a label for the list item variable. + * @param array $variables + * An optional array as for rule(). + * + * @return RulesLoop + */ +function rules_loop($settings = array(), $variables = NULL) { + return rules_plugin_factory('loop', $settings, $variables); +} + +/** + * Creates a rule set. + * + * @param array $variables + * An array as for rule(). + * @param array $provides + * The names of variables which should be provided to the caller. See rule(). + * + * @return RulesRuleSet + */ +function rules_rule_set($variables = array(), $provides = array()) { + return rules_plugin_factory('rule set', $variables, $provides); +} + +/** + * Creates an action set. + * + * @param array $variables + * An array as for rule(). + * @param array $provides + * The names of variables which should be provided to the caller. See rule(). + * + * @return RulesActionSet + */ +function rules_action_set($variables = array(), $provides = array()) { + return rules_plugin_factory('action set', $variables, $provides); +} + +/** + * Log a message to the rules logger. + * + * @param $msg + * The message to log. + * @param array $args + * An array of placeholder arguments as used by t(). + * @param $priority + * A priority as defined by the RulesLog class. + * @param RulesPlugin $element + * (optional) The RulesElement causing the log entry. + * @param bool $scope + * (optional) This may be used to denote the beginning (TRUE) or the end + * (FALSE) of a new execution scope. + */ +function rules_log($msg, $args = array(), $priority = RulesLog::INFO, RulesPlugin $element = NULL, $scope = NULL) { + static $logger, $settings; + + // Statically cache the variable settings as this is called very often. + if (!isset($settings)) { + $settings['rules_log_errors'] = variable_get('rules_log_errors', RulesLog::WARN); + $settings['rules_debug_log'] = variable_get('rules_debug_log', FALSE); + $settings['rules_debug'] = variable_get('rules_debug', 0); + } + + if ($priority >= $settings['rules_log_errors']) { + $link = NULL; + if (isset($element) && isset($element->root()->name)) { + $link = l(t('edit configuration'), RulesPluginUI::path($element->root()->name, 'edit', $element)); + } + // Disabled rules invocation to avoid an endless loop when using + // watchdog - which would trigger a rules event. + rules_event_invocation_enabled(FALSE); + watchdog('rules', $msg, $args, $priority == RulesLog::WARN ? WATCHDOG_WARNING : WATCHDOG_ERROR, $link); + rules_event_invocation_enabled(TRUE); + } + // Do nothing in case debugging is totally disabled. + if (!$settings['rules_debug_log'] && !$settings['rules_debug']) { + return; + } + if (!isset($logger)) { + $logger = RulesLog::logger(); + } + $path = isset($element) && isset($element->root()->name) ? RulesPluginUI::path($element->root()->name, 'edit', $element) : NULL; + $logger->log($msg, $args, $priority, $scope, $path); +} + +/** + * Fetches module definitions for the given hook name. + * + * Used for collecting events, rules, actions and condition from other modules. + * + * @param $hook + * The hook of the definitions to get from invoking hook_rules_{$hook}. + */ +function rules_fetch_data($hook) { + $data = &drupal_static(__FUNCTION__, array()); + static $discover = array( + 'action_info' => 'RulesActionHandlerInterface', + 'condition_info' => 'RulesConditionHandlerInterface', + 'event_info' => 'RulesEventHandlerInterface', + ); + + if (!isset($data[$hook])) { + $data[$hook] = array(); + foreach (module_implements('rules_' . $hook) as $module) { + $result = call_user_func($module . '_rules_' . $hook); + if (isset($result) && is_array($result)) { + foreach ($result as $name => $item) { + $item += array('module' => $module); + $data[$hook][$name] = $item; + } + } + } + // Support class discovery. + if (isset($discover[$hook])) { + $data[$hook] += rules_discover_plugins($discover[$hook]); + } + drupal_alter('rules_' . $hook, $data[$hook]); + } + return $data[$hook]; +} + +/** + * Discover plugin implementations. + * + * Class based plugin handlers must be loaded when rules caches are rebuilt, + * such that they get discovered properly. You have the following options: + * - Put it into a regular module file (discouraged) + * - Put it into your module.rules.inc file + * - Put it in any file and declare it using hook_rules_file_info() + * - Put it in any file and declare it using hook_rules_directory() + * + * In addition to that, the class must be loadable via regular class + * auto-loading, thus put the file holding the class in your info file or use + * another class-loader. + * + * @param string $class + * The class or interface the plugins must implement. For a plugin to be + * discovered it must have a static getInfo() method also. + * + * @return array + * An info-hook style array containing info about discovered plugins. + * + * @see RulesActionHandlerInterface + * @see RulesConditionHandlerInterface + * @see RulesEventHandlerInterface + */ +function rules_discover_plugins($class) { + // Make sure all files possibly holding plugins are included. + RulesAbstractPlugin::includeFiles(); + + $items = array(); + foreach (get_declared_classes() as $plugin_class) { + if (is_subclass_of($plugin_class, $class) && method_exists($plugin_class, 'getInfo')) { + $info = call_user_func(array($plugin_class, 'getInfo')); + $info['class'] = $plugin_class; + $info['module'] = _rules_discover_module($plugin_class); + $items[$info['name']] = $info; + } + } + return $items; +} + +/** + * Determines the module providing the given class. + * + * @param string $class + * The name of the class or interface plugins to discover. + * + * @return string|false + * The path of the class, relative to the Drupal installation root, + * or FALSE if not discovered. + */ +function _rules_discover_module($class) { + $paths = &drupal_static(__FUNCTION__); + + if (!isset($paths)) { + // Build up a map of modules keyed by their directory. + foreach (system_list('module_enabled') as $name => $module_info) { + $paths[dirname($module_info->filename)] = $name; + } + } + + // Retrieve the class file and convert its absolute path to a regular Drupal + // path relative to the installation root. + $reflection = new ReflectionClass($class); + $path = str_replace(realpath(DRUPAL_ROOT) . DIRECTORY_SEPARATOR, '', realpath(dirname($reflection->getFileName()))); + $path = DIRECTORY_SEPARATOR != '/' ? str_replace(DIRECTORY_SEPARATOR, '/', $path) : $path; + + // Go up the path until we match a module. + $parts = explode('/', $path); + while (!isset($paths[$path]) && array_pop($parts)) { + $path = dirname($path); + } + return isset($paths[$path]) ? $paths[$path] : FALSE; +} + +/** + * Gets a rules cache entry. + */ +function &rules_get_cache($cid = 'data') { + // Make use of the fast, advanced drupal static pattern. + static $drupal_static_fast; + if (!isset($drupal_static_fast)) { + $drupal_static_fast['cache'] = &drupal_static(__FUNCTION__, array()); + } + $cache = &$drupal_static_fast['cache']; + + if (!isset($cache[$cid])) { + // The main 'data' cache includes translated strings, so each language is + // cached separately. + $cid_suffix = $cid == 'data' ? ':' . $GLOBALS['language']->language : ''; + + if ($get = cache_get($cid . $cid_suffix, 'cache_rules')) { + $cache[$cid] = $get->data; + } + else { + // Prevent stampeding by ensuring the cache is rebuilt just once at the + // same time. + while (!lock_acquire(__FUNCTION__ . $cid . $cid_suffix, 60)) { + // Now wait until the lock is released. + lock_wait(__FUNCTION__ . $cid . $cid_suffix, 30); + // If the lock is released it's likely the cache was rebuild. Thus check + // again if we can fetch it from the persistent cache. + if ($get = cache_get($cid . $cid_suffix, 'cache_rules')) { + $cache[$cid] = $get->data; + return $cache[$cid]; + } + } + if ($cid === 'data') { + // There is no 'data' cache so we need to rebuild it. Make sure + // subsequent cache gets of the main 'data' cache during rebuild get + // the interim cache by passing in the reference of the static cache + // variable. + _rules_rebuild_cache($cache['data']); + } + elseif (strpos($cid, 'comp_') === 0) { + $cache[$cid] = FALSE; + _rules_rebuild_component_cache(); + } + elseif (strpos($cid, 'event_') === 0 || $cid == 'rules_event_whitelist') { + $cache[$cid] = FALSE; + RulesEventSet::rebuildEventCache(); + } + else { + $cache[$cid] = FALSE; + } + // Ensure a set lock is released. + lock_release(__FUNCTION__ . $cid . $cid_suffix); + } + } + return $cache[$cid]; +} + +/** + * Rebuilds the rules cache. + * + * This rebuilds the rules 'data' cache and invokes rebuildCache() methods on + * all plugin classes, which allows plugins to add their own data to the cache. + * The cache is rebuilt in the order the plugins are defined. + * + * Note that building the action/condition info cache triggers loading of all + * components, thus depends on entity-loading and so syncing entities in code + * to the database. + * + * @see rules_rules_plugin_info() + * @see entity_defaults_rebuild() + */ +function _rules_rebuild_cache(&$cache) { + foreach (array('data_info', 'plugin_info') as $hook) { + $cache[$hook] = rules_fetch_data($hook); + } + foreach ($cache['plugin_info'] as $name => &$info) { + // Let the items add something to the cache. + $item = new $info['class'](); + $item->rebuildCache($info, $cache); + } + $cid_suffix = ':' . $GLOBALS['language']->language; + cache_set('data' . $cid_suffix, $cache, 'cache_rules'); +} + +/** + * Cache components to allow efficient usage via rules_invoke_component(). + * + * @see rules_invoke_component() + * @see rules_get_cache() + */ +function _rules_rebuild_component_cache() { + $components = rules_get_components(); + + foreach ($components as $id => $component) { + // If a component is marked as dirty, check if this still applies. + if ($component->dirty) { + rules_config_update_dirty_flag($component); + } + if (!$component->dirty) { + // Clone the component to avoid modules getting the to be cached + // version from the static loading cache. + $component = clone $component; + $component->optimize(); + // Allow modules to alter the cached component. + drupal_alter('rules_component', $component->plugin, $component); + rules_set_cache('comp_' . $component->name, $component); + } + } +} + +/** + * Sets a rules cache item. + * + * In addition to calling cache_set(), this function makes sure the cache item + * is immediately available via rules_get_cache() by keeping all cache items + * in memory. That way we can guarantee rules_get_cache() is able to retrieve + * any cache item, even if all cache gets fail. + * + * @see rules_get_cache() + */ +function rules_set_cache($cid, $data) { + $cache = &drupal_static('rules_get_cache', array()); + $cache[$cid] = $data; + cache_set($cid, $data, 'cache_rules'); +} + +/** + * Implements hook_flush_caches(). + */ +function rules_flush_caches() { + return array('cache_rules'); +} + +/** + * Clears the rule set cache. + */ +function rules_clear_cache() { + cache_clear_all('*', 'cache_rules', TRUE); + drupal_static_reset('rules_get_cache'); + drupal_static_reset('rules_fetch_data'); + drupal_static_reset('rules_config_update_dirty_flag'); + entity_get_controller('rules_config')->resetCache(); +} + +/** + * Imports the given export and returns the imported configuration. + * + * @param string $export + * A serialized string in JSON format as produced by the RulesPlugin::export() + * method, or the PHP export as usual PHP array. + * @param string $error_msg + * + * @return RulesPlugin + */ +function rules_import($export, &$error_msg = '') { + return entity_get_controller('rules_config')->import($export, $error_msg); +} + +/** + * Wraps the given data. + * + * @param $data + * If available, the actual data, else NULL. + * @param $info + * An array of info about this data. + * @param bool $force + * Usually data is only wrapped if really needed. If set to TRUE, wrapping the + * data is forced, so primitive data types are also wrapped. + * + * @return EntityMetadataWrapper + * An EntityMetadataWrapper or the unwrapped data. + * + * @see hook_rules_data_info() + */ +function &rules_wrap_data($data = NULL, $info, $force = FALSE) { + // If the data is already wrapped, use the existing wrapper. + if ($data instanceof EntityMetadataWrapper) { + return $data; + } + $cache = rules_get_cache(); + // Define the keys to be passed through to the metadata wrapper. + $wrapper_keys = array_flip(array('property info', 'property defaults')); + if (isset($cache['data_info'][$info['type']])) { + $info += array_intersect_key($cache['data_info'][$info['type']], $wrapper_keys); + } + // If a list is given, also add in the info of the item type. + $list_item_type = entity_property_list_extract_type($info['type']); + if ($list_item_type && isset($cache['data_info'][$list_item_type])) { + $info += array_intersect_key($cache['data_info'][$list_item_type], $wrapper_keys); + } + // By default we do not wrap the data, except for completely unknown types. + if (!empty($cache['data_info'][$info['type']]['wrap']) || $list_item_type || $force || empty($cache['data_info'][$info['type']])) { + unset($info['handler']); + // Allow data types to define custom wrapper classes. + if (!empty($cache['data_info'][$info['type']]['wrapper class'])) { + $class = $cache['data_info'][$info['type']]['wrapper class']; + $wrapper = new $class($info['type'], $data, $info); + } + else { + $wrapper = entity_metadata_wrapper($info['type'], $data, $info); + } + return $wrapper; + } + return $data; +} + +/** + * Unwraps the given data, if it's wrapped. + * + * @param array $data + * An array of wrapped data. + * @param array $info + * Optionally an array of info about how to unwrap the data. Keyed as $data. + * + * @return array + * An array containing unwrapped or passed through data. + */ +function rules_unwrap_data(array $data, $info = array()) { + $cache = rules_get_cache(); + foreach ($data as $key => $entry) { + // If it's a wrapper, unwrap unless specified otherwise. + if ($entry instanceof EntityMetadataWrapper) { + if (!isset($info[$key]['allow null'])) { + $info[$key]['allow null'] = FALSE; + } + if (!isset($info[$key]['wrapped'])) { + // By default, do not unwrap special data types that are always wrapped. + $info[$key]['wrapped'] = (isset($info[$key]['type']) && is_string($info[$key]['type']) && !empty($cache['data_info'][$info[$key]['type']]['is wrapped'])); + } + // Activate the decode option by default if 'sanitize' is not enabled, so + // any text is either sanitized or decoded. + // @see EntityMetadataWrapper::value() + $options = $info[$key] + array('decode' => empty($info[$key]['sanitize'])); + + try { + if (!($info[$key]['allow null'] && $info[$key]['wrapped'])) { + $value = $entry->value($options); + + if (!$info[$key]['wrapped']) { + $data[$key] = $value; + } + if (!$info[$key]['allow null'] && !isset($value)) { + throw new RulesEvaluationException('The variable or parameter %name is empty.', array('%name' => $key)); + } + } + } + catch (EntityMetadataWrapperException $e) { + throw new RulesEvaluationException('Unable to get the data value for the variable or parameter %name. Error: !error', array('%name' => $key, '!error' => $e->getMessage())); + } + } + } + return $data; +} + +/** + * Gets event info for a given event. + * + * @param string $event_name + * A (configured) event name. + * + * @return array + * An array of event info. If the event is unknown, a suiting info array is + * generated and returned + */ +function rules_get_event_info($event_name) { + $base_event_name = rules_get_event_base_name($event_name); + $events = rules_fetch_data('event_info'); + if (isset($events[$base_event_name])) { + return $events[$base_event_name] + array('name' => $base_event_name); + } + return array( + 'label' => t('Unknown event "!event_name"', array('!event_name' => $base_event_name)), + 'name' => $base_event_name, + ); +} + +/** + * Returns the base name of a configured event name. + * + * For a configured event name like node_view--article the base event name + * node_view is returned. + * + * @param string $event_name + * A (configured) event name. + * + * @return string + * The event base name. + */ +function rules_get_event_base_name($event_name) { + // Cut off any suffix from a configured event name. + if (strpos($event_name, '--') !== FALSE) { + $parts = explode('--', $event_name, 2); + return $parts[0]; + } + return $event_name; +} + +/** + * Returns the rule event handler for the given event. + * + * Events having no settings are handled via the class RulesEventSettingsNone. + * + * @param string $event_name + * The event name (base or configured). + * @param array $settings + * (optional) An array of event settings to set on the handler. + * + * @return RulesEventHandlerInterface + * The event handler. + */ +function rules_get_event_handler($event_name, array $settings = NULL) { + $event_name = rules_get_event_base_name($event_name); + $event_info = rules_get_event_info($event_name); + $class = !empty($event_info['class']) ? $event_info['class'] : 'RulesEventDefaultHandler'; + $handler = new $class($event_name, $event_info); + return isset($settings) ? $handler->setSettings($settings) : $handler; +} + +/** + * Creates a new instance of a the given rules plugin. + * + * @return RulesPlugin + */ +function rules_plugin_factory($plugin_name, $arg1 = NULL, $arg2 = NULL) { + $cache = rules_get_cache(); + if (isset($cache['plugin_info'][$plugin_name]['class'])) { + return new $cache['plugin_info'][$plugin_name]['class']($arg1, $arg2); + } +} + +/** + * Implements hook_rules_plugin_info(). + * + * Note that the cache is rebuilt in the order of the plugins. Therefore the + * condition and action plugins must be at the top, so that any components + * re-building their cache can create configurations including properly setup-ed + * actions and conditions. + */ +function rules_rules_plugin_info() { + return array( + 'condition' => array( + 'class' => 'RulesCondition', + 'embeddable' => 'RulesConditionContainer', + 'extenders' => array( + 'RulesPluginImplInterface' => array( + 'class' => 'RulesAbstractPluginDefaults', + ), + 'RulesPluginFeaturesIntegrationInterface' => array( + 'methods' => array( + 'features_export' => 'rules_features_abstract_default_features_export', + ), + ), + 'RulesPluginUIInterface' => array( + 'class' => 'RulesAbstractPluginUI', + ), + ), + ), + 'action' => array( + 'class' => 'RulesAction', + 'embeddable' => 'RulesActionContainer', + 'extenders' => array( + 'RulesPluginImplInterface' => array( + 'class' => 'RulesAbstractPluginDefaults', + ), + 'RulesPluginFeaturesIntegrationInterface' => array( + 'methods' => array( + 'features_export' => 'rules_features_abstract_default_features_export', + ), + ), + 'RulesPluginUIInterface' => array( + 'class' => 'RulesAbstractPluginUI', + ), + ), + ), + 'or' => array( + 'label' => t('Condition set (OR)'), + 'class' => 'RulesOr', + 'embeddable' => 'RulesConditionContainer', + 'component' => TRUE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesConditionContainerUI', + ), + ), + ), + 'and' => array( + 'label' => t('Condition set (AND)'), + 'class' => 'RulesAnd', + 'embeddable' => 'RulesConditionContainer', + 'component' => TRUE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesConditionContainerUI', + ), + ), + ), + 'action set' => array( + 'label' => t('Action set'), + 'class' => 'RulesActionSet', + 'embeddable' => FALSE, + 'component' => TRUE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesActionContainerUI', + ), + ), + ), + 'rule' => array( + 'label' => t('Rule'), + 'class' => 'Rule', + 'embeddable' => 'RulesRuleSet', + 'component' => TRUE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesRuleUI', + ), + ), + ), + 'loop' => array( + 'class' => 'RulesLoop', + 'embeddable' => 'RulesActionContainer', + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesLoopUI', + ), + ), + ), + 'reaction rule' => array( + 'class' => 'RulesReactionRule', + 'embeddable' => FALSE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesReactionRuleUI', + ), + ), + ), + 'event set' => array( + 'class' => 'RulesEventSet', + 'embeddable' => FALSE, + ), + 'rule set' => array( + 'label' => t('Rule set'), + 'class' => 'RulesRuleSet', + 'component' => TRUE, + // Rule sets don't get embedded - we use a separate action to execute. + 'embeddable' => FALSE, + 'extenders' => array( + 'RulesPluginUIInterface' => array( + 'class' => 'RulesRuleSetUI', + ), + ), + ), + ); +} + +/** + * Implements hook_entity_info(). + */ +function rules_entity_info() { + return array( + 'rules_config' => array( + 'label' => t('Rules configuration'), + 'controller class' => 'RulesEntityController', + 'base table' => 'rules_config', + 'fieldable' => TRUE, + 'entity keys' => array( + 'id' => 'id', + 'name' => 'name', + 'label' => 'label', + ), + 'module' => 'rules', + 'static cache' => TRUE, + 'bundles' => array(), + 'configuration' => TRUE, + 'exportable' => TRUE, + 'export' => array( + 'default hook' => 'default_rules_configuration', + ), + 'access callback' => 'rules_config_access', + 'features controller class' => 'RulesFeaturesController', + ), + ); +} + +/** + * Implements hook_hook_info(). + */ +function rules_hook_info() { + foreach (array('plugin_info', 'rules_directory', 'data_info', 'condition_info', 'action_info', 'event_info', 'file_info', 'evaluator_info', 'data_processor_info') as $hook) { + $hooks['rules_' . $hook] = array( + 'group' => 'rules', + ); + $hooks['rules_' . $hook . '_alter'] = array( + 'group' => 'rules', + ); + } + $hooks['default_rules_configuration'] = array( + 'group' => 'rules_defaults', + ); + $hooks['default_rules_configuration_alter'] = array( + 'group' => 'rules_defaults', + ); + return $hooks; +} + +/** + * Load rule configurations from the database. + * + * This function should be used whenever you need to load more than one entity + * from the database. The entities are loaded into memory and will not require + * database access if loaded again during the same page request. + * + * @param array|false $names + * An array of rules configuration names or FALSE to load all. + * @param array $conditions + * An array of conditions in the form 'field' => $value. + * + * @return array + * An array of rule configurations indexed by their ids. + * + * @see hook_entity_info() + * @see RulesEntityController + */ +function rules_config_load_multiple($names = array(), $conditions = array()) { + return entity_load_multiple_by_name('rules_config', $names, $conditions); +} + +/** + * Loads a single rule configuration from the database. + * + * @see rules_config_load_multiple() + * + * @return RulesPlugin + */ +function rules_config_load($name) { + return entity_load_single('rules_config', $name); +} + +/** + * Returns an array of configured components. + * + * For actually executing a component use rules_invoke_component(), as this + * retrieves the component from cache instead. + * + * @param $label + * Whether to return only the label or the whole component object. + * @param $type + * Optionally filter for 'action' or 'condition' components. + * @param array $conditions + * An array of additional conditions as required by rules_config_load(). + * + * @return array + * An array keyed by component name containing either the label or the config. + */ +function rules_get_components($label = FALSE, $type = NULL, $conditions = array()) { + $cache = rules_get_cache(); + $plugins = array_keys(rules_filter_array($cache['plugin_info'], 'component', TRUE)); + $conditions = $conditions + array('plugin' => $plugins); + $faces = array( + 'action' => 'RulesActionInterface', + 'condition' => 'RulesConditionInterface', + ); + $items = array(); + foreach (rules_config_load_multiple(FALSE, $conditions) as $name => $config) { + if (!isset($type) || $config instanceof $faces[$type]) { + $items[$name] = $label ? $config->label() : $config; + } + } + return $items; +} + +/** + * Delete rule configurations from database. + * + * @param array $ids + * An array of entity IDs. + */ +function rules_config_delete(array $ids) { + return entity_get_controller('rules_config')->delete($ids); +} + +/** + * Ensures the configuration's 'dirty' flag is up to date by running an integrity check. + * + * @param bool $update + * (optional) Whether the dirty flag is also updated in the database if + * necessary. Defaults to TRUE. + */ +function rules_config_update_dirty_flag($rules_config, $update = TRUE) { + // Keep a log of already check configurations to avoid repetitive checks on + // often used components. + // @see rules_element_invoke_component_validate() + $checked = &drupal_static(__FUNCTION__, array()); + if (!empty($checked[$rules_config->name])) { + return; + } + $checked[$rules_config->name] = TRUE; + + $was_dirty = !empty($rules_config->dirty); + try { + // First set the rule to dirty, so any repetitive checks give green light + // for this configuration. + $rules_config->dirty = FALSE; + $rules_config->integrityCheck(); + if ($was_dirty) { + $variables = array( + '%label' => $rules_config->label(), + '%name' => $rules_config->name, + '@plugin' => $rules_config->plugin(), + ); + watchdog('rules', 'The @plugin %label (%name) was marked dirty, but passes the integrity check now and is active again.', $variables, WATCHDOG_INFO); + } + } + catch (RulesIntegrityException $e) { + $rules_config->dirty = TRUE; + if (!$was_dirty) { + $variables = array( + '%label' => $rules_config->label(), + '%name' => $rules_config->name, + '!message' => $e->getMessage(), + '@plugin' => $rules_config->plugin(), + ); + watchdog('rules', 'The @plugin %label (%name) fails the integrity check and cannot be executed. Error: !message', $variables, WATCHDOG_ERROR); + } + } + // Save the updated dirty flag to the database. + if ($was_dirty != $rules_config->dirty) { + db_update('rules_config') + ->fields(array('dirty' => (int) $rules_config->dirty)) + ->condition('id', $rules_config->id) + ->execute(); + } +} + +/** + * Invokes a hook and the associated rules event. + * + * Calling this function does the same as calling module_invoke_all() and + * rules_invoke_event() separately, however merges both functions into one in + * order to ease usage and to work efficiently. + * + * @param $hook + * The name of the hook / event to invoke. + * @param ... + * Arguments to pass to the hook / event. + * + * @return array + * An array of return values of the hook implementations. If modules return + * arrays from their implementations, those are merged into one array. + */ +function rules_invoke_all() { + // Copied code from module_invoke_all(). + $args = func_get_args(); + $hook = $args[0]; + unset($args[0]); + $return = array(); + foreach (module_implements($hook) as $module) { + $function = $module . '_' . $hook; + if (function_exists($function)) { + $result = call_user_func_array($function, $args); + if (isset($result) && is_array($result)) { + $return = array_merge_recursive($return, $result); + } + elseif (isset($result)) { + $return[] = $result; + } + } + } + // Invoke the event. + rules_invoke_event_by_args($hook, $args); + + return $return; +} + +/** + * Invokes configured rules for the given event. + * + * @param $event_name + * The event's name. + * @param ... + * Pass parameters for the variables provided by this event, as defined in + * hook_rules_event_info(). Example given: + * @code + * rules_invoke_event('node_view', $node, $view_mode); + * @endcode + * + * @see rules_invoke_event_by_args() + */ +function rules_invoke_event() { + $args = func_get_args(); + $event_name = $args[0]; + unset($args[0]); + // We maintain a whitelist of configured events to reduces the number of cache + // reads. If the whitelist is not in the cache we proceed and it is rebuilt. + if (rules_event_invocation_enabled()) { + $whitelist = rules_get_cache('rules_event_whitelist'); + if ((($whitelist === FALSE) || isset($whitelist[$event_name])) && $event = rules_get_cache('event_' . $event_name)) { + $event->executeByArgs($args); + } + } +} + +/** + * Invokes configured rules for the given event. + * + * @param string $event_name + * The event's name. + * @param array $args + * An array of parameters for the variables provided by the event, as defined + * in hook_rules_event_info(). Either pass an array keyed by the variable + * names or a numerically indexed array, in which case the ordering of the + * passed parameters has to match the order of the specified variables. + * Example given: + * @code + * rules_invoke_event_by_args('node_view', array('node' => $node, 'view_mode' => $view_mode)); + * @endcode + * + * @see rules_invoke_event() + */ +function rules_invoke_event_by_args($event_name, $args = array()) { + // We maintain a whitelist of configured events to reduces the number of cache + // reads. If the whitelist is empty we proceed and it is rebuilt. + if (rules_event_invocation_enabled()) { + $whitelist = rules_get_cache('rules_event_whitelist'); + if ((empty($whitelist) || isset($whitelist[$event_name])) && $event = rules_get_cache('event_' . $event_name)) { + $event->executeByArgs($args); + } + } +} + +/** + * Invokes a rule component, e.g. a rule set. + * + * @param $component_name + * The component's name. + * @param $args + * Pass further parameters as required for the invoked component. + * + * @return array + * An array of variables as provided by the component, or FALSE in case the + * component could not be executed. + */ +function rules_invoke_component() { + $args = func_get_args(); + $name = array_shift($args); + if ($component = rules_get_cache('comp_' . $name)) { + return $component->executeByArgs($args); + } + return FALSE; +} + +/** + * Filters the given array of arrays. + * + * This filter operates by keeping only entries which have $key set to the + * value of $value. + * + * @param array $array + * The array of arrays to filter. + * @param $key + * The key used for the comparison. + * @param $value + * The value to compare the array's entry to. + * + * @return array + * The filtered array. + */ +function rules_filter_array($array, $key, $value) { + $return = array(); + foreach ($array as $i => $entry) { + $entry += array($key => NULL); + if ($entry[$key] == $value) { + $return[$i] = $entry; + } + } + return $return; +} + +/** + * Merges the $update array into $array. + * + * Makes sure no values of $array not appearing in $update are lost. + * + * @return array + * The updated array. + */ +function rules_update_array(array $array, array $update) { + foreach ($update as $key => $data) { + if (isset($array[$key]) && is_array($array[$key]) && is_array($data)) { + $array[$key] = rules_update_array($array[$key], $data); + } + else { + $array[$key] = $data; + } + } + return $array; +} + +/** + * Extracts the property with the given name. + * + * @param array $arrays + * An array of arrays from which a property is to be extracted. + * @param $key + * The name of the property to extract. + * + * @return array + * An array of extracted properties, keyed as in $arrays. + */ +function rules_extract_property($arrays, $key) { + $data = array(); + foreach ($arrays as $name => $item) { + $data[$name] = $item[$key]; + } + return $data; +} + +/** + * Returns the first key of the array. + */ +function rules_array_key($array) { + reset($array); + return key($array); +} + +/** + * Clean replacements so they are URL friendly. + * + * Can be used as 'cleaning callback' for action or condition parameters. + * + * @param $replacements + * An array of token replacements that need to be "cleaned" + * for use in the URL. + * @param array $data + * An array of objects used to generate the replacements. + * @param array $options + * An array of options used to generate the replacements. + * + * @see rules_path_action_info() + */ +function rules_path_clean_replacement_values(&$replacements, $data = array(), $options = array()) { + // Include path.eval.inc which contains path cleaning functions. + module_load_include('inc', 'rules', 'modules/path.eval'); + foreach ($replacements as $token => $value) { + $replacements[$token] = rules_clean_path($value); + } +} + +/** + * Implements hook_theme(). + */ +function rules_theme() { + return array( + 'rules_elements' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_content_group' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_parameter_configuration' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_variable_view' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_data_selector_help' => array( + 'variables' => array('parameter' => NULL, 'variables' => NULL), + 'file' => 'ui/ui.theme.inc', + ), + 'rules_ui_variable_form' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_log' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_autocomplete' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_debug_element' => array( + 'render element' => 'element', + 'file' => 'ui/ui.theme.inc', + ), + 'rules_settings_help' => array( + 'variables' => array('text' => '', 'heading' => ''), + 'file' => 'ui/ui.theme.inc', + ), + ); +} + +/** + * Implements hook_permission(). + */ +function rules_permission() { + $perms = array( + 'administer rules' => array( + 'title' => t('Administer rule configurations'), + 'description' => t('Administer rule configurations including events, conditions and actions for which the user has sufficient access permissions.'), + ), + 'bypass rules access' => array( + 'title' => t('Bypass Rules access control'), + 'description' => t('Control all configurations regardless of permission restrictions of events, conditions or actions.'), + 'restrict access' => TRUE, + ), + 'access rules debug' => array( + 'title' => t('Access the Rules debug log'), + ), + ); + + // Fetch all components to generate the access keys. + $conditions['plugin'] = array_keys(rules_filter_array(rules_fetch_data('plugin_info'), 'component', TRUE)); + $conditions['access_exposed'] = 1; + $components = entity_load('rules_config', FALSE, $conditions); + $perms += rules_permissions_by_component($components); + + return $perms; +} + +/** + * Helper function return permissions for components that have access exposed. + */ +function rules_permissions_by_component(array $components = array()) { + $perms = array(); + foreach ($components as $component) { + $perms += array( + "use Rules component $component->name" => array( + 'title' => t('Use Rules component %component', array('%component' => $component->label())), + 'description' => t('Controls access for using the component %component via the provided action or condition. Edit this component.', array('%component' => $component->label(), '@component-edit-url' => url(RulesPluginUI::path($component->name)))), + ), + ); + } + return $perms; +} + +/** + * Menu callback for loading rules configuration elements. + * + * @see RulesUIController::config_menu() + */ +function rules_element_load($element_id, $config_name) { + $config = rules_config_load($config_name); + return $config->elementMap()->lookup($element_id); +} + +/** + * Menu callback for getting the title as configured. + * + * @see RulesUIController::config_menu() + */ +function rules_get_title($text, $element) { + if ($element instanceof RulesPlugin) { + $cache = rules_get_cache(); + $plugin = $element->plugin(); + $plugin = isset($cache['plugin_info'][$plugin]['label']) ? $cache['plugin_info'][$plugin]['label'] : $plugin; + $plugin = drupal_strtolower(drupal_substr($plugin, 0, 1)) . drupal_substr($plugin, 1); + return t($text, array('!label' => $element->label(), '!plugin' => $plugin)); + } + // As fallback treat $element as simple string. + return t($text, array('!plugin' => $element)); +} + +/** + * Menu callback for getting the title for the add element page. + * + * Uses a work-a-round for accessing the plugin name. + * + * @see RulesUIController::config_menu() + */ +function rules_menu_add_element_title($array) { + $plugin_name = arg($array[0]); + $cache = rules_get_cache(); + if (isset($cache['plugin_info'][$plugin_name]['class'])) { + $info = $cache['plugin_info'][$plugin_name] + array('label' => $plugin_name); + $label = drupal_strtolower(drupal_substr($info['label'], 0, 1)) . drupal_substr($info['label'], 1); + return t('Add a new !plugin', array('!plugin' => $label)); + } +} + +/** + * Returns the current region for the debug log. + */ +function rules_debug_log_region() { + // If there is no setting for the current theme use the default theme setting. + global $theme_key; + $theme_default = variable_get('theme_default', 'bartik'); + return variable_get('rules_debug_region_' . $theme_key, variable_get('rules_debug_region_' . $theme_default, 'help')); +} + +/** + * Implements hook_page_build() to add the rules debug log to the page bottom. + */ +function rules_page_build(&$page) { + // Invoke a the page redirect, in case the action has been executed. + // @see rules_action_drupal_goto() + if (isset($GLOBALS['_rules_action_drupal_goto_do'])) { + list($url, $force) = $GLOBALS['_rules_action_drupal_goto_do']; + drupal_goto($url); + } + + if (isset($_SESSION['rules_debug'])) { + $region = rules_debug_log_region(); + foreach ($_SESSION['rules_debug'] as $log) { + $page[$region]['rules_debug'][] = array( + '#markup' => $log, + ); + $page[$region]['rules_debug']['#theme_wrappers'] = array('rules_log'); + } + unset($_SESSION['rules_debug']); + } + + if (rules_show_debug_output()) { + $region = rules_debug_log_region(); + $page[$region]['rules_debug']['#pre_render'] = array('rules_debug_log_pre_render'); + } +} + +/** + * Pre-render callback for the debug log, which renders and then clears it. + */ +function rules_debug_log_pre_render($elements) { + $logger = RulesLog::logger(); + if ($log = $logger->render()) { + $logger = RulesLog::logger(); + $logger->clear(); + $elements[] = array('#markup' => $log); + $elements['#theme_wrappers'] = array('rules_log'); + // Log the rules log to the system log if enabled. + if (variable_get('rules_debug_log', FALSE)) { + watchdog('rules', 'Rules debug information: !log', array('!log' => $log), WATCHDOG_NOTICE); + } + } + return $elements; +} + +/** + * Implements hook_drupal_goto_alter(). + * + * @see rules_action_drupal_goto() + */ +function rules_drupal_goto_alter(&$path, &$options, &$http_response_code) { + // Invoke a the page redirect, in case the action has been executed. + if (isset($GLOBALS['_rules_action_drupal_goto_do'])) { + list($url, $force) = $GLOBALS['_rules_action_drupal_goto_do']; + + if ($force || !isset($_GET['destination'])) { + $url = drupal_parse_url($url); + $path = $url['path']; + $options['query'] = $url['query']; + $options['fragment'] = $url['fragment']; + $http_response_code = 302; + } + } +} + +/** + * Returns whether the debug log should be shown. + */ +function rules_show_debug_output() { + // For performance avoid unnecessary auto-loading of the RulesLog class. + if (!class_exists('RulesLog', FALSE)) { + return FALSE; + } + if (variable_get('rules_debug', 0) == RulesLog::INFO && user_access('access rules debug')) { + return TRUE; + } + return variable_get('rules_debug', 0) == RulesLog::WARN && user_access('access rules debug') && RulesLog::logger()->hasErrors(); +} + +/** + * Implements hook_exit(). + */ +function rules_exit() { + // Bail out if this is cached request and modules are not loaded. + if (!module_exists('rules') || !module_exists('user')) { + return; + } + if (rules_show_debug_output()) { + if ($log = RulesLog::logger()->render()) { + // Keep the log in the session so we can show it on the next page. + $_SESSION['rules_debug'][] = $log; + } + } + // Log the rules log to the system log if enabled. + if (variable_get('rules_debug_log', FALSE) && $log = RulesLog::logger()->render()) { + watchdog('rules', 'Rules debug information: !log', array('!log' => $log), WATCHDOG_NOTICE); + } +} + +/** + * Implements hook_element_info(). + */ +function rules_element_info() { + // A duration form element for rules. Needs ui.forms.inc included. + $types['rules_duration'] = array( + '#input' => TRUE, + '#tree' => TRUE, + '#default_value' => 0, + '#value_callback' => 'rules_ui_element_duration_value', + '#process' => array('rules_ui_element_duration_process', 'ajax_process_form'), + '#after_build' => array('rules_ui_element_duration_after_build'), + '#pre_render' => array('form_pre_render_conditional_form_element'), + ); + $types['rules_data_selection'] = array( + '#input' => TRUE, + '#pre_render' => array('form_pre_render_conditional_form_element'), + '#process' => array('rules_data_selection_process', 'ajax_process_form'), + '#theme' => 'rules_autocomplete', + ); + return $types; +} + +/** + * Implements hook_modules_enabled(). + */ +function rules_modules_enabled($modules) { + // Re-enable Rules configurations that are dirty, because they require one of + // the enabled the modules. + $query = db_select('rules_dependencies', 'rd'); + $query->join('rules_config', 'rc', 'rd.id = rc.id'); + $query->fields('rd', array('id')) + ->condition('rd.module', $modules, 'IN') + ->condition('rc.dirty', 1); + $ids = $query->execute()->fetchCol(); + + // If there are some configurations that might work again, re-check all dirty + // configurations as others might work again too, e.g. consider a rule that is + // dirty because it requires a dirty component. + if ($ids) { + $rules_configs = rules_config_load_multiple(FALSE, array('dirty' => 1)); + foreach ($rules_configs as $rules_config) { + try { + $rules_config->integrityCheck(); + // If no exceptions were thrown we can set the configuration back to OK. + db_update('rules_config') + ->fields(array('dirty' => 0)) + ->condition('id', $rules_config->id) + ->execute(); + if ($rules_config->active) { + drupal_set_message(t('All dependencies for the Rules configuration %config are met again, so it has been re-activated.', array('%config' => $rules_config->label()))); + } + } + catch (RulesIntegrityException $e) { + // The rule is still dirty, so do nothing. + } + } + } + rules_clear_cache(); +} + +/** + * Implements hook_modules_disabled(). + */ +function rules_modules_disabled($modules) { + // Disable Rules configurations that depend on one of the disabled modules. + $query = db_select('rules_dependencies', 'rd'); + $query->join('rules_config', 'rc', 'rd.id = rc.id'); + $query->fields('rd', array('id')) + ->distinct() + ->condition('rd.module', $modules, 'IN') + ->condition('rc.dirty', 0); + $ids = $query->execute()->fetchCol(); + + if (!empty($ids)) { + db_update('rules_config') + ->fields(array('dirty' => 1)) + ->condition('id', $ids, 'IN') + ->execute(); + // Tell the user about enabled rules that have been marked as dirty. + $count = db_select('rules_config', 'r') + ->fields('r') + ->condition('id', $ids, 'IN') + ->condition('active', 1) + ->countQuery() + ->execute() + ->fetchField(); + if ($count > 0) { + $message = format_plural($count, + '1 Rules configuration requires some of the disabled modules to function and cannot be executed any more.', + '@count Rules configurations require some of the disabled modules to function and cannot be executed any more.' + ); + drupal_set_message($message, 'warning'); + } + } + rules_clear_cache(); +} + +/** + * Access callback for dealing with Rules configurations. + * + * @see entity_access() + */ +function rules_config_access($op, $rules_config = NULL, $account = NULL) { + if (user_access('bypass rules access', $account)) { + return TRUE; + } + // Allow modules to grant / deny access. + $access = module_invoke_all('rules_config_access', $op, $rules_config, $account); + + // Only grant access if at least one module granted access and no one denied + // access. + if (in_array(FALSE, $access, TRUE)) { + return FALSE; + } + elseif (in_array(TRUE, $access, TRUE)) { + return TRUE; + } + return FALSE; +} + +/** + * Implements hook_rules_config_access(). + */ +function rules_rules_config_access($op, $rules_config = NULL, $account = NULL) { + // Instead of returning FALSE return nothing, so others still can grant + // access. + if (!isset($rules_config) || (isset($account) && $account->uid != $GLOBALS['user']->uid)) { + return; + } + if (user_access('administer rules', $account) && ($op == 'view' || $rules_config->access())) { + return TRUE; + } +} + +/** + * Implements hook_menu(). + */ +function rules_menu() { + $items['admin/config/workflow/rules/upgrade'] = array( + 'title' => 'Upgrade', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_upgrade_form'), + 'access arguments' => array('administer rules'), + 'file' => 'includes/rules.upgrade.inc', + 'file path' => drupal_get_path('module', 'rules'), + 'type' => MENU_CALLBACK, + ); + $items['admin/config/workflow/rules/upgrade/clear'] = array( + 'title' => 'Clear', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_upgrade_confirm_clear_form'), + 'access arguments' => array('administer rules'), + 'file' => 'includes/rules.upgrade.inc', + 'file path' => drupal_get_path('module', 'rules'), + 'type' => MENU_CALLBACK, + ); + $items['admin/config/workflow/rules/autocomplete_tags'] = array( + 'title' => 'Rules tags autocomplete', + 'page callback' => 'rules_autocomplete_tags', + 'page arguments' => array(5), + 'access arguments' => array('administer rules'), + 'file' => 'ui/ui.forms.inc', + 'type' => MENU_CALLBACK, + ); + return $items; +} + +/** + * Helper function to keep track of external documentation pages for Rules. + * + * @param string $topic + * The topic key for used for identifying help pages. + * + * @return string|array|false + * Either a URL for the given page, or the full list of external help pages. + */ +function rules_external_help($topic = NULL) { + $help = array( + 'rules' => 'https://www.drupal.org/node/298480', + 'terminology' => 'https://www.drupal.org/node/1299990', + 'condition-components' => 'https://www.drupal.org/node/1300034', + 'data-selection' => 'https://www.drupal.org/node/1300042', + 'chained-tokens' => 'https://www.drupal.org/node/1300042', + 'loops' => 'https://www.drupal.org/node/1300058', + 'components' => 'https://www.drupal.org/node/1300024', + 'component-types' => 'https://www.drupal.org/node/1300024', + 'variables' => 'https://www.drupal.org/node/1300024', + 'scheduler' => 'https://www.drupal.org/node/1300068', + 'coding' => 'https://www.drupal.org/node/878720', + ); + + if (isset($topic)) { + return isset($help[$topic]) ? $help[$topic] : FALSE; + } + return $help; +} + +/** + * Implements hook_help(). + */ +function rules_help($path, $arg) { + // Only enable the help if the admin module is active. + if ($path == 'admin/help#rules' && module_exists('rules_admin')) { + + $output['header'] = array( + '#markup' => t('Rules documentation is kept online. Please use the links below for more information about Rules. Feel free to contribute to improving the online documentation!'), + ); + // Build a list of essential Rules help pages, formatted as a bullet list. + $link_list['rules'] = l(t('Rules introduction'), rules_external_help('rules')); + $link_list['terminology'] = l(t('Rules terminology'), rules_external_help('terminology')); + $link_list['scheduler'] = l(t('Rules Scheduler'), rules_external_help('scheduler')); + $link_list['coding'] = l(t('Coding for Rules'), rules_external_help('coding')); + + $output['topic-list'] = array( + '#markup' => theme('item_list', array('items' => $link_list)), + ); + return render($output); + } +} + +/** + * Implements hook_token_info(). + */ +function rules_token_info() { + $cache = rules_get_cache(); + $data_info = $cache['data_info']; + + $types = array( + 'text', + 'integer', + 'uri', + 'token', + 'decimal', + 'date', + 'duration', + ); + + foreach ($types as $type) { + $token_type = $data_info[$type]['token type']; + + $token_info['types'][$token_type] = array( + 'name' => $data_info[$type]['label'], + 'description' => t('Tokens related to %label Rules variables.', array('%label' => $data_info[$type]['label'])), + 'needs-data' => $token_type, + ); + $token_info['tokens'][$token_type]['value'] = array( + 'name' => t("Value"), + 'description' => t('The value of the variable.'), + ); + } + return $token_info; +} + +/** + * Implements hook_tokens(). + */ +function rules_tokens($type, $tokens, $data, $options = array()) { + // Handle replacements of primitive variable types. + if (substr($type, 0, 6) == 'rules_' && !empty($data[$type])) { + // Leverage entity tokens token processor by passing on as struct. + $info['property info']['value'] = array( + 'type' => substr($type, 6), + 'label' => '', + ); + // Entity tokens uses metadata wrappers as values for 'struct' types. + $wrapper = entity_metadata_wrapper('struct', array('value' => $data[$type]), $info); + return entity_token_tokens('struct', $tokens, array('struct' => $wrapper), $options); + } +} + +/** + * Helper function that retrieves a metadata wrapper with all properties. + * + * Note that without this helper, bundle-specific properties aren't added. + */ +function rules_get_entity_metadata_wrapper_all_properties(RulesAbstractPlugin $element) { + return entity_metadata_wrapper($element->settings['type'], NULL, array( + 'property info alter' => 'rules_entity_metadata_wrapper_all_properties_callback', + )); +} + +/** + * Callback that returns a metadata wrapper with all properties. + */ +function rules_entity_metadata_wrapper_all_properties_callback(EntityMetadataWrapper $wrapper, $property_info) { + $info = $wrapper->info(); + $properties = entity_get_all_property_info($info['type']); + $property_info['properties'] += $properties; + return $property_info; +} + +/** + * Helper to enable or disable the invocation of rules events. + * + * Rules invocation is disabled by default, such that Rules does not operate + * when Drupal is not fully bootstrapped. It gets enabled in rules_init() and + * rules_enable(). + * + * @param bool|null $enable + * NULL to leave the setting as is and TRUE / FALSE to change the behaviour. + * + * @return bool + * Whether the rules invocation is enabled or disabled. + */ +function rules_event_invocation_enabled($enable = NULL) { + static $invocation_enabled = FALSE; + if (isset($enable)) { + $invocation_enabled = (bool) $enable; + } + // Disable invocation if configured or if site runs in maintenance mode. + return $invocation_enabled && !defined('MAINTENANCE_MODE'); +} diff --git a/rules.rules.inc b/rules.rules.inc new file mode 100644 index 0000000..0d87849 --- /dev/null +++ b/rules.rules.inc @@ -0,0 +1,133 @@ + 'reaction rule', 'active' => TRUE); + $collapsed = TRUE; + if (empty($_GET['tag'])) { + $tag = 0; + } + else { + $tag = $_GET['tag']; + $conditions['tags'] = array($tag); + $collapsed = FALSE; + } + if (empty($_GET['event'])) { + $event = 0; + } + else { + $event = $_GET['event']; + // Filter using a wildcard suffix so configured event names with suffixes + // are found also. + $conditions['event'] = $event . '%'; + $collapsed = FALSE; + } + $form['help'] = array( + '#markup' => t('Reaction rules, listed below, react on selected events on the site. Each reaction rule may fire any number of actions, and may have any number of conditions that must be met for the actions to be executed. You can also set up components – stand-alone sets of Rules configuration that can be used in Rules and other parts of your site. See the online documentation for an introduction on how to use Rules.', + array('@url1' => url('admin/config/workflow/rules/components'), + '@url2' => rules_external_help('rules'))), + ); + + $form['filter'] = array( + '#type' => 'fieldset', + '#title' => t('Filter'), + '#collapsible' => TRUE, + ); + $form['filter']['#id'] = 'rules-filter-form'; + $form['filter']['#attached']['css'][] = drupal_get_path('module', 'rules') . '/ui/rules.ui.css'; + $form['filter']['event'] = array( + '#type' => 'select', + '#title' => t('Filter by event'), + '#options' => array(0 => t('')) + RulesPluginUI::getOptions('event'), + '#default_value' => $event, + ); + $form['filter']['tag'] = array( + '#type' => 'select', + '#title' => t('Filter by tag'), + '#options' => array(0 => t('')) + RulesPluginUI::getTags(), + '#default_value' => $tag, + ); + $form['filter']['submit'] = array( + '#type' => 'submit', + '#value' => t('Filter'), + '#name' => '', // prevent from showing up in $_GET. + ); + + $options = array('show plugin' => FALSE, 'base path' => $base_path); + $form['active'] = rules_ui()->overviewTable($conditions, $options); + $form['active']['#caption'] = t('Active rules'); + $form['active']['#empty'] = t('There are no active rules. Add new rule.', array('!url' => url('admin/config/workflow/rules/reaction/add'))); + + $conditions['active'] = FALSE; + $form['inactive'] = rules_ui()->overviewTable($conditions, $options); + $form['inactive']['#caption'] = t('Inactive rules'); + $form['inactive']['#empty'] = t('There are no inactive rules.'); + + $form['filter']['#collapsed'] = $collapsed; + $form['#submit'][] = 'rules_form_submit_rebuild'; + $form['#method'] = 'get'; + return $form; +} + +/** + * Components overview. + */ +function rules_admin_components_overview($form, &$form_state, $base_path) { + RulesPluginUI::formDefaults($form, $form_state); + + $collapsed = TRUE; + if (empty($_GET['tag'])) { + $tag = 0; + } + else { + $tag = $_GET['tag']; + $conditions['tags'] = array($tag); + $collapsed = FALSE; + } + if (empty($_GET['plugin'])) { + // Get the plugin name usable as component. + $conditions['plugin'] = array_keys(rules_filter_array(rules_fetch_data('plugin_info'), 'component', TRUE)); + $plugin = 0; + } + else { + $plugin = $_GET['plugin']; + $conditions['plugin'] = $plugin; + $collapsed = FALSE; + } + $form['help'] = array( + '#markup' => t('Components are stand-alone sets of Rules configuration that can be used by Rules and other modules on your site. Components are for example useful if you want to use the same conditions, actions or rules in multiple places, or call them from your custom module. You may also export each component separately. See the online documentation for more information about how to use components.', + array('@url' => rules_external_help('components'))), + ); + $form['filter'] = array( + '#type' => 'fieldset', + '#title' => t('Filter'), + '#collapsible' => TRUE, + ); + $form['filter']['#id'] = 'rules-filter-form'; + $form['filter']['#attached']['css'][] = drupal_get_path('module', 'rules') . '/ui/rules.ui.css'; + $form['filter']['plugin'] = array( + '#type' => 'select', + '#title' => t('Filter by plugin'), + '#options' => array(0 => t('')) + rules_admin_component_options(), + '#default_value' => $plugin, + ); + $form['filter']['tag'] = array( + '#type' => 'select', + '#title' => t('Filter by tag'), + '#options' => array(0 => '') + RulesPluginUI::getTags(), + '#default_value' => $tag, + ); + $form['filter']['submit'] = array( + '#type' => 'submit', + '#value' => t('Filter'), + '#name' => '', // prevent from showing up in $_GET. + ); + + $form['table'] = RulesPluginUI::overviewTable($conditions, array('hide status op' => TRUE)); + $form['table']['#empty'] = t('There are no rule components.'); + + $form['filter']['#collapsed'] = $collapsed; + $form['#submit'][] = 'rules_form_submit_rebuild'; + $form['#method'] = 'get'; + return $form; +} + +/** + * Rules settings form. + */ +function rules_admin_settings($form, &$form_state) { + + if (module_exists('path')) { + // Present a list of available path cleaning callbacks. + // @see rules_clean_path() + $options = array( + 'rules_path_default_cleaning_method' => t('Rules (built in)'), + ); + if (module_exists('ctools')) { + $options['rules_path_clean_ctools'] = t('CTools'); + } + if (module_exists('pathauto')) { + $options['rules_path_clean_pathauto'] = t('Pathauto'); + $pathauto_help = t("Note that Pathauto's URL path cleaning method can be configured at admin/config/search/path/settings.", array('!url' => url('admin/config/search/path/settings'))); + } + else { + $pathauto_help = t('Install the Pathauto module in order to get a configurable URL path cleaning method.'); + } + + $form['path']['rules_path_cleaning_callback'] = array( + '#type' => 'select', + '#title' => t('URL path cleaning method'), + '#description' => t('Choose the path cleaning method to be applied when generating URL path aliases.') . ' ' . $pathauto_help, + '#default_value' => variable_get('rules_path_cleaning_callback', 'rules_path_default_cleaning_method'), + '#options' => $options, + ); + } + + $form['rules_log_errors'] = array( + '#type' => 'radios', + '#title' => t('Logging of Rules evaluation errors'), + '#options' => array( + RulesLog::WARN => t('Log all warnings and errors'), + RulesLog::ERROR => t('Log errors only'), + ), + '#default_value' => variable_get('rules_log_errors', RulesLog::WARN), + '#description' => t('Evaluations errors are logged to the system log.'), + ); + + $form['debug'] = array( + '#type' => 'fieldset', + '#title' => t('Debugging'), + ); + $form['debug']['rules_debug_log'] = array( + '#type' => 'checkbox', + '#title' => t('Log debug information to the system log'), + '#default_value' => variable_get('rules_debug_log', FALSE), + ); + $form['debug']['rules_debug'] = array( + '#type' => 'radios', + '#title' => t('Show debug information'), + '#default_value' => variable_get('rules_debug', 0), + '#options' => array( + 0 => t('Never'), + RulesLog::WARN => t('In case of errors'), + RulesLog::INFO => t('Always'), + ), + '#description' => t('Debug information is only shown when rules are evaluated and is visible for users having the permission %link.', array('%link' => t('Access the Rules debug log'), '!url' => url('admin/people/permissions', array('fragment' => 'module-rules')))), + ); + + $form['debug']['regions'] = array( + '#type' => 'container', + '#states' => array( + // Hide the regions settings when the debug log is disabled. + 'invisible' => array( + 'input[name="rules_debug"]' => array('value' => 0), + ), + ), + ); + + $theme_default = variable_get('theme_default', 'bartik'); + $admin_theme = variable_get('admin_theme', 'seven'); + + $form['debug']['regions']['rules_debug_region_' . $theme_default] = array( + '#type' => 'select', + '#title' => t('Default theme region'), + '#description' => t("The region, where the debug log should be displayed on the default theme %theme. For other themes, Rules will try to display the log on the same region, or hide it in case it doesn't exist.", array('%theme' => $theme_default)), + '#options' => system_region_list($theme_default, REGIONS_VISIBLE), + '#default_value' => variable_get('rules_debug_region_' . $theme_default, 'help'), + ); + + $form['debug']['regions']['rules_debug_region_' . $admin_theme] = array( + '#type' => 'select', + '#title' => t('Admin theme region'), + '#description' => t('The region, where the debug log should be displayed on the admin theme %theme.', array('%theme' => $admin_theme)), + '#options' => system_region_list($admin_theme, REGIONS_VISIBLE), + '#default_value' => variable_get('rules_debug_region_' . $admin_theme, 'help'), + ); + if (db_table_exists('rules_rules')) { + drupal_set_message(t('There are left over rule configurations from a previous Rules 1.x installation. Proceed to the upgrade page to convert them and consult the README.txt for more details.', array('!url' => url('admin/config/workflow/rules/upgrade'))), 'warning'); + } + + return system_settings_form($form); +} + +/** + * Advanced settings form. + */ +function rules_admin_settings_advanced($form, &$form_state) { + + $form['integrity'] = array( + '#type' => 'fieldset', + '#title' => t('Integrity'), + '#description' => t('Rules checks the integrity of your configurations to discover and exclude broken configurations from evaluation.'), + ); + $form['integrity']['start_integrity_check'] = array( + '#type' => 'submit', + '#value' => t('Recheck integrity'), + '#submit' => array('rules_admin_settings_integrity_check_submit'), + ); + $form['cache'] = array( + '#type' => 'fieldset', + '#title' => t('Cache'), + '#description' => t('Rules caches information about available actions, conditions and data types. Additionally all components and reaction rules are cached for efficient evaluation.'), + ); + $form['cache']['rebuild_rules_cache'] = array( + '#type' => 'submit', + '#value' => t("Rebuild Rules' cache"), + '#weight' => 2, + '#submit' => array('rules_admin_settings_cache_rebuild_submit'), + ); + return $form; +} + +/** + * Form submit callback to check the integrity of all configurations. + */ +function rules_admin_settings_integrity_check_submit($form, &$form_state) { + $start = microtime(TRUE); + $count = 0; + $rules_configs = rules_config_load_multiple(FALSE); + foreach ($rules_configs as $rules_config) { + rules_config_update_dirty_flag($rules_config, TRUE, TRUE); + if ($rules_config->dirty) { + $count++; + $variables = array('%label' => $rules_config->label(), '%name' => $rules_config->name, '@plugin' => $rules_config->plugin(), '!uri' => url(RulesPluginUI::path($rules_config->name))); + drupal_set_message(t('The @plugin %label (%name) fails the integrity check and cannot be executed.', $variables), 'error'); + } + + } + drupal_set_message(t('Integrity of %count configurations checked in %duration seconds. %count_failed broken configurations found.', array( + '%count' => count($rules_configs), + '%count_failed' => $count, + '%duration' => round(microtime(TRUE) - $start, 2), + ))); +} + +/** + * Form submit callback: Rebuild the Rules' cache. + */ +function rules_admin_settings_cache_rebuild_submit($form, &$form_state) { + $start = microtime(TRUE); + rules_clear_cache(); + // Manually trigger cache rebuilding of all caches. + rules_get_cache(); + _rules_rebuild_component_cache(); + RulesEventSet::rebuildEventCache(); + drupal_set_message(t('Rules cache rebuilt in %duration seconds.', array( + '%duration' => round(microtime(TRUE) - $start, 2), + ))); +} + +/** + * Add reaction rules form. + */ +function rules_admin_add_reaction_rule($form, &$form_state, $base_path) { + RulesPluginUI::formDefaults($form, $form_state); + + $rules_config = isset($form_state['rules_config']) ? $form_state['rules_config'] : rules_reaction_rule(); + $rules_config->form($form, $form_state, array('show settings' => TRUE, 'button' => TRUE)); + + $form['settings']['#collapsible'] = FALSE; + $form['settings']['#type'] = 'container'; + $form['settings']['label']['#default_value'] = ''; + + // Hide the rule elements stuff for now. + foreach (array('elements', 'conditions', 'add', 'events') as $key) { + $form[$key]['#access'] = FALSE; + } + foreach (array('active', 'weight') as $key) { + $form['settings'][$key]['#access'] = FALSE; + } + // Incorporate the form to add the first event. + $form['settings'] += rules_ui_add_event(array(), $form_state, $rules_config, $base_path); + $form['settings']['event']['#tree'] = FALSE; + $form['settings']['event_settings']['#tree'] = FALSE; + unset($form['settings']['help']); + + unset($form['settings']['submit']); + $form['submit']['#value'] = t('Save'); + + $form_state += array('rules_config' => $rules_config); + $form['#validate'][] = 'rules_ui_add_reaction_rule_validate'; + $form['#validate'][] = 'rules_ui_edit_element_validate'; + $form['#submit'][] = 'rules_ui_add_reaction_rule_submit'; + return $form; +} + +/** + * Form validation callback. + */ +function rules_ui_add_reaction_rule_validate(&$form, &$form_state) { + rules_ui_add_event_validate($form['settings'], $form_state); +} + +/** + * Form submit callback. + */ +function rules_ui_add_reaction_rule_submit(&$form, &$form_state) { + rules_ui_add_event_apply($form['settings'], $form_state); + rules_ui_edit_element_submit($form, $form_state); +} + +/** + * Add component form. + */ +function rules_admin_add_component($form, &$form_state, $base_path) { + RulesPluginUI::$basePath = $base_path; + RulesPluginUI::formDefaults($form, $form_state); + + $form['plugin_name'] = array( + '#type' => 'select', + '#title' => t('Component plugin'), + '#options' => rules_admin_component_options(), + '#description' => t('Choose which kind of component to create. Each component type is described in the online documentation.', + array('@url' => rules_external_help('component-types'))), + '#weight' => -2, + '#default_value' => isset($form_state['values']['plugin_name']) ? $form_state['values']['plugin_name'] : '', + ); + + if (!isset($form_state['rules_config'])) { + $form['continue'] = array( + '#type' => 'submit', + '#name' => 'continue', + '#submit' => array('rules_admin_add_component_create_submit'), + '#value' => t('Continue'), + ); + } + else { + $form['plugin_name']['#disabled'] = TRUE; + $form_state['rules_config']->form($form, $form_state, array('show settings' => TRUE, 'button' => TRUE, 'init' => TRUE)); + $form['settings']['#collapsible'] = FALSE; + $form['settings']['#type'] = 'container'; + $form['settings']['label']['#default_value'] = ''; + $form['settings']['#weight'] = -1; + + // Hide the rule elements stuff for now. + foreach (array('elements', 'negate') as $key) { + $form[$key]['#access'] = FALSE; + } + foreach (array('active', 'weight') as $key) { + $form['settings'][$key]['#access'] = FALSE; + } + } + return $form; +} + +function rules_admin_component_options() { + $cache = rules_get_cache(); + return rules_extract_property(rules_filter_array($cache['plugin_info'], 'component', TRUE), 'label'); +} + +/** + * Submit callback that creates the new component object initially. + */ +function rules_admin_add_component_create_submit($form, &$form_state) { + $form_state['rules_config'] = rules_plugin_factory($form_state['values']['plugin_name']); + $form_state['rebuild'] = TRUE; +} + +/** + * Validation callback for adding a component. + */ +function rules_admin_add_component_validate($form, &$form_state) { + if (isset($form_state['rules_config'])) { + $form_state['rules_config']->form_validate($form, $form_state); + } +} + +/** + * Final submit callback for adding a component. + */ +function rules_admin_add_component_submit($form, &$form_state) { + $rules_config = $form_state['rules_config']; + $rules_config->form_submit($form, $form_state); + drupal_set_message(t('Your changes have been saved.')); + $form_state['redirect'] = RulesPluginUI::path($rules_config->name); +} diff --git a/rules_admin/rules_admin.info b/rules_admin/rules_admin.info new file mode 100644 index 0000000..1c92710 --- /dev/null +++ b/rules_admin/rules_admin.info @@ -0,0 +1,16 @@ +name = Rules UI +description = Administrative interface for managing rules. +package = Rules +core = 7.x +dependencies[] = rules +configure = admin/config/workflow/rules + +; Test cases +files[] = tests/rules_admin.test +files[] = tests/rules_admin_minimal_profile.test + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/rules_admin/rules_admin.module b/rules_admin/rules_admin.module new file mode 100644 index 0000000..f5a38e7 --- /dev/null +++ b/rules_admin/rules_admin.module @@ -0,0 +1,135 @@ +config_menu($reaction_path); + + $items[$reaction_path] = array( + 'title' => 'Rules', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -1, + ); + $items[$reaction_path . '/add'] = array( + 'title' => 'Add new rule', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_add_reaction_rule', $reaction_path), + 'access arguments' => array('administer rules'), + 'type' => MENU_LOCAL_ACTION, + 'file' => 'rules_admin.inc', + 'weight' => 0, + ); + $items[$reaction_path . '/import'] = array( + 'title' => 'Import rule', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_import_form', $reaction_path), + 'access arguments' => array('administer rules'), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + 'type' => MENU_LOCAL_ACTION, + ); + + // Components UI menu entries. + $component_path = 'admin/config/workflow/rules/components'; + $items += rules_ui()->config_menu($component_path); + $items[$component_path] = array( + 'title' => 'Components', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_components_overview', $component_path), + 'access arguments' => array('administer rules'), + 'type' => MENU_LOCAL_TASK, + 'file' => 'rules_admin.inc', + 'weight' => 0, + ); + $items[$component_path . '/add'] = array( + 'title' => 'Add new component', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_add_component', $component_path), + 'access arguments' => array('administer rules'), + 'type' => MENU_LOCAL_ACTION, + 'file' => 'rules_admin.inc', + 'weight' => 0, + ); + $items[$component_path . '/import'] = array( + 'title' => 'Import component', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_import_form', $component_path), + 'access arguments' => array('administer rules'), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + 'type' => MENU_LOCAL_ACTION, + ); + + // Some general rules admin menu items. + $items['admin/config/workflow/rules'] = array( + 'title' => 'Rules', + 'position' => 'right', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_reaction_overview', $reaction_path), + 'description' => 'Manage reaction rules and rule components.', + 'access arguments' => array('administer rules'), + 'file' => 'rules_admin.inc', + ); + $items['admin/config/workflow/rules/settings'] = array( + 'title' => 'Settings', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_settings'), + 'access arguments' => array('administer rules'), + 'type' => MENU_LOCAL_TASK, + 'file' => 'rules_admin.inc', + 'weight' => 1, + ); + $items['admin/config/workflow/rules/settings/basic'] = array( + 'title' => 'Basic', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + $items['admin/config/workflow/rules/settings/advanced'] = array( + 'title' => 'Advanced', + 'type' => MENU_LOCAL_TASK, + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_admin_settings_advanced'), + 'access arguments' => array('administer rules'), + 'file' => 'rules_admin.inc', + ); + return $items; +} + +/** + * Implements hook_form_alter(). + * + * Since the overview forms are GET forms, we don't want them to send a wide + * variety of information. We need to use hook_form_alter() because the + * properties are added after form creation. + */ +function rules_admin_form_alter(&$form, &$form_state, $form_id) { + if ($form_id == 'rules_admin_reaction_overview' || $form_id == 'rules_admin_components_overview') { + $form['form_build_id']['#access'] = FALSE; + $form['form_token']['#access'] = FALSE; + $form['form_id']['#access'] = FALSE; + } +} + +/** + * Implements hook_system_info_alter(). + * + * Adds configuration links for Rules and Rules Scheduler in the modules list. + * (This is done by the Rules UI module, without which there would be no + * configuration pages to visit.) + */ +function rules_admin_system_info_alter(&$info, $file, $type) { + if ($file->name == 'rules') { + $info['configure'] = 'admin/config/workflow/rules'; + } + if ($file->name == 'rules_scheduler') { + $info['configure'] = 'admin/config/workflow/rules/schedule'; + } +} diff --git a/rules_admin/tests/rules_admin.test b/rules_admin/tests/rules_admin.test new file mode 100644 index 0000000..d9f7f5d --- /dev/null +++ b/rules_admin/tests/rules_admin.test @@ -0,0 +1,376 @@ + 'Rules UI Tests ', + 'description' => 'Tests Rules UI.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_admin', 'rules_test'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Tests that NOT condition labels are not HTML-encoded in the UI. + * + * @see https://www.drupal.org/project/rules/issues/1945006 + */ + public function testConditionLabel() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array('access administration pages', 'administer rules')); + $this->drupalLogin($user); + + // First we need an event. + $this->drupalGet('admin/config/workflow/rules/reaction/add'); + $edit = array( + 'settings[label]' => 'Test node event', + 'settings[name]' => 'test_node_event', + 'event' => 'node_insert', + ); + $this->drupalPost(NULL, $edit, 'Save'); + $this->assertText('Editing reaction rule', 'Rule edit page is shown.'); + + // Now add a condition with a special character in the label. + $this->clickLink('Add condition'); + $this->assertText('Add a new condition', 'Condition edit page is shown.'); + $edit = array( + 'element_name' => 'rules_test_condition_apostrophe', + ); + $this->drupalPost(NULL, $edit, 'Continue'); + + // Negate the condition, as this is how it gets improperly HTML encoded. + $edit = array( + 'negate' => TRUE, + ); + $this->drupalPost(NULL, $edit, 'Save'); + $this->assertNoRaw("&#039;", 'Apostrophe is not HTML-encoded.'); + } + + /** + * Tests that the title for embedded container plugins displays properly. + * + * @see https://www.drupal.org/project/rules/issues/3028444 + */ + public function testContainerPluginLabel() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array('access administration pages', 'administer rules')); + $this->drupalLogin($user); + + // First we need an event. + $this->drupalGet('admin/config/workflow/rules/reaction/add'); + $edit = array( + 'settings[label]' => 'Test node event', + 'settings[name]' => 'test_node_event', + 'event' => 'node_insert', + ); + $this->drupalPost(NULL, $edit, 'Save'); + $this->assertText('Editing reaction rule', 'Rule edit page is shown.'); + + // Now add an OR condition container. + $this->clickLink('Add or'); + $this->assertText('Add a new condition set (OR)', 'Condition set add confirmation is shown.'); + $this->drupalPost(NULL, array(), 'Continue'); + $this->assertRaw('
OR
', 'Condition set label is shown.'); + } + + /** + * Tests setting component variables in the UI. + * + * @see https://www.drupal.org/project/rules/issues/3005864 + */ + public function testComponentVariables() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array( + 'access administration pages', + 'administer rules', + 'bypass rules access', + )); + $this->drupalLogin($user); + + // Variables cannot be set/changed for code-provided components, so we must + // build our test component here. + // Create an 'action set' rule component. + $action_set = rules_action_set(array( + 'node' => array('type' => 'node', 'label' => 'Input node'), + )); + $action_set->save('rules_test_variables'); + + // Verify that our test component appears in the UI. + $this->drupalGet('admin/config/workflow/rules/components'); + $this->assertText( + 'rules_test_variables', + 'Test component is defined.' + ); + + // Visit the component edit page. + $this->clickLink('rules_test_variables'); + $this->assertText( + 'Variables are normally input parameters for the component', + 'Component variables form is present.' + ); + + // Check for presence of pre-existing variable fields AND for presence + // of exactly one row of fields for new variable input. + $this->assertFieldByName( + 'settings[vars][items][node][label]', + 'Input node', + 'Label of pre-existing variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][0][label]', + '', + 'First row for new variable is present.' + ); + // Should only have line [0], not [1] or [2]. + $this->assertNoFieldByName( + 'settings[vars][items][1][label]', + NULL, + 'Second row for new variable is missing.' + ); + $this->assertNoFieldByName( + 'settings[vars][items][2][label]', + NULL, + 'Third row for new variable is missing.' + ); + + // Save new variable. + $edit = array( + 'settings[vars][items][0][type]' => 'integer', + 'settings[vars][items][0][label]' => 'Integer value', + 'settings[vars][items][0][name]' => 'integer_value', + ); + $this->drupalPost(NULL, $edit, 'Save changes'); + $this->assertFieldByName( + 'settings[vars][items][node][label]', + 'Input node', + 'Label of pre-existing variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][integer_value][label]', + 'Integer value', + 'Label of variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][integer_value][name]', + 'integer_value', + 'Machine name of variable is defined.' + ); + + // Check to see if "Add more" button properly adds one more row for + // variable input while preserving what we've already entered. + $edit = array( + 'settings[vars][items][0][type]' => 'decimal', + 'settings[vars][items][0][label]' => 'Decimal value', + 'settings[vars][items][0][name]' => 'decimal_value', + ); + $this->drupalPostAjax(NULL, $edit, array('op' => 'Add more')); + $this->assertFieldByName( + 'settings[vars][items][node][label]', + 'Input node', + 'Label of pre-existing variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][integer_value][label]', + 'Integer value', + 'Label of integer variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][decimal_value][label]', + 'Decimal value', + 'Label of decimal variable is defined.' + ); + $this->assertFieldByName( + 'settings[vars][items][0][label]', + NULL, + 'First row for new variable is present.' + ); + } + + /** + * Tests setting component permissions in the UI. + * + * @see https://www.drupal.org/project/rules/issues/2340505 + */ + public function testComponentPermissions() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array( + 'access administration pages', + 'administer rules', + 'bypass rules access', + )); + $this->drupalLogin($user); + + // The rules_test module defines the component 'rules_test_action_set'. + // Verify that this code-provided component appears in the UI. + $this->drupalGet('admin/config/workflow/rules/components'); + $this->assertText( + 'rules_test_action_set', + 'Test component is defined.' + ); + + // Visit the component edit page. + $this->clickLink('rules_test_action_set'); + $this->assertText( + 'Configure access for using this component with a permission.', + 'Enable component permission checkbox is present.' + ); + $this->assertText( + 'Use Rules component rules_test_action_set', + 'Permission configuration form is present.' + ); + + // Try to enable permissions on this component and set a component + // permission at the same time. + $edit = array( + 'settings[access][access_exposed]' => TRUE, + 'settings[access][permissions][matrix][checkboxes][1][use Rules component rules_test_action_set]' => TRUE, + ); + $this->drupalPost(NULL, $edit, 'Save changes'); + } + + /** + * Tests overriding and reverting configurations. + * + * Verify that when we overwrite a default rule with an import, the status of + * that rule is overridden. + * + * @see https://www.drupal.org/project/rules/issues/2027717#comment-12904190 + */ + public function testOverrideStatus() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array( + 'access administration pages', + 'administer rules', + 'bypass rules access', + )); + $this->drupalLogin($user); + + // The rules_test module defines the rule 'rules_test_default_1' in code. + // Ensure this rule has status equals ENTITY_IN_CODE. + $rule = rules_config_load('rules_test_default_1'); + $this->assertTrue( + $rule->hasStatus(ENTITY_IN_CODE), + 'Rule defined in hook_default_rules_configuration() has status ENTITY_IN_CODE.' + ); + + // Verify the code-provided rule appears in the UI. + $this->drupalGet('admin/config/workflow/rules'); + $this->assertText( + 'example default rule', + 'Example default rule is defined in code.' + ); + $this->assertText( + 'rules_test_default_1', + 'Machine name shows up in UI.' + ); + + // Now we need to overwrite the 'rules_test_default_1' rule in the + // database by importing a rule with the same id and forcing the overwrite. + // First check that importing fails if the 'overwrite' box is not checked. + $this->drupalGet('admin/config/workflow/rules/reaction/import'); + $edit = array( + 'import' => $this->getTestRuleExport('rules_test_default_1'), + 'overwrite' => FALSE, + ); + $this->drupalPost(NULL, $edit, 'Import'); + $this->assertText( + 'Import of Rules configuration example imported default rule failed, a Rules configuration with the same machine name already exists. Check the overwrite option to replace it.', + 'Rule overwrite failed.' + ); + + // Now set the 'overwrite' checkbox to force the overwrite and resubmit. + $edit = array( + 'import' => $this->getTestRuleExport('rules_test_default_1'), + 'overwrite' => TRUE, + ); + $this->drupalPost(NULL, $edit, 'Import'); + + // Verify that the overwritten rule now has a status of ENTITY_OVERRIDDEN. + $this->assertText( + 'example imported default rule', + 'New example default rule has been imported.' + ); + $this->assertText( + 'rules_test_default_1', + 'Machine name shows up in UI.' + ); + $this->assertText( + 'Overridden', + 'Example default rule has overridden status.' + ); + + // Clear cache and ensure the rule is still overridden. + cache_clear_all(); + // Visit reaction rules listing page to force refresh. + $this->clickLink('Rules'); + $this->assertText( + 'example imported default rule', + 'Rule label unchanged after cache clear.' + ); + $this->assertText( + 'Overridden', + 'Rule overridden status unchanged after cache clear.' + ); + + // A 'revert' link should now be available for the overridden rule. + $this->assertText('revert', 'Revert link is now present.'); + + // Revert the overridden rule and verify it's back to its original status. + $this->clickLink('revert'); + $this->drupalPost(NULL, array(), 'Confirm'); + $this->assertText( + 'example default rule', + 'Example default rule original label restored.' + ); + $this->assertText( + 'Reverted reaction rule example imported default rule to the defaults', + 'Example default rule was reverted.' + ); + $this->assertNoText('revert', 'Revert link is not present.'); + } + + /** + * Helper function to return a known JSON encoded rule export. + * + * @param string $machine_name + * The machine name of the returned rule. + */ + protected function getTestRuleExport($machine_name) { + return '{ "' . $machine_name . '" : { + "LABEL" : "example imported default rule", + "PLUGIN" : "reaction rule", + "ACTIVE" : false, + "OWNER" : "rules", + "TAGS" : [ "Admin", "Tag2" ], + "REQUIRES" : [ "rules" ], + "ON" : { "node_update" : [] }, + "IF" : [ + { "NOT data_is" : { "data" : [ "node:status" ], "value" : true } }, + { "data_is" : { "data" : [ "node:type" ], "value" : "page" } } + ], + "DO" : [ { "drupal_message" : { "message" : "A node has been updated." } } ] + } +}'; + } + +} diff --git a/rules_admin/tests/rules_admin_minimal_profile.test b/rules_admin/tests/rules_admin_minimal_profile.test new file mode 100644 index 0000000..3fdc479 --- /dev/null +++ b/rules_admin/tests/rules_admin_minimal_profile.test @@ -0,0 +1,66 @@ + 'Rules UI Minimal Profile Tests ', + 'description' => 'Tests UI support for minimal profile.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_admin'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Tests node event UI without content types. + * + * @see https://www.drupal.org/project/rules/issues/2267341 + */ + public function testNodeEventUi() { + // Create a simple user account with permission to create a rule. + $user = $this->drupalCreateUser(array('access administration pages', 'administer rules')); + $this->drupalLogin($user); + + $this->drupalGet('admin/config/workflow/rules/reaction/add'); + $edit = array( + 'settings[label]' => 'Test node event', + 'settings[name]' => 'test_node_event', + 'event' => 'node_insert', + ); + $this->drupalPostAJAX(NULL, $edit, 'event'); + $this->assertText('Restrict by type', 'Restrict by type selection is visible.'); + $this->drupalPost(NULL, $edit, 'Save'); + $this->assertText('Editing reaction rule', 'Rule edit page is shown.'); + } + +} diff --git a/rules_i18n/rules_i18n.i18n.inc b/rules_i18n/rules_i18n.i18n.inc new file mode 100644 index 0000000..0f855b8 --- /dev/null +++ b/rules_i18n/rules_i18n.i18n.inc @@ -0,0 +1,107 @@ + t('Configuration name'), + 'string' => $this->object->label, + ); + + $this->buildElementProperties($this->object, $properties); + + // Add in translations for all elements. + foreach ($this->object->elements() as $element) { + $this->buildElementProperties($element, $properties); + } + $strings[$this->get_textgroup()]['rules_config'][$this->object->name] = $properties; + return $strings; + } + + /** + * Adds in translatable properties of the given element. + */ + protected function buildElementProperties($element, &$properties) { + + foreach ($element->pluginParameterInfo() as $name => $info) { + // Add in all directly provided input variables. + if (!empty($info['translatable']) && isset($element->settings[$name])) { + // If its an array of textual values, translate each value on its own. + if (is_array($element->settings[$name])) { + foreach ($element->settings[$name] as $i => $value) { + $properties[$element->elementId() . ':' . $name . ':' . $i] = array( + 'title' => t('@plugin "@label" (id @id), @parameter, Value @delta', array( + '@plugin' => drupal_ucfirst($element->plugin()), + '@label' => $element->label(), + '@id' => $element->elementId(), + '@parameter' => $info['label'], + '@delta' => $i + 1 + )), + 'string' => $value, + ); + } + } + else { + $properties[$element->elementId() . ':' . $name] = array( + 'title' => t('@plugin "@label" (id @id), @parameter', array( + '@plugin' => drupal_ucfirst($element->plugin()), + '@label' => $element->label(), + '@id' => $element->elementId(), + '@parameter' => $info['label'], + )), + 'string' => $element->settings[$name], + ); + } + } + } + } + +} diff --git a/rules_i18n/rules_i18n.info b/rules_i18n/rules_i18n.info new file mode 100644 index 0000000..588e266 --- /dev/null +++ b/rules_i18n/rules_i18n.info @@ -0,0 +1,15 @@ +name = Rules translation +description = Allows translating rules. +dependencies[] = rules +dependencies[] = i18n_string +package = Multilingual - Internationalization +core = 7.x +files[] = rules_i18n.i18n.inc +files[] = rules_i18n.rules.inc +files[] = rules_i18n.test + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/rules_i18n/rules_i18n.install b/rules_i18n/rules_i18n.install new file mode 100644 index 0000000..7debfd9 --- /dev/null +++ b/rules_i18n/rules_i18n.install @@ -0,0 +1,19 @@ +language; + drupal_static_reset('i18n_object_info'); + drupal_static_reset('entity_get_info'); + drupal_static_reset('entity_i18n_controller'); + cache_clear_all("entity_info:$langcode", 'cache'); +} diff --git a/rules_i18n/rules_i18n.module b/rules_i18n/rules_i18n.module new file mode 100644 index 0000000..e51b7fc --- /dev/null +++ b/rules_i18n/rules_i18n.module @@ -0,0 +1,133 @@ + 'Edit', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -100, + ); + + // I18n generates the menu items for reaction rules. For the others, + // we provide further i18n menu items for all other base paths. + if ($base_path != 'admin/config/workflow/rules/reaction') { + + $items[$base_path . '/manage/%rules_config/translate'] = array( + 'title' => 'Translate', + 'page callback' => 'i18n_page_translate_localize', + 'page arguments' => array('rules_config', $base_count + 1), + 'access callback' => 'i18n_object_translate_access', + 'access arguments' => array('rules_config', $base_count + 1), + 'type' => MENU_LOCAL_TASK, + 'file' => 'i18n.pages.inc', + 'file path' => drupal_get_path('module', 'i18n'), + 'weight' => 10, + ); + + $items[$base_path . '/manage/%rules_config/translate/%i18n_language'] = array( + 'title' => 'Translate', + 'page callback' => 'i18n_page_translate_localize', + 'page arguments' => array('rules_config', $base_count + 1, $base_count + 3), + 'access callback' => 'i18n_object_translate_access', + 'access arguments' => array('rules_config', $base_count), + 'type' => MENU_CALLBACK, + 'file' => 'i18n.pages.inc', + 'file path' => drupal_get_path('module', 'i18n'), + 'weight' => 10, + ); + } +} + +/** + * Implements hook_entity_info_alter(). + */ +function rules_i18n_entity_info_alter(&$info) { + // Enable i18n support via the entity API. + $info['rules_config']['i18n controller class'] = 'RulesI18nStringController'; +} + +/** + * Implements hook_rules_config_insert(). + */ +function rules_i18n_rules_config_insert($rules_config) { + // Do nothing when rebuilding defaults to avoid multiple cache rebuilds. + // @see rules_i18n_rules_config_defaults_rebuild() + if (!empty($rules_config->is_rebuild)) { + return; + } + + i18n_string_object_update('rules_config', $rules_config); +} + +/** + * Implements hook_rules_config_update(). + */ +function rules_i18n_rules_config_update($rules_config, $original = NULL) { + // Do nothing when rebuilding defaults to avoid multiple cache rebuilds. + // @see rules_i18n_rules_config_defaults_rebuild() + if (!empty($rules_config->is_rebuild)) { + return; + } + $original = $original ? $original : $rules_config->original; + + // Account for name changes. + if ($original->name != $rules_config->name) { + i18n_string_update_context("rules:rules_config:{$original->name}:*", "rules:rules_config:{$rules_config->name}:*"); + } + + // We need to remove the strings of any disappeared properties, i.e. strings + // from translatable parameters of deleted actions. + + // i18n_object() uses a static cache per config, so bypass it to wrap the + // original entity. + $object_key = i18n_object_key('rules_config', $original); + $old_i18n_object = new RulesI18nStringObjectWrapper('rules_config', $object_key, $original); + $old_strings = $old_i18n_object->get_strings(array('empty' => TRUE)); + + // Note: For the strings to have updated values, the updated entity needs to + // be handled last due to i18n's cache. + $strings = i18n_object('rules_config', $rules_config)->get_strings(array('empty' => TRUE)); + + foreach (array_diff_key($old_strings, $strings) as $name => $string) { + $string->remove(array('empty' => TRUE)); + } + // Now update the remaining strings. + foreach ($strings as $string) { + $string->update(array('empty' => TRUE, 'update' => TRUE)); + } +} + +/** + * Implements hook_rules_config_delete(). + */ +function rules_i18n_rules_config_delete($rules_config) { + // Only react on real delete, not revert. + if (!$rules_config->hasStatus(ENTITY_IN_CODE)) { + i18n_string_object_remove('rules_config', $rules_config); + } +} + +/** + * Implements hook_rules_config_defaults_rebuild(). + */ +function rules_i18n_rules_config_defaults_rebuild($rules_configs, $originals) { + // Once all defaults have been rebuilt, update all i18n strings at once. That + // way we build the rules cache once the rebuild is complete and avoid + // rebuilding caches for each updated rule. + foreach ($rules_configs as $name => $rule_config) { + if (empty($originals[$name])) { + rules_i18n_rules_config_insert($rule_config); + } + else { + rules_i18n_rules_config_update($rule_config, $originals[$name]); + } + } +} diff --git a/rules_i18n/rules_i18n.rules.inc b/rules_i18n/rules_i18n.rules.inc new file mode 100644 index 0000000..3b70b84 --- /dev/null +++ b/rules_i18n/rules_i18n.rules.inc @@ -0,0 +1,209 @@ + t('Translate a text'), + 'group' => t('Translation'), + 'parameter' => array( + 'text' => array( + 'type' => 'text', + 'label' => t('Text'), + 'description' => t('The text to translate.'), + 'translatable' => TRUE, + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('The language to translate the text into.'), + 'options list' => 'entity_metadata_language_list', + 'default mode' => 'select', + ), + ), + 'provides' => array( + 'text' => array('type' => 'text', 'label' => t('The translated text')), + ), + 'base' => 'rules_i18n_action_t', + 'access callback' => 'rules_i18n_rules_integration_access', + ); + $items['rules_i18n_select'] = array( + 'label' => t('Select a translated value'), + 'group' => t('Translation'), + 'parameter' => array( + 'data' => array( + 'type' => '*', + 'label' => t('Data'), + 'description' => t('Select a translated value, e.g. a translatable field. If the selected data is not translatable, the language neutral value will be selected.'), + 'translatable' => TRUE, + 'restrict' => 'select', + ), + 'language' => array( + 'type' => 'token', + 'label' => t('Language'), + 'description' => t('The language to translate the value into.'), + 'options list' => 'entity_metadata_language_list', + ), + ), + 'provides' => array( + 'data_translated' => array('type' => '*', 'label' => t('The translated value')), + ), + 'base' => 'rules_i18n_action_select', + 'access callback' => 'rules_i18n_rules_integration_access', + ); + return $items; +} + +/** + * Access callback for the rules i18n integration. + */ +function rules_i18n_rules_integration_access() { + return user_access('translate interface'); +} + +/** + * Action callback: Translate a text. + */ +function rules_i18n_action_t($text) { + // Nothing to do, as our input evaluator has already translated it. + // @see RulesI18nStringEvaluator + return array('text' => $text); +} + +/** + * Implements hook_form_FORMID_alter() for the action_t form. + * + * Implements the form_alter callback for the "Translate a text" action + * to set a default selector. + */ +function rules_i18n_action_t_form_alter(&$form, &$form_state, $options, $element) { + if (isset($form['parameter']['language']['settings']['language:select']) && empty($element->settings['language:select'])) { + $form['parameter']['language']['settings']['language:select']['#default_value'] = 'site:current-page:language'; + } +} + +/** + * Action callback: Select a translated value. + */ +function rules_i18n_action_select($data) { + // Nothing to do, as Rules applies the language to the data selector for us. + return array('data_translated' => $data); +} + +/** + * Action "Select a translated value" info_alter callback. + */ +function rules_i18n_action_select_info_alter(&$element_info, $element) { + $element->settings += array('data:select' => NULL); + if ($wrapper = $element->applyDataSelector($element->settings['data:select'])) { + $info = $wrapper->info(); + // Pass through the data type of the selected data. + $element_info['provides']['data_translated']['type'] = $wrapper->type(); + } +} + +/** + * Implements hook_rules_evaluator_info(). + */ +function rules_i18n_rules_evaluator_info() { + return array( + 'i18n' => array( + 'class' => 'RulesI18nStringEvaluator', + 'type' => array('text', 'list', 'token', 'list'), + // Be sure to translate after doing PHP evaluation. + 'weight' => -8, + ), + ); +} + +/** + * A class implementing a rules input evaluator processing tokens. + */ +class RulesI18nStringEvaluator extends RulesDataInputEvaluator { + + /** + * Access callback. + */ + public static function access() { + return user_access('translate admin strings'); + } + + /** + * Overrides RulesDataInputEvaluator::prepare(). + */ + public function prepare($text, $var_info, $param_info = NULL) { + if (!empty($param_info['translatable'])) { + $this->setting = TRUE; + } + else { + // Else, skip this evaluator. + $this->setting = NULL; + } + } + + /** + * Prepare the i18n-context string. + * + * We have to use process() here instead of evaluate() because we need more + * context than evaluate() provides. + */ + public function process($value, $info, RulesState $state, RulesPlugin $element, $options = NULL) { + $options = isset($options) ? $options : $this->getEvaluatorOptions($info, $state, $element); + $value = isset($this->processor) ? $this->processor->process($value, $info, $state, $element, $options) : $value; + if (isset($element->root()->name)) { + $config_name = $element->root()->name; + $id = $element->elementId(); + $name = $info['#name']; + $options['i18n context'] = "rules:rules_config:$config_name:$id:$name"; + return $this->evaluate($value, $options, $state); + } + return $value; + } + + /** + * Translate the value. + * + * If the element provides a language parameter, we are using this target + * language provided via $options['language']. Sanitizing is handled by Rules, + * so disable that for i18n. + */ + public function evaluate($value, $options, RulesState $state) { + $langcode = isset($options['language']) ? $options['language']->language : NULL; + if (is_array($value)) { + foreach ($value as $key => $text) { + $value[$key] = i18n_string($options['i18n context'] . ':' . $key, $text, array('langcode' => $langcode, 'sanitize' => FALSE)); + } + } + else { + $value = i18n_string($options['i18n context'], $value, array('langcode' => $langcode, 'sanitize' => FALSE)); + } + return $value; + } + + /** + * Overrides RulesDataInputEvaluator::help(). + */ + public static function help($var_info, $param_info = array()) { + if (!empty($param_info['translatable'])) { + if (!empty($param_info['custom translation language'])) { + $text = t('Translations can be provided at the %translate tab. The argument value is translated to the configured language.', array('%translate' => t('Translate'))); + } + else { + $text = t('Translations can be provided at the %translate tab. The argument value is translated to the current interface language.', array('%translate' => t('Translate'))); + } + $render = array( + '#theme' => 'rules_settings_help', + '#text' => $text, + '#heading' => t('Translation'), + ); + return $render; + } + } + +} diff --git a/rules_i18n/rules_i18n.test b/rules_i18n/rules_i18n.test new file mode 100644 index 0000000..32b5ef1 --- /dev/null +++ b/rules_i18n/rules_i18n.test @@ -0,0 +1,197 @@ + 'Rules I18n', + 'description' => 'Tests translating Rules configs.', + 'group' => 'Rules', + 'dependencies' => array('i18n_string'), + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules_i18n'); + $this->admin_user = $this->drupalCreateUser(array( + 'bypass node access', + 'administer nodes', + 'administer languages', + 'administer content types', + 'administer blocks', + 'access administration pages', + )); + $this->drupalLogin($this->admin_user); + $this->addLanguage('de'); + } + + /** + * Copied from i18n module (class Drupali18nTestCase). + * + * We cannot extend from Drupali18nTestCase as else the test-bot would die. + */ + public function addLanguage($language_code) { + // Check to make sure that language has not already been installed. + $this->drupalGet('admin/config/regional/language'); + + if (strpos($this->drupalGetContent(), 'enabled[' . $language_code . ']') === FALSE) { + // Doesn't have language installed so add it. + $edit = array(); + $edit['langcode'] = $language_code; + $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language')); + + // Make sure we are not using a stale list. + drupal_static_reset('language_list'); + $languages = language_list('language'); + $this->assertTrue(array_key_exists($language_code, $languages), t('Language was installed successfully.')); + + if (array_key_exists($language_code, $languages)) { + $this->assertRaw(t('The language %language has been created and can now be used. More information is available on the help screen.', array('%language' => $languages[$language_code]->name, '@locale-help' => url('admin/help/locale'))), t('Language has been created.')); + } + } + elseif ($this->xpath('//input[@type="checkbox" and @name=:name and @checked="checked"]', array(':name' => 'enabled[' . $language_code . ']'))) { + // It's installed and enabled. No need to do anything. + $this->assertTrue(TRUE, 'Language [' . $language_code . '] already installed and enabled.'); + } + else { + // It's installed but not enabled. Enable it. + $this->assertTrue(TRUE, 'Language [' . $language_code . '] already installed.'); + $this->drupalPost(NULL, array('enabled[' . $language_code . ']' => TRUE), t('Save configuration')); + $this->assertRaw(t('Configuration saved.'), t('Language successfully enabled.')); + } + } + + /** + * Tests translating rules configs. + */ + public function testRulesConfigTranslation() { + // Create a rule and translate it. + $rule = rule(); + $rule->label = 'label-en'; + $rule->action('drupal_message', array('message' => 'English message for [site:current-user].')); + $rule->save(); + + $actions = $rule->actions(); + $id = $actions[0]->elementId(); + + // Add a translation. + i18n_string_textgroup('rules')->update_translation("rules_config:{$rule->name}:label", 'de', 'label-de'); + i18n_string_textgroup('rules')->update_translation("rules_config:{$rule->name}:$id:message", 'de', 'German message für [site:current-user].'); + + // Execute the Rule and make sure the translated message has been output. + // To do so, set the global language to German. + $languages = language_list(); + $GLOBALS['language'] = $languages['de']; + + // Clear messages and execute the rule. + i18n_string_textgroup('rules')->cache_reset(); + drupal_get_messages(); + $rule->execute(); + + $messages = drupal_get_messages(); + $this->assertEqual($messages['status'][0], 'German message für ' . $GLOBALS['user']->name . '.', 'Translated message has been output.'); + + // Test re-naming the rule. + $rule->name = 'rules_i18n_name_2'; + $rule->save(); + $translation = entity_i18n_string("rules:rules_config:{$rule->name}:label", $rule->label, 'de'); + $this->assertEqual($translation, 'label-de', 'Translation survives a name change.'); + + // Test updating and make sure the translation stays. + $rule->label = 'Label new'; + $rule->save(); + $translation = entity_i18n_string("rules:rules_config:{$rule->name}:label", $rule->label, 'de'); + $this->assertEqual($translation, 'label-de', 'Translation survives an update.'); + + // Test deleting the action and make sure the string is deleted too. + $actions[0]->delete(); + $rule->save(); + $translation = entity_i18n_string("rules_config:{$rule->name}:$id:message", 'English message for [site:current-user].', 'de'); + $this->assertEqual($translation, 'English message for [site:current-user].', 'Translation of deleted action has been deleted.'); + + // Now delete the whole config and make sure all translations are deleted. + $rule->delete(); + $translation = entity_i18n_string("rules_config:{$rule->name}:label", 'label-en', 'de'); + $this->assertEqual($translation, 'label-en', 'Translation of deleted config has been deleted.'); + } + + /** + * Tests the "Translate a text" action. + */ + public function testI18nActionT() { + $set = rules_action_set(array()); + $set->action('rules_i18n_t', array( + 'text' => 'untranslated', + 'language' => 'de', + )); + $set->action('drupal_message', array('message:select' => 'text')); + $set->save('rules_i18n_test'); + + // Add a translation. + $actions = $set->getIterator(); + $id = $actions[0]->elementId(); + i18n_string_textgroup('rules')->update_translation("rules_config:{$set->name}:$id:text", 'de', 'text-de'); + + // Clear messages and execute it. + drupal_get_messages(); + $set->execute(); + $messages = drupal_get_messages(); + $this->assertEqual($messages['status'][0], 'text-de', 'Text has been successfully translated.'); + + // Enable the PHP module and make sure PHP in translations is not evaluated. + module_enable(array('php')); + i18n_string_textgroup('rules')->update_translation("rules_config:{$set->name}:$id:text", 'de', 'text '); + + // Clear messages and execute it. + drupal_get_messages(); + $set->execute(); + $messages = drupal_get_messages(); + $this->assertEqual($messages['status'][0], check_plain('text '), 'PHP in translated text is not executed.'); + } + + /** + * Tests the "Select a translated value" action. + */ + public function testI18nActionSelect() { + // Make the body field and the node type 'page' translatable. + $field = field_info_field('body'); + $field['translatable'] = TRUE; + field_update_field($field); + variable_set('language_content_type_page', 1); + + $set = rules_action_set(array('node' => array('type' => 'node'))); + $set->action('rules_i18n_select', array( + 'data:select' => 'node:body:value', + 'language' => 'de', + 'data_translated:var' => 'body', + )); + $set->action('drupal_message', array('message:select' => 'body')); + $set->save(); + + $body['en'][0] = array('value' => 'English body.'); + $body['de'][0] = array('value' => 'German body.'); + $node = $this->drupalCreateNode(array('language' => 'en', 'body' => $body)); + + // Clear messages and execute it. + drupal_get_messages(); + $set->execute($node); + + $messages = drupal_get_messages(); + $this->assertEqual($messages['status'][0], "German body.\n", 'Translated text has been selected.'); + } + +} diff --git a/rules_scheduler/includes/rules_scheduler.handler.inc b/rules_scheduler/includes/rules_scheduler.handler.inc new file mode 100644 index 0000000..6034157 --- /dev/null +++ b/rules_scheduler/includes/rules_scheduler.handler.inc @@ -0,0 +1,104 @@ +task = $task; + } + + /** + * Implements RulesSchedulerTaskHandlerInterface::runTask(). + */ + public function runTask() { + if ($component = rules_get_cache('comp_' . $this->task['config'])) { + $replacements = array('%label' => $component->label(), '%plugin' => $component->plugin()); + $replacements['%identifier'] = $this->task['identifier'] ? $this->task['identifier'] : t('without identifier'); + rules_log('Scheduled evaluation of %plugin %label, task %identifier.', $replacements, RulesLog::INFO, $component, TRUE); + $state = unserialize($this->task['data']); + $state->restoreBlocks(); + // Block the config to prevent any future recursion. + $state->block($component); + // Finally evaluate the component with the given state. + $component->evaluate($state); + $state->unblock($component); + rules_log('Finished evaluation of %plugin %label, task %identifier.', $replacements, RulesLog::INFO, $component, FALSE); + $state->cleanUp(); + } + } + + /** + * Implements RulesSchedulerTaskHandlerInterface::afterTaskQueued(). + */ + public function afterTaskQueued() { + // Delete the task from the task list. + db_delete('rules_scheduler') + ->condition('tid', $this->task['tid']) + ->execute(); + } + + /** + * Implements RulesSchedulerTaskHandlerInterface::getTask(). + */ + public function getTask() { + return $this->task; + } + +} + +/** + * Interface for scheduled task handlers. + * + * Task handlers control the behavior of a task when it's queued or executed. + * Unless specified otherwise, the RulesSchedulerDefaultTaskHandler task handler + * is used. + * + * @see rules_scheduler_run_task() + * @see rules_scheduler_cron() + * @see RulesSchedulerDefaultTaskHandler + */ +interface RulesSchedulerTaskHandlerInterface { + + /** + * Processes a queue item. + * + * @throws RulesEvaluationException + * If there are any problems executing the task. + * + * @see rules_scheduler_run_task() + */ + public function runTask(); + + /** + * Processes a task after it has been queued. + * + * @see rules_scheduler_cron() + */ + public function afterTaskQueued(); + + /** + * Returns the task associated with the task handler. + * + * @return array + * The task (queue item) array. + */ + public function getTask(); + +} diff --git a/rules_scheduler/includes/rules_scheduler.views.inc b/rules_scheduler/includes/rules_scheduler.views.inc new file mode 100644 index 0000000..2820d45 --- /dev/null +++ b/rules_scheduler/includes/rules_scheduler.views.inc @@ -0,0 +1,84 @@ + array( + 'table' => array( + 'group' => 'Rules scheduler', + 'base' => array( + 'field' => 'tid', + 'title' => t('Scheduled Rules components'), + 'help' => t("Scheduled Rules components that are executed based on time and cron"), + 'weight' => -10, + ), + ), + 'tid' => array( + 'title' => t('Tid'), + 'help' => t('The internal ID of the scheduled component'), + 'field' => array( + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_numeric', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + ), + ), + 'config' => array( + 'title' => t('Component name'), + 'help' => t('The name of the component'), + 'field' => array( + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'rules_scheduler_views_filter', + ), + 'argument' => array( + 'handler' => 'views_handler_argument_string', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + ), + ), + 'date' => array( + 'title' => t('Scheduled date'), + 'help' => t('Scheduled date and time stamp'), + 'field' => array( + 'handler' => 'views_handler_field_date', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + ), + ), + 'identifier' => array( + 'title' => t('User provided identifier'), + 'help' => t('ID to recognize this specific scheduled task'), + 'field' => array( + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_string', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + ), + ), + ), + ); + return $table; +} diff --git a/rules_scheduler/includes/rules_scheduler.views_default.inc b/rules_scheduler/includes/rules_scheduler.views_default.inc new file mode 100644 index 0000000..708dd63 --- /dev/null +++ b/rules_scheduler/includes/rules_scheduler.views_default.inc @@ -0,0 +1,180 @@ +name = 'rules_scheduler'; + $view->description = 'Scheduled Rules components'; + $view->tag = ''; + $view->base_table = 'rules_scheduler'; + $view->api_version = '3.0-alpha1'; + $view->disabled = FALSE; /* Edit this to true to make a default view disabled initially */ + + /* Display: Defaults */ + $handler = $view->new_display('default', 'Defaults', 'default'); + $handler->display->display_options['access']['type'] = 'perm'; + $handler->display->display_options['access']['perm'] = 'administer rules'; + $handler->display->display_options['cache']['type'] = 'none'; + $handler->display->display_options['query']['type'] = 'views_query'; + $handler->display->display_options['exposed_form']['type'] = 'basic'; + $handler->display->display_options['pager']['type'] = 'full'; + $handler->display->display_options['pager']['options']['items_per_page'] = '30'; + $handler->display->display_options['pager']['options']['offset'] = '0'; + $handler->display->display_options['pager']['options']['id'] = '0'; + $handler->display->display_options['style_plugin'] = 'table'; + $handler->display->display_options['style_options']['columns'] = array( + 'tid' => 'tid', + 'config' => 'config', + 'date' => 'date', + 'identifier' => 'identifier', + 'nothing' => 'nothing', + ); + $handler->display->display_options['style_options']['default'] = 'date'; + $handler->display->display_options['style_options']['info'] = array( + 'tid' => array( + 'sortable' => 0, + 'default_sort_order' => 'asc', + 'align' => '', + 'separator' => '', + ), + 'config' => array( + 'sortable' => 1, + 'default_sort_order' => 'asc', + 'align' => '', + 'separator' => '', + ), + 'date' => array( + 'sortable' => 1, + 'default_sort_order' => 'asc', + 'align' => '', + 'separator' => '', + ), + 'identifier' => array( + 'sortable' => 1, + 'default_sort_order' => 'asc', + 'align' => '', + 'separator' => '', + ), + 'nothing' => array( + 'align' => '', + 'separator' => '', + ), + ); + $handler->display->display_options['style_options']['override'] = 1; + $handler->display->display_options['style_options']['sticky'] = 0; + /* Empty text: Global: Text area */ + $handler->display->display_options['empty']['area']['id'] = 'area'; + $handler->display->display_options['empty']['area']['table'] = 'views'; + $handler->display->display_options['empty']['area']['field'] = 'area'; + $handler->display->display_options['empty']['area']['empty'] = FALSE; + $handler->display->display_options['empty']['area']['content'] = 'No tasks have been scheduled.'; + $handler->display->display_options['empty']['area']['format'] = 'plain_text'; + /* Field: Rules scheduler: Tid */ + $handler->display->display_options['fields']['tid']['id'] = 'tid'; + $handler->display->display_options['fields']['tid']['table'] = 'rules_scheduler'; + $handler->display->display_options['fields']['tid']['field'] = 'tid'; + /* Field: Rules scheduler: Component name */ + $handler->display->display_options['fields']['config']['id'] = 'config'; + $handler->display->display_options['fields']['config']['table'] = 'rules_scheduler'; + $handler->display->display_options['fields']['config']['field'] = 'config'; + $handler->display->display_options['fields']['config']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['config']['alter']['make_link'] = 1; + $handler->display->display_options['fields']['config']['alter']['path'] = 'admin/config/workflow/rules/components/manage/[config]'; + $handler->display->display_options['fields']['config']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['config']['alter']['trim'] = 0; + $handler->display->display_options['fields']['config']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['config']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['config']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['config']['alter']['html'] = 0; + $handler->display->display_options['fields']['config']['element_label_colon'] = 1; + $handler->display->display_options['fields']['config']['element_default_classes'] = 1; + $handler->display->display_options['fields']['config']['hide_empty'] = 0; + $handler->display->display_options['fields']['config']['empty_zero'] = 0; + /* Field: Rules scheduler: Scheduled date */ + $handler->display->display_options['fields']['date']['id'] = 'date'; + $handler->display->display_options['fields']['date']['table'] = 'rules_scheduler'; + $handler->display->display_options['fields']['date']['field'] = 'date'; + /* Field: Rules scheduler: User provided identifier */ + $handler->display->display_options['fields']['identifier']['id'] = 'identifier'; + $handler->display->display_options['fields']['identifier']['table'] = 'rules_scheduler'; + $handler->display->display_options['fields']['identifier']['field'] = 'identifier'; + /* Field: Global: Custom text */ + $handler->display->display_options['fields']['nothing']['id'] = 'nothing'; + $handler->display->display_options['fields']['nothing']['table'] = 'views'; + $handler->display->display_options['fields']['nothing']['field'] = 'nothing'; + $handler->display->display_options['fields']['nothing']['label'] = 'Operations'; + $handler->display->display_options['fields']['nothing']['alter']['text'] = 'delete'; + $handler->display->display_options['fields']['nothing']['alter']['make_link'] = 1; + $handler->display->display_options['fields']['nothing']['alter']['path'] = 'admin/config/workflow/rules/schedule/[tid]/delete'; + $handler->display->display_options['fields']['nothing']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['nothing']['alter']['alt'] = 'Delete this scheduled task'; + $handler->display->display_options['fields']['nothing']['alter']['trim'] = 0; + $handler->display->display_options['fields']['nothing']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['nothing']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['nothing']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['nothing']['alter']['html'] = 0; + $handler->display->display_options['fields']['nothing']['element_label_colon'] = 1; + $handler->display->display_options['fields']['nothing']['element_default_classes'] = 1; + $handler->display->display_options['fields']['nothing']['hide_empty'] = 0; + $handler->display->display_options['fields']['nothing']['empty_zero'] = 0; + /* Sort criterion: Rules scheduler: Scheduled date */ + $handler->display->display_options['sorts']['date']['id'] = 'date'; + $handler->display->display_options['sorts']['date']['table'] = 'rules_scheduler'; + $handler->display->display_options['sorts']['date']['field'] = 'date'; + /* Argument: Rules scheduler: Component name */ + $handler->display->display_options['arguments']['config']['id'] = 'config'; + $handler->display->display_options['arguments']['config']['table'] = 'rules_scheduler'; + $handler->display->display_options['arguments']['config']['field'] = 'config'; + $handler->display->display_options['arguments']['config']['style_plugin'] = 'default_summary'; + $handler->display->display_options['arguments']['config']['wildcard'] = '0'; + $handler->display->display_options['arguments']['config']['default_argument_type'] = 'fixed'; + $handler->display->display_options['arguments']['config']['glossary'] = 0; + $handler->display->display_options['arguments']['config']['limit'] = '0'; + $handler->display->display_options['arguments']['config']['transform_dash'] = 0; + /* Filter: Rules scheduler: Component name */ + $handler->display->display_options['filters']['config']['id'] = 'config'; + $handler->display->display_options['filters']['config']['table'] = 'rules_scheduler'; + $handler->display->display_options['filters']['config']['field'] = 'config'; + $handler->display->display_options['filters']['config']['exposed'] = TRUE; + $handler->display->display_options['filters']['config']['expose']['operator'] = 'config_op'; + $handler->display->display_options['filters']['config']['expose']['label'] = 'Component filter'; + $handler->display->display_options['filters']['config']['expose']['identifier'] = 'config'; + $handler->display->display_options['filters']['config']['expose']['remember'] = 1; + $handler->display->display_options['filters']['config']['expose']['use_operator'] = 0; + $handler->display->display_options['filters']['config']['expose']['reduce'] = 0; + $translatables['rules_scheduler'] = array( + t('Defaults'), + t('more'), + t('Apply'), + t('Reset'), + t('Sort By'), + t('Asc'), + t('Desc'), + t('Items per page'), + t('- All -'), + t('Offset'), + t('No tasks have been scheduled.'), + t('Tid'), + t('Component name'), + t('admin/config/workflow/rules/components/manage/[config]'), + t('Scheduled date'), + t('User provided identifier'), + t('Operations'), + t('delete'), + t('admin/config/workflow/rules/schedule/[tid]/delete'), + t('Delete this scheduled task'), + t('All'), + t('Component filter'), + ); + + $views = array(); + $views[$view->name] = $view; + return $views; +} diff --git a/rules_scheduler/includes/rules_scheduler_views_filter.inc b/rules_scheduler/includes/rules_scheduler_views_filter.inc new file mode 100644 index 0000000..5994d83 --- /dev/null +++ b/rules_scheduler/includes/rules_scheduler_views_filter.inc @@ -0,0 +1,25 @@ +value_options)) { + $this->value_title = t('Component'); + $result = db_select('rules_scheduler', 'r') + ->fields('r', array('config')) + ->distinct() + ->execute(); + $config_names = array(); + foreach ($result as $record) { + $config_names[$record->config] = $record->config; + } + $this->value_options = $config_names; + } + } + +} diff --git a/rules_scheduler/rules_scheduler.admin.inc b/rules_scheduler/rules_scheduler.admin.inc new file mode 100644 index 0000000..dfc3618 --- /dev/null +++ b/rules_scheduler/rules_scheduler.admin.inc @@ -0,0 +1,139 @@ +override_path = RULES_SCHEDULER_PATH; + $task_list = $view->preview(); + } + else { + $task_list = t('To display scheduled tasks you have to install the Views module.'); + } + $page['task_view'] = array( + '#markup' => $task_list, + ); + $form = drupal_get_form('rules_scheduler_form'); + $page['delete'] = array( + '#markup' => drupal_render($form), + ); + return $page; +} + +/** + * Form for deletion of tasks by component. + */ +function rules_scheduler_form($form, &$form_state) { + $result = db_select('rules_scheduler', 'r') + ->fields('r', array('config')) + ->distinct() + ->execute(); + $config_options = array_intersect_key(rules_get_components(TRUE), $result->fetchAllAssoc('config')); + + // Fieldset for canceling by component name. + $form['delete_by_config'] = array( + '#type' => 'fieldset', + '#title' => t('Delete tasks by component name'), + '#disabled' => empty($config_options), + ); + $form['delete_by_config']['config'] = array( + '#title' => t('Component'), + '#type' => 'select', + '#options' => $config_options, + '#description' => t('Select the component for which to delete all scheduled tasks.'), + '#required' => TRUE, + ); + $form['delete_by_config']['submit'] = array( + '#type' => 'submit', + '#value' => t('Delete tasks'), + '#submit' => array('rules_scheduler_form_delete_by_config_submit'), + ); + return $form; +} + +/** + * Submit handler for deleting future scheduled tasks. + */ +function rules_scheduler_form_delete_by_config_submit($form, &$form_state) { + $config = rules_config_load($form_state['values']['config']); + rules_action('schedule_delete')->execute($config->name); + drupal_set_message(t('All scheduled tasks associated with %config have been deleted.', array('%config' => $config->label()))); + $form_state['redirect'] = RULES_SCHEDULER_PATH; +} + +/** + * Confirmation form for deleting single tasks. + */ +function rules_scheduler_delete_task($form, &$form_state, $task) { + $form_state['task'] = $task; + $config = rules_config_load($task['config']); + $path['path'] = isset($_GET['destination']) ? $_GET['destination'] : RULES_SCHEDULER_PATH; + + $title = t('Are you sure you want to delete the scheduled task %id?', array('%id' => $task['tid'])); + if (!empty($task['identifier'])) { + $msg = t('This task with the custom identifier %id executes component %label on %date. The action cannot be undone.', array( + '%label' => $config->label(), + '%id' => $task['identifier'], + '%date' => format_date($task['date']), + )); + } + else { + $msg = t('This task executes component %label and will be executed on %date. The action cannot be undone.', array( + '%label' => $config->label(), + '%date' => format_date($task['date']), + )); + } + return confirm_form($form, $title, $path, $msg, t('Delete'), t('Cancel')); +} + +/** + * Submit handler for deleting single tasks. + */ +function rules_scheduler_delete_task_submit($form, &$form_state) { + rules_scheduler_task_delete($form_state['task']['tid']); + drupal_set_message(t('Task %tid has been deleted.', array('%tid' => $form_state['task']['tid']))); + $form_state['redirect'] = RULES_SCHEDULER_PATH; +} + +/** + * Configuration form to manually schedule a rules component. + */ +function rules_scheduler_schedule_form($form, &$form_state, $rules_config, $base_path) { + // Only components can be scheduled. + if (!($rules_config instanceof RulesTriggerableInterface)) { + RulesPluginUI::$basePath = $base_path; + $form_state['component'] = $rules_config->name; + $action = rules_action('schedule', array('component' => $rules_config->name)); + $action->form($form, $form_state); + // The component should be fixed, so hide the parameter for it. + $form['parameter']['component']['#access'] = FALSE; + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Schedule'), + ); + $form['#validate'] = array('rules_ui_form_rules_config_validate'); + return $form; + } + drupal_not_found(); + exit; +} + +/** + * Submit callback to execute the scheduling action. + */ +function rules_scheduler_schedule_form_submit($form, &$form_state) { + $action = $form_state['rules_element']; + $action->execute(); + drupal_set_message(t('Component %label has been scheduled.', array('%label' => rules_config_load($form_state['component'])->label()))); + $form_state['redirect'] = RULES_SCHEDULER_PATH; +} diff --git a/rules_scheduler/rules_scheduler.drush.inc b/rules_scheduler/rules_scheduler.drush.inc new file mode 100644 index 0000000..a6ed795 --- /dev/null +++ b/rules_scheduler/rules_scheduler.drush.inc @@ -0,0 +1,81 @@ + 'Check for scheduled tasks to be added to the queue.', + 'options' => array( + 'claim' => 'Optionally claim tasks from the queue to work on. Any value set will override the default time spent on this queue.', + ), + 'drupal dependencies' => array('rules', 'rules_scheduler'), + 'aliases' => array('rusch'), + 'examples' => array( + 'drush rusch' => 'Add scheduled tasks to the queue.', + 'drush rusch --claim' => 'Add scheduled tasks to the queue and claim items for the default amount of time.', + 'drush rusch --claim=30' => 'Add scheduled tasks to the queue and claim items for 30 seconds.', + ), + ); + + return $items; +} + +/** + * Implements hook_drush_help(). + */ +function rules_scheduler_drush_help($section) { + switch ($section) { + case 'drush:rules-scheduler-tasks': + return dt('Checks for scheduled tasks to be added the queue. Can optionally claim tasks from the queue to work on.'); + } +} + +/** + * Command callback for processing the rules_scheduler_tasks queue. + * + * @see rules_scheduler_cron_queue_info() + * @see rules_scheduler_cron() + */ +function drush_rules_scheduler_tasks() { + if (rules_scheduler_queue_tasks()) { + // hook_exit() is not invoked for drush runs, so register it as shutdown + // callback for logging the rules log to the watchdog. + drupal_register_shutdown_function('rules_exit'); + // Clear the log before running tasks via the queue to avoid logging + // unrelated logs from previous operations. + RulesLog::logger()->clear(); + drush_log(dt('Added scheduled tasks to the queue.'), 'success'); + } + + $claim = drush_get_option('claim', FALSE); + if ($claim) { + // Fetch the queue information and let other modules alter it. + $queue_name = 'rules_scheduler_tasks'; + $info = module_invoke('rules_scheduler', 'cron_queue_info'); + drupal_alter('cron_queue_info', $info); + + $function = $info[$queue_name]['worker callback']; + // The drush option can override the default process time. + $time = is_numeric($claim) ? (int) $claim : $info[$queue_name]['time']; + $end = time() + $time; + // Claim items and process the queue. + $queue = DrupalQueue::get($queue_name); + $claimed = 0; + while (time() < $end && ($item = $queue->claimItem())) { + $function($item->data); + $queue->deleteItem($item); + $claimed++; + } + if ($claimed) { + drush_log(dt('Claimed and worked on !claimed scheduled tasks for up to !time seconds.', array('!claimed' => $claimed, '!time' => $time)), 'success'); + } + } +} diff --git a/rules_scheduler/rules_scheduler.info b/rules_scheduler/rules_scheduler.info new file mode 100644 index 0000000..ff11146 --- /dev/null +++ b/rules_scheduler/rules_scheduler.info @@ -0,0 +1,19 @@ +name = Rules Scheduler +description = Schedule the execution of Rules components using actions. +dependencies[] = rules +package = Rules +core = 7.x +files[] = includes/rules_scheduler.handler.inc + +; Views handlers +files[] = includes/rules_scheduler_views_filter.inc + +; Test cases +files[] = tests/rules_scheduler.test +files[] = tests/rules_scheduler_test.inc + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/rules_scheduler/rules_scheduler.install b/rules_scheduler/rules_scheduler.install new file mode 100644 index 0000000..0aa8143 --- /dev/null +++ b/rules_scheduler/rules_scheduler.install @@ -0,0 +1,225 @@ + 'Stores scheduled tasks.', + 'fields' => array( + 'tid' => array( + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => "The scheduled task's id.", + ), + 'config' => array( + 'type' => 'varchar', + 'length' => '64', + 'default' => '', + 'not null' => TRUE, + 'description' => "The scheduled configuration's name.", + ), + 'date' => array( + 'description' => 'The Unix timestamp of when the task is to be scheduled.', + 'type' => 'int', + 'not null' => TRUE, + ), + 'data' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'The whole, serialized evaluation data.', + ), + 'identifier' => array( + 'type' => 'varchar', + 'length' => '255', + 'default' => '', + 'not null' => FALSE, + 'description' => 'The user defined string identifying this task.', + ), + 'handler' => array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => FALSE, + 'description' => 'The fully-qualified class name of the queue item handler.', + ), + ), + 'primary key' => array('tid'), + 'indexes' => array( + 'date' => array('date'), + ), + 'unique key' => array( + 'id' => array('config', 'identifier'), + ), + ); + return $schema; +} + +/** + * Implements hook_install(). + */ +function rules_scheduler_install() { + // Create the queue to hold scheduled tasks. + $queue = DrupalQueue::get('rules_scheduler_tasks', TRUE); + $queue->createQueue(); +} + +/** + * Implements hook_uninstall(). + */ +function rules_scheduler_uninstall() { + // Clean up after ourselves by deleting the queue and all items in it. + $queue = DrupalQueue::get('rules_scheduler_tasks'); + $queue->deleteQueue(); +} + +/** + * Upgrade from Rules scheduler 6.x-1.x to 7.x. + */ +function rules_scheduler_update_7200() { + // Rename the old table so we can keep its content and start over with a + // fresh one. + db_rename_table('rules_scheduler', 'rules_scheduler_d6'); + // Create the d7 table. + $schema['rules_scheduler'] = array( + 'description' => 'Stores scheduled tasks.', + 'fields' => array( + 'tid' => array( + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => "The scheduled task's id.", + ), + 'config' => array( + 'type' => 'varchar', + 'length' => '255', + 'default' => '', + 'not null' => TRUE, + 'description' => "The scheduled configuration's name.", + ), + 'date' => array( + 'description' => 'The Unix timestamp of when the task is to be scheduled.', + 'type' => 'int', + 'not null' => TRUE, + ), + 'data' => array( + 'type' => 'text', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'The whole, serialized evaluation data.', + ), + 'identifier' => array( + 'type' => 'varchar', + 'length' => '255', + 'default' => '', + 'not null' => FALSE, + 'description' => 'The user defined string identifying this task.', + ), + ), + 'primary key' => array('tid'), + 'indexes' => array('date' => array('date')), + ); + db_create_table('rules_scheduler', $schema['rules_scheduler']); +} + +/** + * Fix the length of the rules_scheduler.name column. + */ +function rules_scheduler_update_7202() { + // Note that update 7201 (add the 'id' unique key') has been removed as it is + // incorporated by 7202. For anyone that has already run the previous update + // 7201, we have to first drop the unique key. + db_drop_unique_key('rules_scheduler', 'id'); + db_change_field('rules_scheduler', 'config', 'config', array( + 'type' => 'varchar', + 'length' => '64', + 'default' => '', + 'not null' => TRUE, + 'description' => "The scheduled configuration's name.", + )); + db_add_unique_key('rules_scheduler', 'id', array('config', 'identifier')); +} + +/** + * Add a database column for specifying a queue item handler. + */ +function rules_scheduler_update_7203() { + db_add_field('rules_scheduler', 'handler', array( + 'type' => 'varchar', + 'length' => '255', + 'not null' => FALSE, + 'description' => 'The fully-qualified class name of the queue item handler.', + )); +} + +/** + * Rename rules_scheduler.state into rules_scheduler.data. + */ +function rules_scheduler_update_7204() { + if (db_field_exists('rules_scheduler', 'state')) { + db_change_field('rules_scheduler', 'state', 'data', array( + 'type' => 'text', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'The whole, serialized evaluation data.', + )); + } +} + +/** + * Use blob:big for rules_scheduler.data for compatibility with PostgreSQL. + */ +function rules_scheduler_update_7205() { + if (db_field_exists('rules_scheduler', 'data')) { + db_change_field('rules_scheduler', 'data', 'data', array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + 'description' => 'The whole, serialized evaluation data.', + )); + } +} + +/** + * Rules upgrade callback for mapping the action name. + */ +function rules_scheduler_action_upgrade_map_name($element) { + return 'schedule'; +} + +/** + * Rules upgrade callback. + */ +function rules_scheduler_action_upgrade($element, $target) { + $target->settings['component'] = $element['#info']['set']; + $target->settings['date'] = $element['#settings']['task_date']; + $target->settings['identifier'] = $element['#settings']['task_identifier']; + + unset($element['#info']['arguments']['task_date'], $element['#info']['arguments']['task_identifier']); + foreach ($element['#info']['arguments'] as $name => $info) { + rules_upgrade_element_parameter_settings($element, $target, $name, $info, 'param_' . $name); + } +} + +/** + * Rules upgrade callback for mapping the action name. + */ +function rules_action_delete_scheduled_set_upgrade_map_name($element) { + return 'schedule_delete'; +} + +/** + * Rules upgrade callback. + */ +function rules_action_delete_scheduled_set_upgrade($element, $target) { + $target->settings['component'] = $element['#settings']['ruleset']; + $target->settings['task'] = $element['#settings']['task_identifier']; +} diff --git a/rules_scheduler/rules_scheduler.module b/rules_scheduler/rules_scheduler.module new file mode 100644 index 0000000..1130c64 --- /dev/null +++ b/rules_scheduler/rules_scheduler.module @@ -0,0 +1,220 @@ +clear(); + } +} + +/** + * Implements hook_cron_queue_info(). + */ +function rules_scheduler_cron_queue_info() { + $queues['rules_scheduler_tasks'] = array( + 'worker callback' => 'rules_scheduler_run_task', + 'time' => 15, + ); + return $queues; +} + +/** + * Queue worker callback for running a single task. + * + * @param array $task + * The task to process. + */ +function rules_scheduler_run_task(array $task) { + try { + // BC support for tasks that have been already queued, before update + // rules_scheduler_update_7204() ran. + if (isset($task['state'])) { + $task['data'] = $task['state']; + } + rules_scheduler_task_handler($task)->runTask(); + } + catch (RulesEvaluationException $e) { + rules_log($e->msg, $e->args, $e->severity); + rules_log('Unable to execute task with identifier %id scheduled on date %date.', array('%id' => $task['identifier'], '%date' => format_date($task['date'])), RulesLog::ERROR); + } +} + +/** + * Returns the task handler for a given task. + * + * @param array $task + * A task (queue item) array. + * + * @throws RulesEvaluationException + * If the task handler class is missing. + * + * @return RulesSchedulerTaskHandlerInterface + * The task handler. + */ +function rules_scheduler_task_handler(array $task) { + $class = !empty($task['handler']) ? $task['handler'] : 'RulesSchedulerDefaultTaskHandler'; + if (!class_exists($class)) { + throw new RulesEvaluationException('Missing task handler implementation %class.', array('%class' => $class), NULL, RulesLog::ERROR); + } + return new $class($task); +} + +/** + * Implements hook_rules_ui_menu_alter(). + * + * Adds a menu item for the 'schedule' operation. + */ +function rules_scheduler_rules_ui_menu_alter(&$items, $base_path, $base_count) { + $items[$base_path . '/manage/%rules_config/schedule'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Schedule !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_scheduler_schedule_form', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'rules_scheduler.admin.inc', + 'file path' => drupal_get_path('module', 'rules_scheduler'), + ); +} + +/** + * Implements hook_menu(). + */ +function rules_scheduler_menu() { + $items = array(); + $items[RULES_SCHEDULER_PATH] = array( + 'title' => 'Schedule', + 'type' => MENU_LOCAL_TASK, + 'page callback' => 'rules_scheduler_schedule_page', + 'access arguments' => array('administer rules'), + 'file' => 'rules_scheduler.admin.inc', + ); + $items[RULES_SCHEDULER_PATH . '/%rules_scheduler_task/delete'] = array( + 'title' => 'Delete a scheduled task', + 'type' => MENU_CALLBACK, + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_scheduler_delete_task', 5), + 'access arguments' => array('administer rules'), + 'file' => 'rules_scheduler.admin.inc', + ); + return $items; +} + +/** + * Loads a task by a given task ID. + * + * @param int $tid + * The task ID. + */ +function rules_scheduler_task_load($tid) { + $result = db_select('rules_scheduler', 'r') + ->fields('r') + ->condition('tid', (int) $tid) + ->execute(); + return $result->fetchAssoc(); +} + +/** + * Deletes a task by a given task ID. + * + * @param int $tid + * The task ID. + */ +function rules_scheduler_task_delete($tid) { + db_delete('rules_scheduler') + ->condition('tid', $tid) + ->execute(); +} + +/** + * Schedule a task to be executed later on. + * + * @param array $task + * An array representing the task with the following keys: + * - config: The machine readable name of the to-be-scheduled component. + * - date: Timestamp when the component should be executed. + * - state: (deprecated) Rules evaluation state to use for scheduling. + * - data: Any additional data to store with the task. + * - handler: The name of the task handler class. + * - identifier: User provided string to identify the task per scheduled + * configuration. + */ +function rules_scheduler_schedule_task($task) { + // Map the deprecated 'state' property into 'data'. + if (isset($task['state'])) { + $task['data'] = $task['state']; + unset($task['state']); + } + if (!empty($task['identifier'])) { + // If there is a task with the same identifier and component, we replace it. + db_delete('rules_scheduler') + ->condition('config', $task['config']) + ->condition('identifier', $task['identifier']) + ->execute(); + } + drupal_write_record('rules_scheduler', $task); +} + +/** + * Queue tasks that are ready for execution. + * + * @return bool + * TRUE if any queue items were created, otherwise FALSE. + */ +function rules_scheduler_queue_tasks() { + $items_created = FALSE; + // Limit adding tasks to 1000 per cron run. + $result = db_select('rules_scheduler', 'r', array('fetch' => PDO::FETCH_ASSOC)) + ->fields('r') + ->condition('date', time(), '<=') + ->orderBy('date') + ->range(0, 1000) + ->execute(); + + $queue = DrupalQueue::get('rules_scheduler_tasks'); + foreach ($result as $task) { + // Add the task to the queue and remove the entry afterwards. + if ($queue->createItem($task)) { + $items_created = TRUE; + rules_scheduler_task_handler($task)->afterTaskQueued(); + } + } + return $items_created; +} + +/** + * Implements hook_rules_config_delete(). + */ +function rules_scheduler_rules_config_delete($rules_config) { + // Only react on real delete, not revert. + if (!$rules_config->hasStatus(ENTITY_IN_CODE)) { + // Delete all tasks scheduled for this config. + db_delete('rules_scheduler') + ->condition('config', $rules_config->name) + ->execute(); + } +} + +/** + * Implements hook_views_api(). + */ +function rules_scheduler_views_api() { + return array( + 'api' => '3.0-alpha1', + 'path' => drupal_get_path('module', 'rules_scheduler') . '/includes', + ); +} diff --git a/rules_scheduler/rules_scheduler.rules.inc b/rules_scheduler/rules_scheduler.rules.inc new file mode 100644 index 0000000..aabbf26 --- /dev/null +++ b/rules_scheduler/rules_scheduler.rules.inc @@ -0,0 +1,217 @@ + t('Schedule component evaluation'), + 'group' => t('Rules scheduler'), + 'base' => 'rules_scheduler_action_schedule', + 'named parameter' => TRUE, + 'parameter' => array( + 'component' => array( + 'type' => 'text', + 'label' => t('Component'), + 'options list' => 'rules_scheduler_component_options_list', + 'restriction' => 'input', + 'description' => 'Select the component to schedule. Only components containing actions are available – no condition sets.', + ), + 'date' => array( + 'type' => 'date', + 'label' => t('Scheduled evaluation date'), + ), + 'identifier' => array( + 'type' => 'text', + 'label' => t('Identifier'), + 'description' => t('A string used for identifying this task. Any existing tasks for this component with the same identifier will be replaced.'), + 'optional' => TRUE, + ), + // Further needed parameter by the component are added during processing. + ), + ); + // Add action to delete scheduled tasks. + $items['schedule_delete'] = array( + 'label' => t('Delete scheduled tasks'), + 'group' => t('Rules scheduler'), + 'base' => 'rules_scheduler_action_delete', + 'parameter' => array( + 'component' => array( + 'type' => 'text', + 'label' => t('Component'), + 'options list' => 'rules_scheduler_component_options_list', + 'description' => t('The component for which scheduled tasks will be deleted.'), + 'optional' => TRUE, + ), + 'task' => array( + 'type' => 'text', + 'label' => t('Task identifier'), + 'description' => t('All tasks that are annotated with the given identifier will be deleted.'), + 'optional' => TRUE, + ), + ), + ); + return $items; +} + +/** + * Options list callback returning a list of action components. + */ +function rules_scheduler_component_options_list() { + return rules_get_components(TRUE, 'action'); +} + +/** + * Base action implementation for scheduling components. + */ +function rules_scheduler_action_schedule($args, $element) { + $state = $args['state']; + if ($component = rules_get_cache('comp_' . $args['component'])) { + // Manually create a new evaluation state for scheduling the evaluation. + $new_state = new RulesState(); + + // Register all parameters as variables. + foreach ($element->pluginParameterInfo() as $name => $info) { + if (strpos($name, 'param_') === 0) { + // Remove the parameter name prefix 'param_'. + $var_name = substr($name, 6); + $new_state->addVariable($var_name, $state->currentArguments[$name], $info); + } + } + rules_scheduler_schedule_task(array( + 'date' => $args['date'], + 'config' => $args['component'], + 'data' => $new_state, + 'identifier' => $args['identifier'], + )); + } + else { + throw new RulesEvaluationException('Unable to get the component %name', array('%name' => $args['component']), $element, RulesLog::ERROR); + } +} + +/** + * Info alteration callback for the schedule action. + */ +function rules_scheduler_action_schedule_info_alter(&$element_info, RulesPlugin $element) { + if (isset($element->settings['component'])) { + // If run during a cache rebuild the cache might not be instantiated yet, + // so fail back to loading the component from database. + if (($component = rules_get_cache('comp_' . $element->settings['component'])) || $component = rules_config_load($element->settings['component'])) { + // Add in the needed parameters. + foreach ($component->parameterInfo() as $name => $info) { + $element_info['parameter']['param_' . $name] = $info; + } + } + } +} + +/** + * Validate callback for the schedule action. + * + * Makes sure the component exists and is not dirty. + * + * @see rules_element_invoke_component_validate() + */ +function rules_scheduler_action_schedule_validate(RulesPlugin $element) { + $info = $element->info(); + $component = rules_config_load($element->settings['component']); + if (!$component) { + throw new RulesIntegrityException(t('The component %config does not exist.', array('%config' => $element->settings['component'])), $element); + } + // Check if the component is marked as dirty. + rules_config_update_dirty_flag($component); + if (!empty($component->dirty)) { + throw new RulesIntegrityException(t('The utilized component %config fails the integrity check.', array('%config' => $element->settings['component'])), $element); + } +} + +/** + * Help for the schedule action. + */ +function rules_scheduler_action_schedule_help() { + return t("Note that component evaluation is triggered by cron – make sure cron is configured correctly by checking your site's !status. The scheduling time accuracy depends on your configured cron interval. See the online documentation for more information on how to schedule evaluation of components.", + array('!status' => l(t('Status report'), 'admin/reports/status'), + '@url' => rules_external_help('scheduler'))); +} + +/** + * Form alter callback for the schedule action. + */ +function rules_scheduler_action_schedule_form_alter(&$form, &$form_state, $options, RulesAbstractPlugin $element) { + $first_step = empty($element->settings['component']); + $form['reload'] = array( + '#weight' => 5, + '#type' => 'submit', + '#name' => 'reload', + '#value' => $first_step ? t('Continue') : t('Reload form'), + '#limit_validation_errors' => array(array('parameter', 'component')), + '#submit' => array('rules_action_type_form_submit_rebuild'), + '#ajax' => rules_ui_form_default_ajax(), + ); + // Use ajax and trigger as the reload button. + $form['parameter']['component']['settings']['type']['#ajax'] = $form['reload']['#ajax'] + array( + 'event' => 'change', + 'trigger_as' => array('name' => 'reload'), + ); + + if ($first_step) { + // In the first step show only the component select. + foreach (element_children($form['parameter']) as $key) { + if ($key != 'component') { + unset($form['parameter'][$key]); + } + } + unset($form['submit']); + unset($form['provides']); + } + else { + // Hide the reload button in case js is enabled and it's not the first step. + $form['reload']['#attributes'] = array('class' => array('rules-hide-js')); + } +} + +/** + * Action: Delete scheduled tasks. + */ +function rules_scheduler_action_delete($component_name = NULL, $task_identifier = NULL) { + $query = db_delete('rules_scheduler'); + if (!empty($component_name)) { + $query->condition('config', $component_name); + } + if (!empty($task_identifier)) { + $query->condition('identifier', $task_identifier); + } + $query->execute(); +} + +/** + * Cancels scheduled task action validation callback. + */ +function rules_scheduler_action_delete_validate($element) { + if (empty($element->settings['task']) && empty($element->settings['task:select']) && + empty($element->settings['component']) && empty($element->settings['component:select'])) { + + throw new RulesIntegrityException(t('You have to specify at least either a component or a task identifier.'), $element); + } +} + +/** + * Help for the cancel action. + */ +function rules_scheduler_action_delete_help() { + return t('This action allows you to delete scheduled tasks that are waiting for future execution.') . ' ' . t('They can be addressed by an identifier or by the component name, whereas if both are specified only tasks fulfilling both requirements will be deleted.'); +} + +/** + * @} End of "addtogroup rules" + */ diff --git a/rules_scheduler/tests/rules_scheduler.test b/rules_scheduler/tests/rules_scheduler.test new file mode 100644 index 0000000..89606ce --- /dev/null +++ b/rules_scheduler/tests/rules_scheduler.test @@ -0,0 +1,151 @@ + 'Rules Scheduler tests', + 'description' => 'Test scheduling components.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules_scheduler', 'rules_scheduler_test'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Tests scheduling components from the action. + * + * Note that this also makes sure Rules properly handles timezones, else this + * test could fail due to a wrong 'now' timestamp. + */ + public function testComponentSchedule() { + $set = rules_rule_set(array( + 'node1' => array('type' => 'node', 'label' => 'node'), + )); + $set->rule( + rule()->condition('node_is_published', array('node:select' => 'node1')) + ->action('node_unpublish', array('node:select' => 'node1')) + ); + $set->integrityCheck()->save('rules_test_set_2'); + + // Use different names for the variables to ensure they are properly mapped. + $rule = rule(array( + 'node2' => array('type' => 'node', 'label' => 'node'), + )); + $rule->action('schedule', array( + 'component' => 'rules_test_set_2', + 'identifier' => 'node_[node2:nid]', + 'date' => 'now', + 'param_node1:select' => 'node2', + )); + + $node = $this->drupalCreateNode(array('title' => 'The title.', 'status' => 1)); + $rule->execute($node); + + // Run cron to let the rules scheduler do its work. + $this->cronRun(); + + $node = node_load($node->nid, NULL, TRUE); + $this->assertFalse($node->status, 'The component has been properly scheduled.'); + RulesLog::logger()->checkLog(); + } + + /** + * Makes sure recursion prevention is working fine for scheduled rule sets. + */ + public function testRecursionPrevention() { + $set = rules_rule_set(array( + 'node1' => array('type' => 'node', 'label' => 'node'), + )); + $set->rule( + rule()->condition('node_is_published', array('node:select' => 'node1')) + ->action('node_unpublish', array('node:select' => 'node1')) + ); + $set->integrityCheck()->save('rules_test_set_2'); + + // Add an reaction rule that is triggered upon a node save. The scheduled + // component changes the node, thus it would be scheduled again and run in + // an endless loop. + $rule = rules_reaction_rule(); + $rule->event('node_insert'); + $rule->event('node_update'); + $rule->action('schedule', array( + 'component' => 'rules_test_set_2', + 'identifier' => 'test_recursion_prevention', + 'date' => 'now', + 'param_node1:select' => 'node', + )); + $rule->save(); + + // Create a node, what triggers the rule. + $node = $this->drupalCreateNode(array('title' => 'The title.', 'status' => 1)); + // Run cron to let the rules scheduler do its work. + $this->cronRun(); + + $node = node_load($node->nid, NULL, TRUE); + $this->assertFalse($node->status, 'The component has been properly scheduled.'); + + // Create a simple user account with permission to see the dblog. + $user = $this->drupalCreateUser(array('access site reports')); + $this->drupalLogin($user); + + // View the database log. + $this->drupalGet('admin/reports/dblog'); + + // Can't use + // $this->clickLink('Rules debug information: " Scheduled evaluation...') + // because xpath doesn't allow : or " in the string. + // So instead, use our own xpath to figure out the href of the second link + // on the page (the first link is the most recent log entry, which is the + // log entry for the user login, above.) + + // All links. + $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => 'admin/reports/event/')); + // Strip off /?q= from href. + $href = explode('=', $links[1]['href']); + // Click the link for the RulesLog entry. + $this->drupalGet($href[1]); + $this->assertRaw(RulesTestCase::t('Not evaluating reaction rule %unlabeled to prevent recursion.', array('unlabeled' => $rule->name)), "Scheduled recursion prevented."); + RulesLog::logger()->checkLog(); + } + + /** + * Tests that custom task handlers are properly invoked. + */ + public function testCustomTaskHandler() { + // Set up a scheduled task that will simply write a variable when executed. + $variable = 'rules_schedule_task_handler_variable'; + rules_scheduler_schedule_task(array( + 'date' => REQUEST_TIME, + 'identifier' => '', + 'config' => '', + 'data' => array('variable' => $variable), + 'handler' => 'RulesTestTaskHandler', + )); + + // Run cron to let the rules scheduler do its work. + $this->cronRun(); + + // The task handler should have set the variable to TRUE now. + $this->assertTrue(variable_get($variable)); + } + +} diff --git a/rules_scheduler/tests/rules_scheduler_test.inc b/rules_scheduler/tests/rules_scheduler_test.inc new file mode 100644 index 0000000..39b378e --- /dev/null +++ b/rules_scheduler/tests/rules_scheduler_test.inc @@ -0,0 +1,24 @@ +getTask(); + $data = unserialize($task['data']); + + // Set the variable defined in the test to TRUE. + variable_set($data['variable'], TRUE); + } + +} diff --git a/rules_scheduler/tests/rules_scheduler_test.info b/rules_scheduler/tests/rules_scheduler_test.info new file mode 100644 index 0000000..d4b6f15 --- /dev/null +++ b/rules_scheduler/tests/rules_scheduler_test.info @@ -0,0 +1,12 @@ +name = "Rules Scheduler Tests" +description = "Support module for the Rules Scheduler tests." +package = Testing +core = 7.x +files[] = rules_scheduler_test.inc +hidden = TRUE + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/rules_scheduler/tests/rules_scheduler_test.module b/rules_scheduler/tests/rules_scheduler_test.module new file mode 100644 index 0000000..11e4875 --- /dev/null +++ b/rules_scheduler/tests/rules_scheduler_test.module @@ -0,0 +1,6 @@ + 'Rules Engine tests', + 'description' => 'Test using the rules API to create and evaluate rules.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_test'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Calculates the output of t() given an array of placeholders to replace. + */ + public static function t($text, $strings) { + $placeholders = array(); + foreach ($strings as $key => $string) { + $key = !is_numeric($key) ? $key : $string; + $placeholders['%' . $key] = drupal_placeholder($string); + } + return strtr($text, $placeholders); + } + + /** + * Helper function to create a test Rule. + */ + protected function createTestRule() { + $rule = rule(); + $rule->condition('rules_test_condition_true') + ->condition('rules_test_condition_true') + ->condition(rules_or() + ->condition(rules_condition('rules_test_condition_true')->negate()) + ->condition('rules_test_condition_false') + ->condition(rules_and() + ->condition('rules_test_condition_false') + ->condition('rules_test_condition_true') + ->negate() + ) + ); + $rule->action('rules_test_action'); + return $rule; + } + + /** + * Tests creating a rule and iterating over the rule elements. + */ + public function testRuleCreation() { + $rule = $this->createTestRule(); + $rule->integrityCheck(); + $rule->execute(); + $log = RulesLog::logger()->get(); + $last = array_pop($log); + $last = array_pop($log); + $last = array_pop($log); + $this->assertEqual($last[0], 'action called', 'Action called'); + RulesLog::logger()->checkLog(); + + // Make sure condition and action iterators are working. + $it = new RecursiveIteratorIterator($rule->conditions(), RecursiveIteratorIterator::SELF_FIRST); + $this->assertEqual(iterator_count($it), 8, 'Iterated over all conditions and condition containers'); + $it = new RecursiveIteratorIterator($rule->conditions()); + $this->assertEqual(iterator_count($it), 6, 'Iterated over all conditions'); + $this->assertEqual(iterator_count($rule->actions()), 1, 'Iterated over all actions'); + $this->assertEqual(iterator_count($rule->elements()), 10, 'Iterated over all rule elements.'); + + // Test getting dependencies and the integrity check. + $rule->integrityCheck(); + $this->assertTrue($rule->dependencies() === array('rules_test'), 'Dependencies correctly returned.'); + } + + /** + * Tests handling dependencies. + */ + public function testDependencies() { + $action = rules_action('rules_node_publish_action'); + $this->assertEqual($action->dependencies(), array('rules_test'), 'Providing module is returned as dependency.'); + + $container = new RulesTestContainer(); + $this->assertEqual($container->dependencies(), array('rules_test'), 'Providing module for container plugin is returned as dependency.'); + + // Test handling unmet dependencies. + $rule = rules_config_load('rules_export_test'); + $this->assertTrue(in_array('comment', $rule->dependencies) && !$rule->dirty, 'Dependencies have been imported.'); + + // Remove the required comment module and make sure the rule is dirty then. + module_disable(array('comment')); + rules_clear_cache(); + $rule = rules_config_load('rules_export_test'); + $this->assertTrue($rule->dirty, 'Rule has been marked as dirty'); + + // Now try re-enabling. + module_enable(array('comment')); + rules_clear_cache(); + $rule = rules_config_load('rules_export_test'); + $this->assertTrue(!$rule->dirty, 'Rule has been marked as not dirty again.'); + + // Test it with components. + module_enable(array('path')); + $action_set = rules_action_set(array('node' => array('type' => 'node'))); + $action_set->action('node_path_alias'); + $action_set->save('rules_test_alias'); + + $rule = rule(array('node' => array('type' => 'node'))); + $rule->action('component_rules_test_alias'); + $rule->integrityCheck(); + $rule->save('rules_test_rule'); + + $rule = rules_config_load('rules_test_rule'); + $component = rules_config_load('rules_test_alias'); + $this->assertTrue(in_array('path', $component->dependencies) && !$rule->dirty && !$component->dirty, 'Component has path module dependency.'); + + // Now disable path module and make sure both configs are marked as dirty. + module_disable(array('path')); + rules_clear_cache(); + $rule = rules_config_load('rules_test_rule'); + $component = rules_config_load('rules_test_alias'); + + $this->assertTrue($component->dirty, 'Component has been marked as dirty'); + $node = $this->drupalCreateNode(); + $result = rules_invoke_component('rules_test_alias', $node); + $this->assertTrue($result === FALSE, 'Unable to execute a dirty component.'); + + // When the rule is evaluated, the broken component is detected and the + // rule should be marked as dirty too. + $rule->execute($node); + $this->assertTrue($rule->dirty, 'Rule has been marked as dirty'); + + module_enable(array('path')); + rules_clear_cache(); + + // Trigger rebuilding the cache, so configs are checked again. + rules_get_cache(); + + $rule = rules_config_load('rules_test_rule'); + $component = rules_config_load('rules_test_alias'); + $this->assertTrue(!$component->dirty, 'Component has been marked as not dirty again.'); + $this->assertTrue(!$rule->dirty, 'Rule has been marked as not dirty again.'); + } + + /** + * Tests setting up an action, serializing, and executing it. + */ + public function testActionSetup() { + $action = rules_action('rules_node_publish_action'); + + $s = serialize($action); + $action2 = unserialize($s); + $node = (object) array('status' => 0, 'type' => 'page'); + $node->title = 'test'; + + $action2->execute($node); + $this->assertEqual($node->status, 1, 'Action executed correctly'); + + $this->assertTrue(in_array('node', array_keys($action2->parameterInfo())), 'Parameter info returned.'); + + $node->status = 0; + $action2->integrityCheck(); + $action2->executeByArgs(array('node' => $node)); + $this->assertEqual($node->status, 1, 'Action executed correctly'); + + // Test calling an extended + overridden method. + $this->assertEqual($action2->help(), 'custom', 'Using custom help callback.'); + + // Inspect the cache + // $this->pass(serialize(rules_get_cache())); + RulesLog::logger()->checkLog(); + } + + /** + * Tests executing with wrong arguments. + */ + public function testActionExecutionFails() { + $action = rules_action('rules_node_publish_action'); + try { + $action->execute(); + $this->fail("Execution hasn't created an exception."); + } + catch (RulesEvaluationException $e) { + $this->pass("RulesEvaluationException was thrown: " . $e); + } + } + + /** + * Tests setting up a rule and mapping variables. + */ + public function testVariableMapping() { + $rule = rule(array( + 'node' => array('type' => 'node'), + 'node_unchanged' => array('type' => 'node'), + )); + $rule->condition(rules_condition('rules_condition_content_is_published')->negate()) + ->condition('rules_condition_content_is_type', array('type' => array('page', 'story'))) + ->action('rules_node_publish_action', array('node:select' => 'node_unchanged')); + + $node1 = $this->drupalCreateNode(array('status' => 0, 'type' => 'page')); + $node2 = $this->drupalCreateNode(array('status' => 0, 'type' => 'page')); + $rule->integrityCheck(); + $rule->execute($node1, $node2); + $this->assertEqual($node2->status, 1, 'Action executed correctly on node2.'); + $this->assertEqual($node1->status, 0, 'Action not executed on node1.'); + + RulesLog::logger()->checkLog(); + } + + /** + * Tests making use of class based actions. + */ + public function testClassBasedActions() { + $cache = rules_get_cache(); + $this->assertTrue(!empty($cache['action_info']['rules_test_class_action']), 'Action has been discovered.'); + $action = rules_action('rules_test_class_action'); + + $parameters = $action->parameterInfo(); + $this->assertTrue($parameters['node'], 'Action parameter needs a value.'); + + $node = $this->drupalCreateNode(); + $action->execute($node); + $log = RulesLog::logger()->get(); + $last = array_pop($log); + $last = array_pop($log); + $this->assertEqual($last[0], 'Action called with node ' . $node->nid, 'Action called'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests CRUD functionality. + */ + public function testRulesCrud() { + $rule = $this->createTestRule(); + $rule->integrityCheck()->save('test'); + + $this->assertEqual(TRUE, $rule->active, 'Rule is active.'); + $this->assertEqual(0, $rule->weight, 'Rule weight is zero.'); + + $results = entity_load('rules_config', array('test')); + $rule2 = array_pop($results); + $this->assertEqual($rule->id, $rule2->id, 'Rule created and loaded'); + $this->assertEqual(get_class($rule2), get_class($rule), 'Class properly instantiated.'); + $rule2->execute(); + // Update. + $rule2->save(); + + // Make sure all rule elements are still here. + $it = new RecursiveIteratorIterator($rule2->conditions(), RecursiveIteratorIterator::SELF_FIRST); + $this->assertEqual(iterator_count($it), 8, 'Iterated over all conditions and condition containers'); + $it = new RecursiveIteratorIterator($rule2->conditions()); + $this->assertEqual(iterator_count($it), 6, 'Iterated over all conditions'); + $this->assertEqual(iterator_count($rule2->actions()), 1, 'Iterated over all actions'); + + // Delete. + $rule2->delete(); + $this->assertEqual(entity_load('rules_config', FALSE, array('id' => $rule->id)), array(), 'Deleted.'); + + // Tests CRUD for tags - making sure the tags are stored properly.. + $rule = $this->createTestRule(); + $tag = $this->randomString(); + $rule->tags = array($tag); + $rule->save(); + $result = db_select('rules_tags') + ->fields('rules_tags', array('tag')) + ->condition('id', $rule->id) + ->execute(); + $this->assertEqual($result->fetchField(), $tag, 'Associated tag has been saved.'); + // Try updating. + $rule->tags = array($this->randomName(), $this->randomName()); + $rule->integrityCheck()->save(); + $result = db_select('rules_tags') + ->fields('rules_tags', array('tag')) + ->condition('id', $rule->id) + ->execute() + ->fetchCol(); + $this->assertTrue(in_array($rule->tags[0], $result) && in_array($rule->tags[1], $result), 'Updated associated tags.'); + // Try loading multiple rules by tags. + $rule2 = $this->createTestRule(); + $rule2->tags = array($this->randomName()); + $rule2->save(); + $loaded = entity_load('rules_config', FALSE, array('tags' => array($rule->tags[0], $rule2->tags[0]))); + $this->assertTrue($loaded[$rule->id]->id == $rule->id && $loaded[$rule2->id]->id == $rule2->id, 'Loading configs by tags'); + // Try deleting. + $rule->delete(); + $result = db_select('rules_tags') + ->fields('rules_tags', array('tag')) + ->condition('id', $rule->id) + ->execute(); + $this->assertEqual($result->fetchField(), FALSE, 'Deleted associated tags.'); + } + + /** + * Tests automatic saving of variables. + */ + public function testActionSaving() { + // Test saving a parameter. + $action = rules_action('rules_node_publish_action_save'); + $node = $this->drupalCreateNode(array('status' => 0, 'type' => 'page')); + $action->executeByArgs(array('node' => $node)); + + $this->assertEqual($node->status, 1, 'Action executed correctly on node.'); + // Sync node_load cache with node_save. + entity_get_controller('node')->resetCache(); + + $node = node_load($node->nid); + $this->assertEqual($node->status, 1, 'Node has been saved.'); + + // Now test saving a provided variable, which is renamed and modified before + // it is saved. + $title = $this->randomName(); + $rule = rule(); + $rule->action('entity_create', array( + 'type' => 'node', + 'param_type' => 'article', + 'param_author:select' => 'site:current-user', + 'param_title' => $title, + 'entity_created:var' => 'node', + )); + $rule->action('data_set', array( + 'data:select' => 'node:body', + 'value' => array('value' => 'body'), + )); + $rule->integrityCheck(); + $rule->execute(); + + $node = $this->drupalGetNodeByTitle($title); + $this->assertTrue(!empty($node) && $node->body[LANGUAGE_NONE][0]['value'] == 'body', 'Saved a provided variable'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests adding a variable and optional parameters. + */ + public function testVariableAdding() { + $node = $this->drupalCreateNode(); + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->condition('rules_test_condition_true') + ->action('rules_action_load_node') + ->action('rules_action_delete_node', array('node:select' => 'node_loaded')) + ->execute($node->nid); + + $this->assertEqual(FALSE, node_load($node->nid), 'Variable added and skipped optional parameter.'); + RulesLog::logger()->checkLog(); + + $vars = $rule->conditions()->offsetGet(0)->availableVariables(); + $this->assertEqual(!isset($vars['node_loaded']), 'Loaded variable is not available to conditions.'); + + // Test adding a variable with a custom variable name. + $node = $this->drupalCreateNode(); + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->action('rules_action_load_node', array('node_loaded:var' => 'node')) + ->action('rules_action_delete_node') + ->execute($node->nid); + + $this->assertEqual(FALSE, node_load($node->nid), 'Variable with custom name added.'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests custom access for using component actions/conditions. + */ + public function testRuleComponentAccess() { + // Create a normal user. + $normal_user = $this->drupalCreateUser(); + // Create a role for granting access to the rule component. + $this->normal_role = $this->drupalCreateRole(array(), 'test_role'); + $normal_user->roles[$this->normal_role] = 'test_role'; + user_save($normal_user, array('roles' => $normal_user->roles)); + // Create an 'action set' rule component making use of a permission. + $action_set = rules_action_set(array('node' => array('type' => 'node'))); + $action_set->access_exposed = TRUE; + $action_set->save('rules_test_roles'); + + // Set the global user to be the current one as access is checked for the + // global user. + global $user; + $user = user_load($normal_user->uid); + $this->assertFalse(rules_action('component_rules_test_roles')->access(), 'Authenticated user without the correct role can\'t use the rule component.'); + + // Assign the role that will have permissions for the rule component. + user_role_change_permissions($this->normal_role, array('use Rules component rules_test_roles' => TRUE)); + $this->assertTrue(rules_action('component_rules_test_roles')->access(), 'Authenticated user with the correct role can use the rule component.'); + + // Reset global user to anonymous. + $user = user_load(0); + $this->assertFalse(rules_action('component_rules_test_roles')->access(), 'Anonymous user can\'t use the rule component.'); + } + + /** + * Tests passing arguments by reference to an action. + */ + public function testPassingByReference() { + // Keeping references of variables is unsupported, though the + // EntityMetadataArrayObject may be used to achieve that. + $array = array('foo' => 'bar'); + $data = new EntityMetadataArrayObject($array); + rules_action('rules_action_test_reference')->execute($data); + $this->assertTrue($data['changed'], 'Parameter has been passed by reference'); + } + + /** + * Tests sorting rule elements. + */ + public function testSorting() { + $rule = $this->createTestRule(); + $conditions = $rule->conditions(); + $conditions[0]->weight = 10; + $conditions[2]->weight = 10; + $id[0] = $conditions[0]->elementId(); + $id[1] = $conditions[1]->elementId(); + $id[2] = $conditions[2]->elementId(); + // For testing use a deep sort, even if not necessary here. + $rule->sortChildren(TRUE); + $conditions = $rule->conditions(); + $this->assertEqual($conditions[0]->elementId(), $id[1], 'Condition sorted correctly.'); + $this->assertEqual($conditions[1]->elementId(), $id[0], 'Condition sorted correctly.'); + $this->assertEqual($conditions[2]->elementId(), $id[2], 'Condition sorted correctly.'); + } + + /** + * Tests using data selectors. + */ + public function testDataSelectors() { + $body[LANGUAGE_NONE][0] = array('value' => 'The body & nothing.'); + $node = $this->drupalCreateNode(array( + 'body' => $body, + 'type' => 'page', + 'summary' => '', + )); + + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->action('rules_action_load_node') + ->action('drupal_message', array('message:select' => 'node_loaded:body:value')) + ->execute($node->nid); + + RulesLog::logger()->checkLog(); + $msg = drupal_get_messages('status'); + $last_msg = array_pop($msg['status']); + $wrapper = entity_metadata_wrapper('node', $node); + $this->assertEqual($last_msg, $wrapper->body->value->value(array('sanitize' => TRUE)), 'Data selector for getting parameter applied.'); + + // Get a "reference" on the same object as returned by node_load(). + $node = node_load($node->nid); + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->action('rules_action_load_node') + ->action('data_set', array('data:select' => 'node_loaded:title', 'value' => 'Test title')) + // Use two actions and make sure the node get saved only once. + ->action('data_set', array('data:select' => 'node_loaded:title', 'value' => 'Test title2')) + ->execute($node->nid); + + $wrapper = entity_metadata_wrapper('node', $node); + $this->assertEqual('Test title2', $wrapper->title->value(), 'Data has been modified and saved.'); + + RulesLog::logger()->checkLog(); + $text = RulesLog::logger()->render(); + $msg = RulesTestCase::t('Saved %node_loaded of type %node.', array('node_loaded', 'node')); + if ($pos1 = strpos($text, $msg)) { + $pos2 = strpos($text, $msg, $pos1 + 1); + } + $this->assertTrue($pos1 && $pos2 === FALSE, 'Data has been saved only once.'); + + // Test validation. + try { + rules_action('data_set', array('data' => 'no-selector', 'value' => ''))->integrityCheck(); + $this->fail("Validation hasn't created an exception."); + } + catch (RulesIntegrityException $e) { + $this->pass("Validation error correctly detected: " . $e); + } + + // Test auto creation of nested data structures, like the node body field. + // I.e. if $node->body is not set, it is automatically initialized to an + // empty array, so that the nested value can be set and the wrappers do not + // complain about missing parent data structures. + $rule = rule(); + $rule->action('entity_create', array( + 'type' => 'node', + 'param_type' => 'page', + 'param_title' => 'foo', + 'param_author' => $GLOBALS['user'], + )); + $rule->action('data_set', array('data:select' => 'entity_created:body:value', 'value' => 'test content')) + ->execute(); + try { + RulesLog::logger()->checkLog(); + $this->pass('Auto creation of nested data structures.'); + } + catch (Exception $e) { + $this->fail('Auto creation of nested data structures.'); + } + + // Make sure variables that are passed wrapped work. + $result = rules_condition('rules_test_condition_node_wrapped')->execute($node->nid); + $this->assertTrue($result, 'Condition receiving wrapped parameter.'); + + // Make sure wrapped parameters are checked for containing NULL values. + $rule = rule(array('node' => array('type' => 'node', 'optional' => TRUE))); + $rule->condition('rules_test_condition_node_wrapped', array('node:select' => 'node')); + $rule->execute(entity_metadata_wrapper('node')); + $text = RulesLog::logger()->render(); + $msg = RulesTestCase::t('The variable or parameter %node is empty.', array('node')); + $this->assertTrue(strpos($text, $msg) !== FALSE, 'Evaluation aborted due to an empty argument value.'); + } + + /** + * Tests making use of rule sets. + */ + public function testRuleSets() { + $set = rules_rule_set(array( + 'node' => array('type' => 'node', 'label' => 'node'), + )); + $set->rule(rule()->action('drupal_message', array('message:select' => 'node:title'))) + ->rule( + rule()->condition('rules_condition_content_is_published') + ->action('drupal_message', array('message' => 'Node is published.')) + ); + $set->integrityCheck()->save('rules_test_set_1'); + + $node = $this->drupalCreateNode(array('title' => 'The title.', 'status' => 1)); + // Execute. + rules_invoke_component('rules_test_set_1', $node); + + $msg = drupal_get_messages(); + $this->assertEqual($msg['status'][0], 'The title.', 'First rule evaluated.'); + $this->assertEqual($msg['status'][1], 'Node is published.', 'Second rule evaluated.'); + + // Test a condition set. + $set = rules_or(array( + 'node' => array('type' => 'node', 'label' => 'node'), + )); + $set->condition('data_is', array('data:select' => 'node:author:name', 'value' => 'notthename')) + ->condition('data_is', array('data:select' => 'node:nid', 'value' => $node->nid)) + ->integrityCheck() + ->save('test', 'rules_test'); + // Load and execute condition set. + $set = rules_config_load('test'); + $this->assertTrue($set->execute($node), 'Set has been correctly evaluated.'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests invoking components from the action. + */ + public function testComponentInvocations() { + $set = rules_rule_set(array( + 'node1' => array('type' => 'node', 'label' => 'node'), + )); + $set->rule( + rule()->condition('node_is_published', array('node:select' => 'node1')) + ->action('node_unpublish', array('node:select' => 'node1')) + ); + $set->integrityCheck()->save('rules_test_set_2'); + + // Use different names for the variables to ensure they are properly mapped + // when taking over the variables to be saved. + $rule = rule(array( + 'node2' => array('type' => 'node', 'label' => 'node'), + )); + $rule->action('component_rules_test_set_2', array('node1:select' => 'node2')); + $rule->action('node_make_sticky', array('node:select' => 'node2')); + + $node = $this->drupalCreateNode(array( + 'title' => 'The title.', + 'status' => 1, + 'sticky' => 0, + )); + $rule->execute($node); + + $node = node_load($node->nid, NULL, TRUE); + $this->assertFalse($node->status, 'The component changes have been saved correctly.'); + $this->assertTrue($node->sticky, 'The action changes have been saved correctly.'); + + // Check that we have saved the changes only once. + $text = RulesLog::logger()->render(); + // Make sure both saves are handled in one save operation. + $this->assertEqual(substr_count($text, 'Saved'), 1, 'Changes have been saved in one save operation.'); + RulesLog::logger()->checkLog(); + + // Test recursion prevention on components by invoking the component from + // itself, what should be prevented. + $set->action('component_rules_test_set_2', array('node1:select' => 'node1')) + ->save(); + + $rule->execute($node); + $text1 = RulesLog::logger()->render(); + $text2 = RulesTestCase::t('Not evaluating rule set %rules_test_set_2 to prevent recursion.', array('rules_test_set_2')); + $this->assertTrue((strpos($text1, $text2) !== FALSE), "Recursion of component invocation prevented."); + + // Test executing the component provided in code via the action. This makes + // sure the component in code has been properly picked up. + $node->status = 0; + node_save($node); + rules_action('component_rules_test_action_set')->execute($node); + $this->assertTrue($node->status == 1, 'Component provided in code has been executed.'); + } + + /** + * Tests asserting metadata. + * + * Customizes action info and makes sure integrity is checked. + */ + public function testMetadataAssertion() { + $action = rules_action('rules_node_make_sticky_action'); + + // Test failing integrity check. + try { + $rule = rule(array('node' => array('type' => 'entity'))); + $rule->action($action); + // Fails due to the 'node' variable not matching the node type. + $rule->integrityCheck(); + $this->fail('Integrity check has not thrown an exception.'); + } + catch (RulesIntegrityException $e) { + $this->pass('Integrity check has thrown exception: ' . $e->getMessage()); + } + + // Test asserting additional metadata. + $rule = rule(array('node' => array('type' => 'node'))); + // Customize action info using the settings. + $rule->condition('data_is', array('data:select' => 'node:type', 'value' => 'page')) + // Configure an condition using the body. As the body is a field, + // this requires the bundle to be correctly asserted. + ->condition(rules_condition('data_is', array('data:select' => 'node:body:value', 'value' => 'foo'))->negate()) + // The action also requires the page bundle in order to work. + ->action($action); + // Make sure the integrity check doesn't throw an exception. + $rule->integrityCheck(); + // Test the rule. + $node = $this->drupalCreateNode(array('type' => 'page', 'sticky' => 0)); + $rule->execute($node); + $this->assertTrue($node->sticky, 'Rule with asserted metadata executed.'); + + // Test asserting metadata on a derived property, i.e. not a variable. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('entity_is_of_type', array('entity:select' => 'node:reference', 'type' => 'node')) + ->condition('data_is', array('data:select' => 'node:reference:type', 'value' => 'page')) + ->action('rules_node_page_make_sticky_action', array('node:select' => 'node:reference')); + $rule->integrityCheck(); + $rule->execute($node); + + // Test asserting an entity field. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('entity_has_field', array('entity:select' => 'node:reference', 'field' => 'field_tags')) + ->action('data_set', array('data:select' => 'node:reference:field-tags', 'value' => array())); + $rule->integrityCheck(); + $rule->execute($node); + + // Make sure an asserted bundle can be used as argument. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('entity_is_of_type', array('entity:select' => 'node:reference', 'type' => 'node')) + ->condition('node_is_of_type', array('node:select' => 'node:reference', 'type' => array('page'))) + ->action('rules_node_page_make_sticky_action', array('node:select' => 'node:reference')); + $rule->integrityCheck(); + $rule->execute($node); + + // Test asserting metadata on a derived property being a list item. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('node_is_of_type', array('node:select' => 'node:ref-nodes:0', 'type' => array('article'))) + ->action('data_set', array('data:select' => 'node:ref-nodes:0:field-tags', 'value' => array())); + $rule->integrityCheck(); + $rule->execute($node); + + // Give green lights if there were no exceptions and check rules-log errors. + $this->pass('Rules asserting metadata on a derived property pass integrity checks.'); + RulesLog::logger()->checkLog(); + + // Make sure assertions of a one list item are not valid for another item. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('node_is_of_type', array('node:select' => 'node:ref-nodes:0', 'type' => array('article'))) + ->action('data_set', array('data:select' => 'node:ref-nodes:1:field-tags', 'value' => array())); + try { + $rule->integrityCheck(); + $this->fail('Assertion of a list item is not valid for another item.'); + } + catch (RulesException $e) { + $this->pass('Assertion of a list item is not valid for another item.'); + } + } + + /** + * Tests using loops. + */ + public function testLoops() { + // Test passing the list parameter as argument to ensure that is working + // generally for plugin container too. + drupal_get_messages(NULL, TRUE); + $loop = rules_loop(); + $loop->action('drupal_message', array('message' => 'test')); + $arg_info = $loop->parameterInfo(); + $this->assert($arg_info['list']['type'] == 'list', 'Argument info contains list.'); + $loop->execute(array(1, 2)); + + // Ensure the action has been executed twice, once for each list item. + $msg = drupal_get_messages(); + $this->assert($msg['status'][0] == 'test' && $msg['status'][1], 'Loop has been properly executed'); + + // Now test looping over nodes. + $node1 = $this->drupalCreateNode(array('type' => 'page', 'sticky' => 0)); + $node2 = $this->drupalCreateNode(array('type' => 'page', 'sticky' => 0)); + $node3 = $this->drupalCreateNode(array('type' => 'page', 'sticky' => 0)); + + $rule = rule(array( + 'list' => array( + 'type' => 'list', + 'label' => 'A list of nodes', + ), + )); + $loop = rules_loop(array('list:select' => 'list', 'item:var' => 'node')); + $loop->action('data_set', array('data:select' => 'node:sticky', 'value' => TRUE)); + $rule->action($loop); + // Test using a list with data selectors, just output the last nodes type. + $rule->action('drupal_message', array('message:select' => 'list:2:type')); + + $rule->execute(array($node1->nid, $node2->nid, $node3->nid)); + $text = RulesLog::logger()->render(); + $save_msg = RulesTestCase::t('Saved %node of type %node.', array('node', 'node')); + $this->assertTrue(substr_count($text, $save_msg) == 3, 'List item variables have been saved.'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests access checks. + */ + public function testAccessCheck() { + $rule = rule(); + // Try to set a property which is provided by the test module and is not + // accessible, so the access check has to return FALSE. + $rule->action('data_set', array('data:select' => 'site:no-access-user', 'value' => 'foo')); + $this->assertTrue($rule->access() === FALSE, 'Access check is working.'); + } + + /** + * Tests returning provided variables. + */ + public function testReturningVariables() { + $node = $this->drupalCreateNode(); + $action = rules_action('entity_fetch', array('type' => 'node', 'id' => $node->nid)); + list($node2) = $action->execute(); + $this->assertTrue($node2->nid == $node->nid, 'Action returned a variable.'); + + // Create a simple set that just passed through the given node. + $set = rules_rule_set(array('node' => array('type' => 'node')), array('node')); + $set->integrityCheck()->save('rules_test_set_1'); + + $provides = $set->providesVariables(); + $this->assertTrue($provides['node']['type'] == 'node', 'Rule set correctly passed through the node.'); + + list($node2) = $set->execute($node); + $this->assertTrue($node2->nid == $node->nid, 'Rule set returned a variable.'); + + // Create an action set returning a variable that is no parameter. + $set = rules_action_set(array( + 'node' => array( + 'type' => 'node', + 'parameter' => FALSE, + )), array('node')); + $set->action('entity_fetch', array('type' => 'node', 'id' => $node->nid)) + ->action('data_set', array('data:select' => 'node', 'value:select' => 'entity_fetched')); + $set->integrityCheck(); + list($node3) = $set->execute(); + $this->assertTrue($node3->nid == $node->nid, 'Action set returned a variable that has not been passed as parameter.'); + + // Test the same again with a variable holding a not wrapped data type. + $set = rules_action_set(array( + 'number' => array( + 'type' => 'integer', + 'parameter' => FALSE, + )), array('number')); + $set->action('data_set', array('data:select' => 'number', 'value' => 3)); + $set->integrityCheck(); + list($number) = $set->execute(); + $this->assertTrue($number == 3, 'Actions set returned a number.'); + } + + /** + * Tests using input evaluators. + */ + public function testInputEvaluators() { + $node = $this->drupalCreateNode(array('title' => 'The body & nothing.', 'type' => 'page')); + + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->action('rules_action_load_node') + ->action('drupal_message', array('message' => 'Title: [node_loaded:title]')) + ->execute($node->nid); + + RulesLog::logger()->checkLog(); + $msg = drupal_get_messages(); + $this->assertEqual(array_pop($msg['status']), 'Title: ' . check_plain('The body & nothing.'), 'Token input evaluator applied.'); + + // Test token replacements on a list of text values. + $component = rules_action_set(array('var' => array('type' => 'list', 'label' => 'var')), array('var')); + $component->save('rules_test_input'); + + $action = rules_action('component_rules_test_input', array('var' => array('uid: [site:current-user:uid]'))); + list($var) = $action->execute(); + $uid = $GLOBALS['user']->uid; + $this->assertEqual(array("uid: $uid"), $var, 'Token replacements on a list of values applied.'); + } + + /** + * Tests importing and exporting a rule. + */ + public function testRuleImportExport() { + $rule = rule(array('nid' => array('type' => 'integer'))); + $rule->name = "rules_export_test"; + $rule->action('rules_action_load_node') + ->action('drupal_message', array('message' => 'Title: [node_loaded:title]')); + + $export = +'{ "rules_export_test" : { + "PLUGIN" : "rule", + "REQUIRES" : [ "rules_test", "rules" ], + "USES VARIABLES" : { "nid" : { "type" : "integer" } }, + "DO" : [ + { "rules_action_load_node" : { "PROVIDE" : { "node_loaded" : { "node_loaded" : "Loaded content" } } } }, + { "drupal_message" : { "message" : "Title: [node_loaded:title]" } } + ] + } +}'; + $this->assertEqual($export, $rule->export(), 'Rule has been exported correctly.'); + + // Test importing a rule which makes use of almost all features. + $export = _rules_export_get_test_export(); + $rule = rules_import($export); + $this->assertTrue(!empty($rule) && $rule->integrityCheck(), 'Rule has been imported.'); + + // Test loading the same export provided as default rule. + $rule = rules_config_load('rules_export_test'); + $this->assertTrue(!empty($rule) && $rule->integrityCheck(), 'Export has been provided in code.'); + + // Export it and make sure the same export is generated again. + $this->assertEqual($export, $rule->export(), 'Export of imported rule equals original export.'); + + // Now try importing a rule set. + $export = +'{ "rules_test_set" : { + "LABEL" : "Test set", + "PLUGIN" : "rule set", + "REQUIRES" : [ "rules" ], + "USES VARIABLES" : { "node" : { "label" : "Test node", "type" : "node" } }, + "RULES" : [ + { "RULE" : { + "IF" : [ { "NOT data_is" : { "data" : [ "node:title" ], "value" : "test" } } ], + "DO" : [ { "data_set" : { "data" : [ "node:title" ], "value" : "test" } } ], + "LABEL" : "Test Rule" + } + }, + { "RULE" : { + "DO" : [ { "drupal_message" : { "message" : "hi" } } ], + "LABEL" : "Test Rule 2" + } + } + ] + } +}'; + $set = rules_import($export); + $this->assertTrue(!empty($set) && $set->integrityCheck(), 'Rule set has been imported.'); + // Export it and make sure the same export is generated again. + $this->assertEqual($export, $set->export(), 'Export of imported rule set equals original export.'); + + // Try executing the imported rule set. + $node = $this->drupalCreateNode(); + $set->execute($node); + $this->assertEqual($node->title, 'test', 'Imported rule set has been executed.'); + RulesLog::logger()->checkLog(); + + // Try import / export for a rule component providing a variable. + $rule = rule(array( + 'number' => array( + 'type' => 'integer', + 'label' => 'Number', + 'parameter' => FALSE, + )), array('number')); + $rule->action('data_set', array('data:select' => 'number', 'value' => 3)); + $rule->name = 'rules_test_provides'; + + $export = '{ "rules_test_provides" : { + "PLUGIN" : "rule", + "REQUIRES" : [ "rules" ], + "USES VARIABLES" : { "number" : { "type" : "integer", "label" : "Number", "parameter" : false } }, + "DO" : [ { "data_set" : { "data" : [ "number" ], "value" : 3 } } ], + "PROVIDES VARIABLES" : [ "number" ] + } +}'; + $this->assertEqual($export, $rule->export(), 'Rule 2 has been exported correctly.'); + $imported_rule = rules_import($rule->export()); + + $this->assertTrue(!empty($imported_rule) && $imported_rule->integrityCheck(), 'Rule 2 has been imported.'); + $this->assertEqual($export, $imported_rule->export(), 'Export of imported rule 2 equals original export.'); + + // Test importing a negated condition component. + $export = '{ "rules_negated_component" : { + "LABEL" : "negated_component", + "PLUGIN" : "or", + "REQUIRES" : [ "rules" ], + "NOT OR" : [ { "data_is_empty" : { "data" : [ "site:slogan" ] } } ] + } +}'; + $or = rules_import($export); + $this->assertTrue($or->integrityCheck() && $or->isNegated(), 'Negated condition component imported.'); + } + + /** + * Tests the named parameter mode. + */ + public function testNamedParameters() { + $rule = rule(array('node' => array('type' => 'node'))); + $rule->action('rules_action_node_set_title', array('title' => 'foo')); + $rule->integrityCheck(); + + // Test the rule. + $node = $this->drupalCreateNode(array('type' => 'page', 'sticky' => 0)); + $rule->execute($node); + $this->assertTrue($node->title == 'foo', 'Action with named parameters has been correctly executed.'); + RulesLog::logger()->checkLog(); + } + + /** + * Makes sure Rules aborts when NULL values are used. + */ + public function testAbortOnNULLValues() { + $rule = rule(array('node' => array('type' => 'node'))); + $rule->action('drupal_message', array('message:select' => 'node:log')); + $rule->integrityCheck(); + + // Test the rule. + $node = $this->drupalCreateNode(); + $node->log = NULL; + $rule->execute($node); + + $text = RulesLog::logger()->render(); + $msg = RulesTestCase::t('The variable or parameter %message is empty.', array('message')); + $this->assertTrue(strpos($text, $msg) !== FALSE, 'Evaluation aborted due to an empty argument value.'); + } + +} + +/** + * Test rules data wrappers. + */ +class RulesTestDataCase extends DrupalWebTestCase { + + /** + * Declares test metadata. + */ + public static function getInfo() { + return array( + 'name' => 'Rules Data tests', + 'description' => 'Tests rules data saving and type matching.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_test'); + variable_set('rules_debug_log', TRUE); + // Make sure we don't ran over issues with the node_load static cache. + entity_get_controller('node')->resetCache(); + } + + /** + * Tests intelligently saving data. + */ + public function testDataSaving() { + $node = $this->drupalCreateNode(); + $state = new RulesState(rule()); + $state->addVariable('node', $node, array('type' => 'node')); + $wrapper = $state->get('node'); + $node->title = 'test'; + $wrapper->set($node); + $state->saveChanges('node', $wrapper, FALSE); + + $this->assertFalse($this->drupalGetNodeByTitle('test'), 'Changes have not been saved.'); + $state->saveChanges('node', $wrapper, TRUE); + $this->assertTrue($this->drupalGetNodeByTitle('test'), 'Changes have been saved.'); + + // Test skipping saving. + $state->addVariable('node2', $node, array( + 'type' => 'node', + 'skip save' => TRUE, + )); + $wrapper = $state->get('node2'); + $node->title = 'test2'; + $wrapper->set($node); + $state->saveChanges('node2', $wrapper, TRUE); + $this->assertFalse($this->drupalGetNodeByTitle('test2'), 'Changes have not been saved.'); + + // Try saving a non-entity wrapper, which should result in saving the + // parent entity containing the property. + $wrapper = $state->get('node'); + $wrapper->title->set('test3'); + $state->saveChanges('node:title', $wrapper, TRUE); + $this->assertTrue($this->drupalGetNodeByTitle('test3'), 'Parent entity has been saved.'); + } + + /** + * Tests type matching. + */ + public function testTypeMatching() { + $entity = array('type' => 'entity'); + $node = array('type' => 'node'); + $this->assertTrue(RulesData::typesMatch($node, $entity), 'Types match.'); + $this->assertFalse(RulesData::typesMatch($entity, $node), 'Types don\'t match.'); + + $this->assertTrue(RulesData::typesMatch($node + array('bundle' => 'page'), $node), 'Types match.'); + $this->assertTrue(RulesData::typesMatch($node + array('bundle' => 'page'), $entity), 'Types match.'); + $this->assertTrue(RulesData::typesMatch(array('type' => 'list'), array('type' => 'list')), 'Types match.'); + $this->assertTrue(RulesData::typesMatch($node + array('bundle' => 'page'), $node + array('bundles' => array('page', 'story'))), 'Types match.'); + $this->assertFalse(RulesData::typesMatch($node, $node + array('bundles' => array('page', 'story'))), 'Types don\'t match.'); + + // Test that a type matches its grand-parent type (text > decimal > integer) + $this->assertTrue(RulesData::typesMatch(array('type' => 'integer'), array('type' => 'text')), 'Types match.'); + $this->assertFalse(RulesData::typesMatch(array('type' => 'text'), array('type' => 'integer')), 'Types don\'t match.'); + } + + /** + * Tests making use of custom wrapper classes. + */ + public function testCustomWrapperClasses() { + // Test loading a vocabulary by name, which is done by a custom wrapper. + $set = rules_action_set(array('vocab' => array('type' => 'taxonomy_vocabulary')), array('vocab')); + $set->action('drupal_message', array('message:select' => 'vocab:name')); + $set->integrityCheck(); + list($vocab) = $set->execute('tags'); + $this->assertTrue($vocab->machine_name == 'tags', 'Loaded vocabulary by name.'); + + // Now test wrapper creation for a direct input argument value. + $set = rules_action_set(array('term' => array('type' => 'taxonomy_term'))); + $set->action('data_set', array('data:select' => 'term:vocabulary', 'value' => 'tags')); + $set->integrityCheck(); + + $vocab = entity_create('taxonomy_vocabulary', array( + 'name' => 'foo', + 'machine_name' => 'foo', + )); + entity_save('taxonomy_vocabulary', $vocab); + $term_wrapped = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => $vocab, + ))->save(); + $set->execute($term_wrapped); + $this->assertEqual($term_wrapped->vocabulary->machine_name->value(), 'tags', 'Vocabulary name used as direct input value.'); + RulesLog::logger()->checkLog(); + } + + /** + * Makes sure the RulesIdentifiableDataWrapper is working correctly. + */ + public function testRulesIdentifiableDataWrapper() { + $node = $this->drupalCreateNode(); + $wrapper = new RulesTestTypeWrapper('rules_test_type', $node); + $this->assertTrue($wrapper->value() == $node, 'Data correctly wrapped.'); + + // Test serializing and make sure only the id is stored. + $this->assertTrue(strpos(serialize($wrapper), $node->title) === FALSE, 'Data has been correctly serialized.'); + $this->assertEqual(unserialize(serialize($wrapper))->value()->title, $node->title, 'Serializing works right.'); + + $wrapper2 = unserialize(serialize($wrapper)); + // Test serializing the unloaded wrapper. + $this->assertEqual(unserialize(serialize($wrapper2))->value()->title, $node->title, 'Serializing works right.'); + + // Test loading a not more existing node. + $s = serialize($wrapper2); + node_delete($node->nid); + $this->assertFalse(node_load($node->nid), 'Node deleted.'); + try { + unserialize($s)->value(); + $this->fail("Loading hasn't created an exception."); + } + catch (EntityMetadataWrapperException $e) { + $this->pass("Exception was thrown: " . $e->getMessage()); + } + + // Test saving a savable custom, identifiable wrapper. + $action = rules_action('test_type_save'); + $node = $this->drupalCreateNode(array('status' => 0, 'type' => 'page')); + $node->status = 1; + $action->execute($node); + + // Load the node fresh from the db. + $node = node_load($node->nid, NULL, TRUE); + $this->assertEqual($node->status, 1, 'Savable non-entity has been saved.'); + } + +} + +/** + * Test triggering rules. + */ +class RulesTriggerTestCase extends DrupalWebTestCase { + + /** + * Declares test metadata. + */ + public static function getInfo() { + return array( + 'name' => 'Reaction Rules', + 'description' => 'Tests triggering reactive rules.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_test'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Helper function to create a test Rule. + */ + protected function createTestRule($action = TRUE, $event = 'node_presave') { + $rule = rules_reaction_rule(); + $rule->event($event) + ->condition(rules_condition('data_is', array('data:select' => 'node:status', 'value' => TRUE))->negate()) + ->condition('data_is', array('data:select' => 'node:type', 'value' => 'page')); + if ($action) { + $rule->action('rules_action_delete_node'); + } + return $rule; + } + + /** + * Tests CRUD for reaction rules - making sure the events are stored properly. + */ + public function testReactiveRuleCreation() { + $rule = $this->createTestRule(); + $rule->save(); + $result = db_query("SELECT event FROM {rules_trigger} WHERE id = :id", array(':id' => $rule->id)); + $this->assertEqual($result->fetchField(), 'node_presave', 'Associated event has been saved.'); + // Try updating. + $rule->removeEvent('node_presave'); + $rule->event('node_insert'); + $rule->event('node_update'); + $rule->active = FALSE; + $rule->integrityCheck()->save(); + $result = db_query("SELECT event FROM {rules_trigger} WHERE id = :id", array(':id' => $rule->id)); + $this->assertEqual($result->fetchCol(), array_values($rule->events()), 'Updated associated events.'); + // Try deleting. + $rule->delete(); + $result = db_query("SELECT event FROM {rules_trigger} WHERE id = :id", array(':id' => $rule->id)); + $this->assertEqual($result->fetchField(), FALSE, 'Deleted associated events.'); + } + + /** + * Tests creating and triggering a basic reaction rule. + */ + public function testBasicReactionRule() { + $node = $this->drupalCreateNode(array('type' => 'page')); + $rule = $this->createTestRule(); + $rule->integrityCheck()->save(); + // Test the basics of the event set work right. + $event = rules_get_cache('event_node_presave'); + $this->assertEqual(array_keys($event->parameterInfo()), array('node'), 'EventSet returns correct argument info.'); + + // Trigger the rule by updating the node. + $nid = $node->nid; + $node->status = 0; + node_save($node); + + RulesLog::logger()->checkLog(); + $this->assertFalse(node_load($nid), 'Rule successfully triggered and executed'); + // debug(RulesLog::logger()->render()); + } + + /** + * Tests a rule using a handler to load a variable. + */ + public function testVariableHandler() { + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + $rule = $this->createTestRule(FALSE, 'node_update'); + $rule->action('rules_node_publish_action_save', array('node:select' => 'node_unchanged')); + // Test without recursion prevention to make sure recursive invocations + // work right too. This rule won't ran in an infinite loop anyway. + $rule->recursion = TRUE; + $rule->label = 'rule 1'; + $rule->integrityCheck()->save(); + + $node->status = 0; + $node->sticky = 1; + node_save($node); + + RulesLog::logger()->checkLog(); + entity_get_controller('node')->resetCache(); + $node = node_load($node->nid); + + $this->assertFalse($node->sticky, 'Parameter has been loaded and saved.'); + $this->assertTrue($node->status, 'Action has been executed.'); + + // Ensure the rule was evaluated a second time. + $text = RulesLog::logger()->render(); + $msg = RulesTestCase::t('Evaluating conditions of rule %rule 1', array('rule 1')); + $pos = strpos($text, $msg); + $pos = ($pos !== FALSE) ? strpos($text, $msg, $pos) : FALSE; + $this->assertTrue($pos !== FALSE, "Recursion prevented."); + // debug(RulesLog::logger()->render()); + } + + /** + * Tests aborting silently when handlers are not able to load. + */ + public function testVariableHandlerFailing() { + $rule = $this->createTestRule(FALSE, 'node_presave'); + $rule->action('rules_node_publish_action_save', array('node:select' => 'node_unchanged')); + $rule->integrityCheck()->save(); + + // On insert it's not possible to get the unchanged node during presave. + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + + // debug(RulesLog::logger()->render()); + $text = RulesTestCase::t('Unable to load variable %node_unchanged, aborting.', array('node_unchanged')); + $this->assertTrue(strpos(RulesLog::logger()->render(), $text) !== FALSE, "Aborted evaluation."); + } + + /** + * Tests preventing recursive rule invocations. + * + * Creates a rule that reacts on node-update then generates a node update + * that would trigger it itself. + */ + public function testRecursionPrevention() { + $rule = $this->createTestRule(FALSE, 'node_update'); + $rule->action('rules_node_make_sticky_action'); + $rule->integrityCheck()->save(); + + // Now trigger the rule. + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + node_save($node); + + $text = RulesTestCase::t('Not evaluating reaction rule %label to prevent recursion.', array('label' => $rule->name)); + // debug(RulesLog::logger()->render()); + $this->assertTrue((strpos(RulesLog::logger()->render(), $text) !== FALSE), "Recursion prevented."); + // debug(RulesLog::logger()->render()); + } + + /** + * Tests recursion prevention with altered arguments. + * + * Ensure the recursion prevention still allows the rule to trigger again + * during evaluation of the same event set, if the event isn't caused by the + * rule itself - thus we won't run in an infinite loop. + */ + public function testRecursionOnDifferentArguments() { + // Create rule1 - which might recurse. + $rule = $this->createTestRule(FALSE, 'node_update'); + $rule->action('rules_node_make_sticky_action'); + $rule->label = 'rule 1'; + $rule->integrityCheck()->save(); + + // Create rule2 - which triggers rule1 on another node. + $node2 = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + $rule2 = $this->createTestRule(FALSE, 'node_update'); + $rule2->action('rules_action_load_node', array('nid' => $node2->nid)) + ->action('rules_node_make_sticky_action', array('node:select' => 'node_loaded')); + $rule2->label = 'rule 2'; + $rule2->save(); + + // Now trigger both rules by generating the event. + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + node_save($node); + + // debug(RulesLog::logger()->render()); + $text = RulesLog::logger()->render(); + $pos = strpos($text, RulesTestCase::t('Evaluating conditions of rule %rule 1', array('rule 1'))); + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Evaluating conditions of rule %rule 2', array('rule 2')), $pos) : FALSE; + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Saved %node_loaded of type %node.', array('node_loaded', 'node')), $pos) : FALSE; + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Evaluating conditions of rule %rule 1', array('rule 1')), $pos) : FALSE; + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Not evaluating reaction rule %rule 2 to prevent recursion', array('rule 2')), $pos) : FALSE; + $this->assertTrue($pos !== FALSE, 'Rule1 was triggered on the event caused by Rule2.'); + } + + /** + * Tests the provided default rule 'rules_test_default_1'. + */ + public function testDefaultRule() { + $rule = rules_config_load('rules_test_default_1'); + $this->assertTrue($rule->status & ENTITY_IN_CODE && !($rule->status & ENTITY_IN_DB), 'Default rule can be loaded and has the right status.'); + $this->assertTrue($rule->tags == array('Admin', 'Tag2'), 'Default rule has correct tags.'); + // Enable. + $rule->active = TRUE; + $rule->save(); + + // Create a node that triggers the rule. + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + // Clear messages. + drupal_get_messages(); + // Let event node_update occur. + node_save($node); + + $msg = drupal_get_messages(); + $this->assertEqual($msg['status'][0], 'A node has been updated.', 'Default rule has been triggered.'); + } + + /** + * Tests creating and triggering a reaction rule with event settings. + */ + public function testEventSettings() { + $rule = rules_reaction_rule(); + $rule->event('node_presave', array('bundle' => 'article')) + ->condition('data_is_empty', array('data:select' => 'node:field-tags')) + ->action('node_publish', array('node:select' => 'node')); + $rule->integrityCheck()->save(); + + $node = $this->drupalCreateNode(array('type' => 'page', 'status' => 0)); + $this->assertEqual($node->status, 0, 'Rule has not been triggered.'); + $node = $this->drupalCreateNode(array('type' => 'article', 'status' => 0)); + $this->assertEqual($node->status, 1, 'Rule has been triggered.'); + RulesLog::logger()->checkLog(); + + // Make sure an invalid bundle raises integrity problems. + $rule->event('node_presave', array('bundle' => 'invalid')); + try { + $rule->integrityCheck(); + $this->fail('Integrity check failed.'); + } + catch (RulesIntegrityException $e) { + $this->pass('Integrity check failed: ' . $e); + } + } + +} + +/** + * Tests provided module integration. + */ +class RulesIntegrationTestCase extends DrupalWebTestCase { + + /** + * Declares test metadata. + */ + public static function getInfo() { + return array( + 'name' => 'Rules Core Integration', + 'description' => 'Tests provided integration for drupal core.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_test', 'php', 'path'); + RulesLog::logger()->clear(); + variable_set('rules_debug_log', TRUE); + } + + /** + * Just makes sure the access callback run without errors. + */ + public function testAccessCallbacks() { + $cache = rules_get_cache(); + foreach (array('action', 'condition', 'event') as $type) { + foreach (rules_fetch_data($type . '_info') as $name => $info) { + if (isset($info['access callback'])) { + $info['access callback']($type, $name); + } + } + } + } + + /** + * Tests data integration. + */ + public function testDataIntegration() { + // Test data_create action. + $action = rules_action('data_create', array( + 'type' => 'log_entry', + 'param_type' => 'rules_test', + 'param_message' => 'Rules test log message', + 'param_severity' => WATCHDOG_WARNING, + 'param_request_uri' => 'http://example.com', + 'param_link' => '', + )); + $action->access(); + $action->execute(); + $text = RulesLog::logger()->render(); + $pos = strpos($text, RulesTestCase::t('Added the provided variable %data_created of type %log_entry', array('data_created', 'log_entry'))); + $this->assertTrue($pos !== FALSE, 'Data of type log entry has been created.'); + + // Test variable_add action. + $action = rules_action('variable_add', array( + 'type' => 'text_formatted', + 'value' => array( + 'value' => 'test text', + 'format' => 1, + ), + )); + $action->access(); + $action->execute(); + $text = RulesLog::logger()->render(); + $pos = strpos($text, RulesTestCase::t('Added the provided variable %variable_added of type %text_formatted', array('variable_added', 'text_formatted'))); + $this->assertTrue($pos !== FALSE, 'Data of type text formatted has been created.'); + + // Test using the list actions. + $rule = rule(array( + 'list' => array( + 'type' => 'list', + 'label' => 'A list of text', + ), + )); + $rule->action('list_add', array('list:select' => 'list', 'item' => 'bar2')); + $rule->action('list_add', array('list:select' => 'list', 'item' => 'bar', 'pos' => 'start')); + $rule->action('list_add', array('list:select' => 'list', 'item' => 'bar', 'unique' => TRUE)); + $rule->action('list_remove', array('list:select' => 'list', 'item' => 'bar2')); + $list = entity_metadata_wrapper('list', array('foo', 'foo2')); + $rule->execute($list); + RulesLog::logger()->checkLog(); + $this->assertEqual($list->value(), array('bar', 'foo', 'foo2'), 'List items removed and added.'); + $this->assertFalse(rules_condition('list_contains')->execute($list, 'foo-bar'), 'Condition "List item contains" evaluates to FALSE'); + $this->assertTrue(rules_condition('list_contains')->execute($list, 'foo'), 'Condition "List item contains" evaluates to TRUE'); + // debug(RulesLog::logger()->render()); + + // Test data_is condition with IN operation. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('data_is', array('data:select' => 'node:title', 'op' => 'IN', 'value' => array('foo', 'bar'))); + $rule->action('data_set', array('data:select' => 'node:title', 'value' => 'bar')); + $rule->integrityCheck(); + + $node = $this->drupalCreateNode(array('title' => 'foo')); + $rule->execute($node); + $this->assertEqual($node->title, 'bar', "Data comparison using IN operation evaluates to TRUE."); + + // Test Condition: Data is empty. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('data_is_empty', array('data:select' => 'node:title')); + $rule->action('data_set', array('data:select' => 'node:title', 'value' => 'bar')); + $rule->integrityCheck(); + + // Data is empty condition evaluates to TRUE + // for node with empty title, action sets title to 'bar'. + $node = $this->drupalCreateNode(array('title' => '', 'type' => 'article')); + $rule->execute($node); + $this->assertEqual($node->title, 'bar', "Data is empty condition evaluates to TRUE for node with empty title, action sets title to 'bar'."); + + // Data is empty condition evaluates to FALSE + // for node with title 'foo', action is not executed. + $node = $this->drupalCreateNode(array('title' => 'foo', 'type' => 'article')); + $rule->execute($node); + $this->assertEqual($node->title, 'foo', "Data is empty condition evaluates to FALSE for node with title 'foo', action is not executed."); + + // Data is empty condition evaluates to TRUE for the parent of a + // not existing term in the tags field of the node. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('node_is_of_type', array('type' => array('article'))); + $rule->condition('data_is_empty', array('data:select' => 'node:field-tags:0:parent')); + $rule->action('data_set', array('data:select' => 'node:title', 'value' => 'bar')); + $rule->integrityCheck(); + $node = $this->drupalCreateNode(array('title' => 'foo', 'type' => 'article')); + $rule->execute($node); + $this->assertEqual($node->title, 'bar', "Data is empty condition evaluates to TRUE for not existing data structures"); + + // Test Action: Calculate a value. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->action('data_calc', array('input_1:select' => 'node:nid', 'op' => '*', 'input_2' => 2)); + $rule->action('data_set', array('data:select' => 'node:title', 'value:select' => 'result')); + $rule->integrityCheck(); + $rule->execute($node); + $this->assertEqual($node->title, $node->nid * 2, "Value has been calculated."); + + // Test moving a date. + $action_set = rules_action_set(array('date' => array('type' => 'date')), array('date')); + $action_set->action('data_calc', array('input_1:select' => 'date', 'op' => '+', 'input_2' => 3600)) + ->action('data_set', array('data:select' => 'date', 'value:select' => 'result')); + $action_set->integrityCheck(); + list($result) = $action_set->execute(REQUEST_TIME); + $this->assertEqual($result, REQUEST_TIME + 3600, 'Used data calculation action to move a date by an hour.'); + + // Test data type conversion action. + $set = rules_action_set(array('result' => array('type' => 'text', 'parameter' => FALSE)), array('result')); + $set->action('data_convert', array('type' => 'text', 'value:select' => 'site:login-url')); + $set->action('data_set', array('data:select' => 'result', 'value:select' => 'conversion_result')); + list($result) = $set->execute(); + $set->integrityCheck(); + $this->assertEqual($result, url('user', array('absolute' => TRUE)), 'Converted URI to text.'); + + $set = rules_action_set(array( + 'result' => array('type' => 'integer', 'parameter' => FALSE), + 'source' => array('type' => 'text'), + ), array('result')); + $set->action('data_convert', array('type' => 'integer', 'value:select' => 'source')); + $set->action('data_set', array('data:select' => 'result', 'value:select' => 'conversion_result')); + list($result) = $set->execute('9.4'); + $this->assertEqual($result, 9, 'Converted decimal to integer using rounding.'); + + $set = rules_action_set(array( + 'result' => array('type' => 'integer', 'parameter' => FALSE), + 'source' => array('type' => 'text'), + ), array('result')); + $set->action('data_convert', array('type' => 'integer', 'value:select' => 'source', 'rounding_behavior' => 'down')); + $set->action('data_set', array('data:select' => 'result', 'value:select' => 'conversion_result')); + list($result) = $set->execute('9.6'); + $this->assertEqual($result, 9, 'Converted decimal to integer using rounding behavior down.'); + + $set = rules_action_set(array( + 'result' => array('type' => 'integer', 'parameter' => FALSE), + 'source' => array('type' => 'text'), + ), array('result')); + $set->action('data_convert', array('type' => 'integer', 'value:select' => 'source', 'rounding_behavior' => 'up')); + $set->action('data_set', array('data:select' => 'result', 'value:select' => 'conversion_result')); + list($result) = $set->execute('9.4'); + $this->assertEqual($result, 10, 'Converted decimal to integer using rounding behavior up.'); + + // Test text matching condition. + $result = rules_condition('text_matches')->execute('my-text', 'text', 'contains'); + $result2 = rules_condition('text_matches')->execute('my-text', 'tex2t', 'contains'); + $this->assertTrue($result && !$result2, 'Text matching condition using operation contain evaluated.'); + + $result = rules_condition('text_matches')->execute('my-text', 'my', 'starts'); + $result2 = rules_condition('text_matches')->execute('my-text', 'text', 'starts'); + $this->assertTrue($result && !$result2, 'Text matching condition using operation starts evaluated.'); + + $result = rules_condition('text_matches')->execute('my-text', 'text', 'ends'); + $result2 = rules_condition('text_matches')->execute('my-text', 'my', 'ends'); + $this->assertTrue($result && !$result2, 'Text matching condition using operation ends evaluated.'); + + $result = rules_condition('text_matches')->execute('my-text', 'me?y-texx?t', 'regex'); + $result2 = rules_condition('text_matches')->execute('my-text', 'me+y-texx?t', 'regex'); + $this->assertTrue($result && !$result2, 'Text matching condition using operation regex evaluated.'); + } + + /** + * Tests entity related integration. + */ + public function testEntityIntegration() { + global $user; + + $page = $this->drupalCreateNode(array('type' => 'page')); + $article = $this->drupalCreateNode(array('type' => 'article')); + + $result = rules_condition('entity_field_access') + ->execute(entity_metadata_wrapper('node', $article), 'field_tags'); + $this->assertTrue($result); + + // Test entity_is_of_bundle condition. + $result = rules_condition('entity_is_of_bundle', array( + 'type' => 'node', + 'bundle' => array('article'), + ))->execute(entity_metadata_wrapper('node', $page)); + $this->assertFalse($result, 'Entity is of bundle condition has not been met.'); + $result = rules_condition('entity_is_of_bundle', array( + 'type' => 'node', + 'bundle' => array('article'), + ))->execute(entity_metadata_wrapper('node', $article)); + $this->assertTrue($result, 'Entity is of bundle condition has been met.'); + + // Also test a full rule so the integrity check must work. + $term_wrapped = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->save(); + $rule = rule(array( + 'node' => array('type' => 'node'), + )); + $rule->condition('entity_is_of_bundle', array( + 'entity:select' => 'node', + 'bundle' => array('article'), + )); + $rule->action('data_set', array('data:select' => 'node:field_tags', 'value' => array($term_wrapped->getIdentifier()))); + $rule->integrityCheck(); + $rule->execute($article); + $this->assertEqual($term_wrapped->getIdentifier(), $article->field_tags[LANGUAGE_NONE][0]['tid'], 'Entity is of bundle condition has been met.'); + + // Test again using an entity variable. + $article = $this->drupalCreateNode(array('type' => 'article')); + $rule = rule(array( + 'entity' => array('type' => 'entity'), + )); + $rule->condition('entity_is_of_bundle', array( + 'entity:select' => 'entity', + 'type' => 'node', + 'bundle' => array('article'), + )); + $rule->action('data_set', array('data:select' => 'entity:field_tags', 'value' => array($term_wrapped->getIdentifier()))); + $rule->integrityCheck(); + $rule->execute(entity_metadata_wrapper('node', $article)); + $this->assertEqual($term_wrapped->getIdentifier(), $article->field_tags[LANGUAGE_NONE][0]['tid'], 'Entity is of bundle condition has been met.'); + + // Test CRUD actions. + $action = rules_action('entity_create', array( + 'type' => 'node', + 'param_type' => 'page', + 'param_title' => 'foo', + 'param_author' => $GLOBALS['user'], + )); + $action->access(); + $action->execute(); + $text = RulesLog::logger()->render(); + $pos = strpos($text, RulesTestCase::t('Added the provided variable %entity_created of type %node', array('entity_created', 'node'))); + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Saved %entity_created of type %node.', array('entity_created', 'node')), $pos) : FALSE; + $this->assertTrue($pos !== FALSE, 'Data has been created and saved.'); + + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'sticky' => 0, + 'status' => 0, + )); + $rule = rule(); + $rule->action('entity_fetch', array('type' => 'node', 'id' => $node->nid, 'entity_fetched:var' => 'node')); + $rule->action('entity_save', array('data:select' => 'node', 'immediate' => TRUE)); + $rule->action('entity_delete', array('data:select' => 'node')); + $rule->access(); + $rule->integrityCheck()->execute(); + + $text = RulesLog::logger()->render(); + $pos = strpos($text, RulesTestCase::t('Evaluating the action %entity_fetch.', array('entity_fetch' => 'Fetch entity by id'))); + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Added the provided variable %node of type %node', array('node')), $pos) : FALSE; + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Saved %node of type %node.', array('node')), $pos) : FALSE; + $pos = ($pos !== FALSE) ? strpos($text, RulesTestCase::t('Evaluating the action %entity_delete.', array('entity_delete' => 'Delete entity')), $pos) : FALSE; + $this->assertTrue($pos !== FALSE, 'Data has been fetched, saved and deleted.'); + // debug(RulesLog::logger()->render()); + + $node = entity_property_values_create_entity('node', array( + 'type' => 'article', + 'author' => $user, + 'title' => 'foo', + ))->value(); + $term_wrapped = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->save(); + + // Test asserting the field and using it afterwards. + $rule = rule(array('node' => array('type' => 'node'))); + $rule->condition('entity_has_field', array('entity:select' => 'node', 'field' => 'field_tags')); + $rule->condition('entity_is_new', array('entity:select' => 'node')); + $rule->action('list_add', array('list:select' => 'node:field-tags', 'item' => $term_wrapped)); + $rule->integrityCheck(); + $rule->execute($node); + + $tid = $term_wrapped->getIdentifier(); + $this->assertEqual(array_values($node->field_tags[LANGUAGE_NONE]), array(0 => array('tid' => $tid)), 'Entity has field conditions evaluted.'); + + // Test loading a non-node entity. + $action = rules_action('entity_fetch', array('type' => 'taxonomy_term', 'id' => $tid)); + list($term) = $action->execute(); + $this->assertEqual($term->tid, $tid, 'Fetched a taxonomy term using "entity_fetch".'); + + // Test the entity is of type condition. + $rule = rule(array('entity' => array('type' => 'entity', 'label' => 'entity'))); + $rule->condition('entity_is_of_type', array('type' => 'node')); + $rule->action('data_set', array('data:select' => 'entity:title', 'value' => 'bar')); + $rule->integrityCheck(); + $rule->execute(entity_metadata_wrapper('node', $node)); + + $this->assertEqual(entity_metadata_wrapper('node', $node->nid)->title->value(), 'bar', 'Entity is of type condition correctly asserts the entity type.'); + + // Test the entity_query action. + $node = $this->drupalCreateNode(array('type' => 'page', 'title' => 'foo2')); + $rule = rule(); + $rule->action('entity_query', array('type' => 'node', 'property' => 'title', 'value' => 'foo2')) + ->action('data_set', array('data:select' => 'entity_fetched:0:title', 'value' => 'bar')); + $rule->access(); + $rule->integrityCheck(); + $rule->execute(); + $node = node_load($node->nid); + $this->assertEqual('bar', $node->title, 'Fetched a node by title and modified it.'); + + RulesLog::logger()->checkLog(); + } + + /** + * Tests integration for the taxonomy module. + */ + public function testTaxonomyIntegration() { + $term = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->value(); + $term2 = clone $term; + taxonomy_term_save($term); + taxonomy_term_save($term2); + + $tags[LANGUAGE_NONE][0]['tid'] = $term->tid; + $node = $this->drupalCreateNode(array( + 'title' => 'foo', + 'type' => 'article', + 'field_tags' => $tags, + )); + + // Test assigning and remove a term from an article. + $rule = rule(array('node' => array('type' => 'node', 'bundle' => 'article'))); + $term_wrapped = rules_wrap_data($term->tid, array('type' => 'taxonomy_term')); + $term_wrapped2 = rules_wrap_data($term2->tid, array('type' => 'taxonomy_term')); + $rule->action('list_add', array('list:select' => 'node:field-tags', 'item' => $term_wrapped2)); + $rule->action('list_remove', array('list:select' => 'node:field-tags', 'item' => $term_wrapped)); + $rule->execute($node); + RulesLog::logger()->checkLog(); + $this->assertEqual(array_values($node->field_tags[LANGUAGE_NONE]), array(0 => array('tid' => $term2->tid)), 'Term removed and added from a node.'); + + // Test using the taxonomy term reference field on a term object. + $field_name = drupal_strtolower($this->randomName() . '_field_name'); + $field = field_create_field(array( + 'field_name' => $field_name, + 'type' => 'taxonomy_term_reference', + // Set cardinality to unlimited for tagging. + 'cardinality' => FIELD_CARDINALITY_UNLIMITED, + 'settings' => array( + 'allowed_values' => array( + array( + 'vocabulary' => 'tags', + 'parent' => 0, + ), + ), + ), + )); + $instance = array( + 'field_name' => $field_name, + 'entity_type' => 'taxonomy_term', + 'bundle' => 'tags', // Machine name of vocabulary. + 'label' => $this->randomName() . '_label', + 'description' => $this->randomName() . '_description', + 'weight' => mt_rand(0, 127), + 'widget' => array( + 'type' => 'taxonomy_autocomplete', + 'weight' => -4, + ), + 'display' => array( + 'default' => array( + 'type' => 'taxonomy_term_reference_link', + 'weight' => 10, + ), + ), + ); + field_create_instance($instance); + + $term1 = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->save(); + $term2 = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->save(); + + // Test asserting the term reference field and using it afterwards. + $rule = rule(array('taxonomy_term' => array('type' => 'taxonomy_term'))); + $rule->condition('entity_has_field', array('entity:select' => 'taxonomy-term', 'field' => $field_name)); + // Add $term2 to $term1 using the term reference field. + $selector = str_replace('_', '-', 'taxonomy_term:' . $field_name); + $rule->action('list_add', array('list:select' => $selector, 'item' => $term2)); + $rule->integrityCheck(); + $rule->execute($term1); + + RulesLog::logger()->checkLog(); + $this->assertEqual($term1->{$field_name}[0]->getIdentifier(), $term2->getIdentifier(), 'Rule appended a term to the term reference field on a term.'); + + // Test an action set for merging term parents, which is provided as default + // config. + $term = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + 'parent' => array($term1->value()), + ))->save(); + + $action = rules_action('component_rules_retrieve_term_parents'); + list($parents) = $action->execute(array($term->getIdentifier())); + $this->assertTrue($parents[0]->tid == $term1->getIdentifier(), 'Invoked component to retrieve term parents.'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests integration for the node module. + */ + public function testNodeIntegration() { + $tests = array( + array('node_unpublish', 'node_is_published', 'node_publish', 'status'), + array('node_make_unsticky', 'node_is_sticky', 'node_make_sticky', 'sticky'), + array('node_unpromote', 'node_is_promoted', 'node_promote', 'promote'), + ); + $node = $this->drupalCreateNode(array( + 'type' => 'page', + 'status' => 1, + 'sticky' => 1, + 'promote' => 1, + )); + + foreach ($tests as $info) { + list($action1, $condition, $action2, $property) = $info; + rules_action($action1)->execute($node); + + $node = node_load($node->nid, NULL, TRUE); + $this->assertFalse($node->$property, 'Action has permanently disabled node ' . $property); + $return = rules_condition($condition)->execute($node); + $this->assertFalse($return, 'Condition determines node ' . $property . ' is disabled.'); + + rules_action($action2)->execute($node); + $node = node_load($node->nid, NULL, TRUE); + $this->assertTrue($node->$property, 'Action has permanently enabled node ' . $property); + $return = rules_condition($condition)->execute($node); + $this->assertTrue($return, 'Condition determines node ' . $property . ' is enabled.'); + } + + $return = rules_condition('node_is_of_type', array('type' => array('page', 'article')))->execute($node); + $this->assertTrue($return, 'Condition determines node is of type page.'); + $return = rules_condition('node_is_of_type', array('type' => array('article')))->execute($node); + $this->assertFalse($return, 'Condition determines node is not of type article.'); + + // Test auto saving of a new node after it has been inserted into the DB. + $rule = rules_reaction_rule(); + $rand = $this->randomName(); + $rule->event('node_insert') + ->action('data_set', array('data:select' => 'node:title', 'value' => $rand)); + $rule->save('test'); + $node = $this->drupalCreateNode(); + $node = node_load($node->nid); + $this->assertEqual($node->title, $rand, 'Node title is correct.'); + RulesLog::logger()->checkLog(); + } + + /** + * Tests integration for the user module. + */ + public function testUserIntegration() { + $rid = $this->drupalCreateRole(array('administer nodes'), 'foo'); + $user = $this->drupalCreateUser(); + + // Test assigning a role with the list_add action. + $rule = rule(array('user' => array('type' => 'user'))); + $rule->action('list_add', array('list:select' => 'user:roles', 'item' => $rid)); + $rule->execute($user); + $this->assertTrue(isset($user->roles[$rid]), 'Role assigned to user.'); + + // Test removing a role with the list_remove action. + $rule = rule(array('user' => array('type' => 'user'))); + $rule->action('list_remove', array('list:select' => 'user:roles', 'item' => $rid)); + $rule->execute($user); + $this->assertTrue(!isset($user->roles[$rid]), 'Role removed from user.'); + + // Test assigning a role with user_add_role action. + $rule = rule(array('user' => array('type' => 'user'))); + $rule->action('user_add_role', array('account:select' => 'user', 'roles' => array($rid))); + $rule->execute($user); + + $user = user_load($user->uid, TRUE); + $result = rules_condition('user_has_role', array('roles' => array($rid)))->execute($user); + $this->assertTrue($result, 'Role assigned to user.'); + + // Test removing a role with the user_remove_role action. + $rule = rule(array('user' => array('type' => 'user'))); + $rule->action('user_remove_role', array('account:select' => 'user', 'roles' => array($rid))); + $rule->execute($user); + + $user = user_load($user->uid, TRUE); + $result = rules_condition('user_has_role', array('roles' => array($rid)))->execute($user); + $this->assertFalse($result, 'Role removed from user.'); + + // Test user blocking. + rules_action('user_block')->execute($user); + $user = user_load($user->uid, TRUE); + $this->assertTrue(rules_condition('user_is_blocked')->execute($user), 'User has been blocked.'); + + rules_action('user_unblock')->execute($user); + $user = user_load($user->uid, TRUE); + $this->assertFalse(rules_condition('user_is_blocked')->execute($user), 'User has been unblocked.'); + + RulesLog::logger()->checkLog(); + } + + /** + * Tests integration for the php module. + */ + public function testPHPIntegration() { + $node = $this->drupalCreateNode(array('title' => 'foo')); + $rule = rule(array('var_name' => array('type' => 'node'))); + $rule->condition('php_eval', array('code' => 'return TRUE;')) + ->action('php_eval', array('code' => 'drupal_set_message("Executed-" . $var_name->title);')) + ->action('drupal_message', array('message' => 'Title: title; ?> Token: [var_name:title]')); + $rule->execute($node); + $rule->access(); + RulesLog::logger()->checkLog(); + $msg = drupal_get_messages(); + $this->assertEqual(array_pop($msg['status']), "Title: foo Token: foo", 'PHP input evaluation has been applied.'); + $this->assertEqual(array_pop($msg['status']), "Executed-foo", 'PHP code condition and action have been evaluated.'); + + // Test PHP data processor. + $rule = rule(array('var_name' => array('type' => 'node'))); + $rule->action('drupal_message', array( + 'message:select' => 'var_name:title', + 'message:process' => array( + 'php' => array('code' => 'return "Title: $value";'), + ), + )); + $rule->execute($node); + $rule->access(); + RulesLog::logger()->checkLog(); + $msg = drupal_get_messages(); + $this->assertEqual(array_pop($msg['status']), "Title: foo", 'PHP data processor has been applied.'); + } + + /** + * Tests the "rules_core" integration. + */ + public function testRulesCoreIntegration() { + // Make sure the date input evaluator evaluates properly using strtotime(). + $node = $this->drupalCreateNode(array('title' => 'foo')); + $rule = rule(array('node' => array('type' => 'node'))); + $rule->action('data_set', array('data:select' => 'node:created', 'value' => '+1 day')); + + $rule->execute($node); + RulesLog::logger()->checkLog(); + $node = node_load($node->nid, NULL, TRUE); + $now = RulesDateInputEvaluator::gmstrtotime('now'); + // Tolerate a difference of a second. + $this->assertTrue(abs($node->created - $now - 86400) <= 1, 'Date input has been evaluated.'); + + // Test using a numeric offset. + $rule = rule(array('number' => array('type' => 'decimal')), array('number')); + $rule->action('data_set', array( + 'data:select' => 'number', + 'value:select' => 'number', + 'value:process' => array( + 'num_offset' => array('value' => 1), + ), + )); + $rule->integrityCheck(); + list($result) = $rule->execute(10); + $this->assertTrue($result == 11, 'Numeric offset has been applied'); + + // Test using a date offset. + $set = rules_action_set(array('date' => array('type' => 'date')), array('date')); + $set->action('data_set', array( + 'data:select' => 'date', + 'value:select' => 'date', + 'value:process' => array( + 'date_offset' => array('value' => 1000), + ), + )); + $date = date_create("14 Mar 1984 10:19:23 +01:00")->format('U'); + list($result) = $set->execute($date); + $this->assertEqual($result, $date + 1000, 'Date offset in seconds has been added.'); + + // Test using a negative offset of 2 months. + $set = rules_action_set(array('date' => array('type' => 'date')), array('date')); + $set->action('data_set', array( + 'data:select' => 'date', + 'value:select' => 'date', + 'value:process' => array( + 'date_offset' => array('value' => -86400 * 30 * 2), + ), + )); + $date = date_create("14 Mar 1984 10:19:23 +01:00")->format('U'); + list($result) = $set->execute($date); + $this->assertEqual($result, date_create("14 Jan 1984 10:19:23 +01:00")->format('U'), 'Date offset of -2 months has been added.'); + + // Test using a positive offset of 1 year 6 months and 30 minutes. + $set = rules_action_set(array('date' => array('type' => 'date')), array('date')); + $set->action('data_set', array( + 'data:select' => 'date', + 'value:select' => 'date', + 'value:process' => array( + 'date_offset' => array('value' => 86400 * 30 * 18 + 30 * 60), + ), + )); + $date = date_create("14 Mar 1984 10:19:23 +01:00")->format('U'); + list($result) = $set->execute($date); + $this->assertEqual($result, date_create("14 Sep 1985 10:49:23 +01:00")->format('U'), 'Date offset of 1 year 6 months and 30 minutes has been added.'); + + RulesLog::logger()->checkLog(); + } + + /** + * Tests site/system integration. + */ + public function testSystemIntegration() { + // Test using the 'site' variable. + $condition = rules_condition('data_is', array('data:select' => 'site:current-user:name', 'value' => $GLOBALS['user']->name)); + $this->assertTrue($condition->execute(), 'Retrieved the current user\'s name.'); + // Another test using a token replacement. + $condition = rules_condition('data_is', array('data:select' => 'site:current-user:name', 'value' => '[site:current-user:name]')); + $this->assertTrue($condition->execute(), 'Replaced the token for the current user\'s name.'); + + // Test breadcrumbs and drupal set message. + $rule = rules_reaction_rule(); + $rule->event('init') + ->action('breadcrumb_set', array('titles' => array('foo'), 'paths' => array('bar'))) + ->action('drupal_message', array('message' => 'A message.')); + $rule->save('test'); + + $this->drupalGet('node'); + $this->assertLink('foo', 0, 'Breadcrumb has been set.'); + $this->assertText('A message.', 'Drupal message has been shown.'); + + // Test the page redirect. + $node = $this->drupalCreateNode(); + $rule = rules_reaction_rule(); + $rule->event('node_view') + ->action('redirect', array('url' => 'user')); + $rule->save('test2'); + + $this->drupalGet('node/' . $node->nid); + $this->assertEqual($this->getUrl(), url('user', array('absolute' => TRUE)), 'Redirect has been issued.'); + + // Also test using a url including a fragment. + $actions = $rule->actions(); + $actions[0]->settings['url'] = 'user#fragment'; + $rule->save(); + + $this->drupalGet('node/' . $node->nid); + $this->assertEqual($this->getUrl(), url('user', array('absolute' => TRUE, 'fragment' => 'fragment')), 'Redirect has been issued.'); + + // Test sending mail. + $settings = array('to' => 'mail@example.com', 'subject' => 'subject', 'message' => 'hello.'); + rules_action('mail', $settings)->execute(); + $this->assertMail('to', 'mail@example.com', 'Mail has been sent.'); + $this->assertMail('from', variable_get('site_mail', ini_get('sendmail_from')), 'Default from address has been used'); + + rules_action('mail', $settings + array('from' => 'sender@example.com'))->execute(); + $this->assertMail('from', 'sender@example.com', 'Specified from address has been used'); + + // Test sending mail to all users of a role. First clear the mail + // collector to remove the mail sent in the previous line of code. + variable_set('drupal_test_email_collector', array()); + + // Now make sure there is a custom role and two users with that role. + $user1 = $this->drupalCreateUser(array('administer nodes')); + $roles = $user1->roles; + // Remove the authenticated role so we only use the new role created by + // drupalCreateUser(). + unset($roles[DRUPAL_AUTHENTICATED_RID]); + + // Now create a second user with the same role. + $user2 = $this->drupalCreateUser(); + user_save($user2, array('roles' => $roles)); + + // Now create a third user without the same role - this user should NOT + // receive the role email. + $user3 = $this->drupalCreateUser(array('administer blocks')); + $additional_roles = $user3->roles; + unset($additional_roles[DRUPAL_AUTHENTICATED_RID]); + + // Execute action and check that only two mails were sent. + rules_action('mail_to_users_of_role', $settings + array('roles' => array_keys($roles)))->execute(); + $mails = $this->drupalGetMails(); + $this->assertEqual(count($mails), 2, '2 e-mails were sent to users of a role.'); + + // Check each mail to ensure that only $user1 and $user2 got the mail. + $mail = array_pop($mails); + $this->assertTrue($mail['to'] == $user2->mail, 'Mail to user of a role has been sent.'); + $mail = array_pop($mails); + $this->assertTrue($mail['to'] == $user1->mail, 'Mail to user of a role has been sent.'); + + // Execute action again, this time to send mail to both roles. + // This time check that three mails were sent - one for each user.. + variable_set('drupal_test_email_collector', array()); + rules_action('mail_to_users_of_role', $settings + array('roles' => array_keys($roles + $additional_roles)))->execute(); + $mails = $this->drupalGetMails(); + $this->assertEqual(count($mails), 3, '3 e-mails were sent to users of multiple roles.'); + + // Test reacting on new log entries and make sure the log entry is usable. + $rule = rules_reaction_rule(); + $rule->event('watchdog'); + $rule->action('drupal_message', array('message:select' => 'log_entry:message')); + $rule->integrityCheck()->save('test_watchdog'); + + watchdog('php', 'test %message', array('%message' => 'message')); + $msg = drupal_get_messages(); + $this->assertEqual(array_pop($msg['status']), t('test %message', array('%message' => 'message')), 'Watchdog event occurred and log entry properties can be used.'); + } + + /** + * Tests the path module integration. + */ + public function testPathIntegration() { + rules_action('path_alias')->execute('foo', 'bar'); + $path = path_load('foo'); + $this->assertTrue($path['alias'] == 'bar', 'URL alias has been created.'); + + $alias_exists = rules_condition('path_alias_exists', array('alias' => 'bar'))->execute(); + $this->assertTrue($alias_exists, 'Created URL alias exists.'); + + $has_alias = rules_condition('path_has_alias', array('source' => 'foo'))->execute(); + $this->assertTrue($has_alias, 'System path has an alias.'); + + // Test node alias action. + $node = $this->drupalCreateNode(); + rules_action('node_path_alias')->execute($node, 'test'); + $path = path_load("node/$node->nid"); + $this->assertTrue($path['alias'] == 'test', 'Node URL alias has been created.'); + + // Test term alias action. + $term = entity_property_values_create_entity('taxonomy_term', array( + 'name' => $this->randomName(), + 'vocabulary' => 1, + ))->value(); + rules_action('taxonomy_term_path_alias')->execute($term, 'term-test'); + $path = path_load("taxonomy/term/$term->tid"); + $this->assertTrue($path['alias'] == 'term-test', 'Term URL alias has been created.'); + + RulesLog::logger()->checkLog(); + } + +} + +/** + * Tests event dispatcher functionality. + */ +class RulesEventDispatcherTestCase extends DrupalWebTestCase { + + /** + * Declares test metadata. + */ + public static function getInfo() { + return array( + 'name' => 'Rules event dispatchers', + 'description' => 'Tests event dispatcher functionality.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('rules', 'rules_test'); + } + + /** + * Tests start and stop functionality. + */ + public function testStartAndStop() { + $handler = rules_get_event_handler('rules_test_event'); + $rule = rules_reaction_rule(); + $rule->event('rules_test_event'); + + // The handler should not yet be watching. + $this->assertFalse($handler->isWatching()); + + // Once saved, the event cache rebuild should start the watcher. + $rule->save(); + RulesEventSet::rebuildEventCache(); + $this->assertTrue($handler->isWatching()); + + // Deleting should stop the watcher. + $rule->delete(); + $this->assertFalse($handler->isWatching()); + } + + /** + * Tests start and stop functionality when used with multiple events. + */ + public function testStartAndStopMultiple() { + $handler = rules_get_event_handler('rules_test_event'); + + // Initially, the task handler should not be watching. + $this->assertFalse($handler->isWatching()); + + // Set up five rules that all use the same event. + $rules = array(); + foreach (array(1, 2, 3, 4, 5) as $key) { + $rules[$key] = rules_reaction_rule(); + $rules[$key]->event('rules_test_event'); + $rules[$key]->save(); + } + + // Once saved, the event cache rebuild should start the watcher. + RulesEventSet::rebuildEventCache(); + $this->assertTrue($handler->isWatching()); + + // It should continue watching until all events are deleted. + foreach ($rules as $key => $rule) { + $rule->delete(); + $this->assertEqual($key !== 5, $handler->isWatching()); + } + } + +} + +/** + * Test early bootstrap Rules invocation. + */ +class RulesInvocationEnabledTestCase extends DrupalWebTestCase { + + /** + * Declares test metadata. + */ + public static function getInfo() { + return array( + 'name' => 'Rules invocation enabled', + 'description' => 'Tests that Rules events are enabled during menu item loads.', + 'group' => 'Rules', + ); + } + + /** + * Overrides DrupalWebTestCase::setUp(). + */ + protected function setUp() { + parent::setUp('dblog', 'rules', 'rules_test', 'rules_test_invocation'); + } + + /** + * Tests that a Rules event is triggered on node menu item loading. + * + * @see rules_test_invocation_node_load() + */ + public function testInvocationOnNodeMenuLoading() { + // Create a test node. + $node = $this->drupalCreateNode(array('title' => 'Test')); + // Enable Rules logging on the INFO level so that entries are written to + // dblog. + variable_set('rules_log_errors', RulesLog::INFO); + // Create an empty rule that will fire in our node load hook. + $rule = rules_reaction_rule(); + $rule->event('rules_test_event'); + $rule->save('test_rule'); + + // Visit the node page which should trigger the load hook. + $this->drupalGet('node/' . $node->nid); + $result = db_query("SELECT * FROM {watchdog} WHERE type = 'rules' AND message = 'Reacting on event %label.'")->fetch(); + $this->assertFalse(empty($result), 'Rules event was triggered and logged.'); + } + +} diff --git a/tests/rules_test.info b/tests/rules_test.info new file mode 100644 index 0000000..0fdf309 --- /dev/null +++ b/tests/rules_test.info @@ -0,0 +1,12 @@ +name = "Rules Tests" +description = "Support module for the Rules tests." +package = Testing +core = 7.x +files[] = rules_test.rules.inc +hidden = TRUE + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/tests/rules_test.module b/tests/rules_test.module new file mode 100644 index 0000000..4c54faf --- /dev/null +++ b/tests/rules_test.module @@ -0,0 +1,58 @@ + t("Logged in user"), + 'description' => t("The currently logged in user."), + 'getter callback' => 'entity_metadata_system_get_properties', + 'access callback' => 'rules_test_no_access', + 'type' => 'user', + ); + + $properties =& $info['node']['properties']; + + $properties['reference'] = array( + 'label' => t("Referenced entity"), + 'getter callback' => 'rules_test_get_referenced_entity', + 'type' => 'entity', + ); + $properties['ref_nodes'] = array( + 'label' => t("Referenced nodes"), + 'getter callback' => 'rules_test_get_referenced_node', + 'type' => 'list', + ); +} + +/** + * Getter callback to get the referenced-entity property. + */ +function rules_test_get_referenced_entity($node) { + // For testing purposes we just return the node itself as property value. + return entity_metadata_wrapper('node', $node); +} + +/** + * Getter callback to get the referenced-node list-property. + */ +function rules_test_get_referenced_node($node) { + // For testing purposes we just return the node itself as property value. + return array($node->nid); +} + +/** + * Access callback. Returns TRUE except when $op == 'edit'. + */ +function rules_test_no_access($op) { + return $op == 'edit' ? FALSE : TRUE; +} diff --git a/tests/rules_test.rules.inc b/tests/rules_test.rules.inc new file mode 100644 index 0000000..3f302f4 --- /dev/null +++ b/tests/rules_test.rules.inc @@ -0,0 +1,382 @@ + array( + 'label' => t('Test event'), + 'class' => 'RulesTestEventHandler', + ), + ); +} + +/** + * Implements hook_rules_file_info(). + */ +function rules_test_rules_file_info() { + return array('rules_test.test'); +} + +/** + * Implements hook_rules_condition_info(). + */ +function rules_test_rules_condition_info() { + $items = array(); + $defaults = array( + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content')), + ), + 'group' => t('Node'), + ); + $items['rules_condition_content_is_type'] = array( + 'label' => t('Content has type'), + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content')), + 'type' => array('type' => 'list', 'label' => t('Content types')), + ), + 'help' => t('Evaluates to TRUE, if the given content has one of the selected content types.'), + ) + $defaults; + $items['rules_condition_content_is_published'] = $defaults + array( + 'label' => t('Content is published'), + ); + $items['rules_test_condition_true'] = array( + 'label' => t('Test condition returning true'), + 'group' => t('Rules test'), + ); + $items['rules_test_condition_false'] = array( + 'label' => t('Test condition returning false'), + 'group' => t('Rules test'), + ); + $items['rules_test_condition_apostrophe'] = array( + 'label' => t("Test use of an apostrophe (') in a condition label"), + 'group' => t('Rules test'), + ); + // A condition for testing passing entities wrapped. + $items['rules_test_condition_node_wrapped'] = array( + 'label' => t('Content is published'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Content'), + 'wrapped' => TRUE, + ), + ), + 'group' => t('Node'), + ); + return $items; +} + +/** + * Condition implementation returning true. + */ +function rules_test_condition_true($settings, $state, $element) { + if (!$element instanceof RulesCondition) { + throw new Exception('Rules element has not been passed to condition.'); + } + rules_log('condition true called'); + return TRUE; +} + +/** + * Condition implementation returning false. + */ +function rules_test_condition_false() { + rules_log('condition false called'); + return FALSE; +} + +/** + * Condition testing use of an apostrophe in a condition label. + * + * Specifically, we want to ensure that special characters do not show up as + * HTML-encoded in the user interface. + */ +function rules_test_condition_apostrophe($settings, $state, $element) { + if (!$element instanceof RulesCondition) { + throw new Exception('Rules element has not been passed to condition.'); + } + rules_log('condition apostrophe called'); + return TRUE; +} + +/** + * Condition implementation receiving the node wrapped. + */ +function rules_test_condition_node_wrapped($wrapper) { + return $wrapper instanceof EntityMetadataWrapper; +} + +/** + * Implements hook_rules_action_info(). + */ +function rules_test_rules_action_info() { + $items['rules_test_action'] = array( + 'label' => t('Test action'), + 'group' => t('Rules test'), + ); + return $items + array( + 'rules_node_publish_action' => array( + 'label' => t('Publish content, but do not save'), + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content')), + ), + 'callbacks' => array( + 'help' => 'rules_test_custom_help', + ), + 'base' => 'node_publish_action', + ), + 'rules_node_publish_action_save' => array( + 'label' => t('Publish content'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Content'), + 'save' => TRUE, + ), + ), + 'base' => 'node_publish_action', + ), + 'rules_node_make_sticky_action' => array( + 'label' => t('Make content sticky'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Content'), + 'save' => TRUE, + ), + ), + 'base' => 'node_make_sticky_action', + ), + // The same action again requiring a 'page' node. + 'rules_node_page_make_sticky_action' => array( + 'label' => t('Mage page content sticky'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Content'), + 'save' => TRUE, + 'bundles' => array('page'), + ), + ), + 'base' => 'node_make_sticky_action', + ), + 'rules_action_test_reference' => array( + 'label' => t('Change argument passed by reference'), + 'parameter' => array( + // For references working right, we need a data type with a wrapper. + 'arg' => array('type' => 'test'), + ), + ), + 'rules_action_load_node' => array( + 'label' => t('Fetch content by id'), + 'parameter' => array( + 'nid' => array('type' => 'integer', 'label' => t('Content ID')), + 'vid' => array( + 'type' => 'integer', + 'label' => t('Content Revision ID'), + 'description' => t("If you want to fetch a specific revision, specify it's revision id. Else leave it empty to fetch the currently active revision."), + 'optional' => TRUE, + ), + ), + 'provides' => array( + 'node_loaded' => array( + 'type' => 'node', + 'label' => t('Loaded content'), + 'label callback' => 'rules_action_load_node_variable_label', + ), + ), + 'group' => t('Node'), + 'access callback' => 'rules_node_integration_access', + ), + 'rules_action_delete_node' => array( + 'label' => t('Delete content'), + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content')), + ), + 'group' => t('Node'), + 'access callback' => 'rules_node_integration_access', + ), + // An action for testing named parameters. + 'rules_action_node_set_title' => array( + 'label' => t('Content'), + 'parameter' => array( + 'node' => array('type' => 'node', 'label' => t('Content')), + 'title' => array('type' => 'text', 'label' => t('Text')), + ), + 'named parameter' => TRUE, + 'group' => t('Node'), + 'access callback' => 'rules_node_integration_access', + ), + // Tests automatic saving with a non-entity data type. + 'test_type_save' => array( + 'base' => 'rules_test_type_save', + 'label' => t('Save test type'), + 'parameter' => array( + 'node' => array( + 'type' => 'rules_test_type', + 'label' => t('Test content'), + 'save' => TRUE, + ), + ), + 'group' => t('Node'), + ), + ); +} + +/** + * Test action doing nothing exception logging it has been called. + */ +function rules_test_action() { + rules_log('action called'); +} + +/** + * Action for testing writing class-based actions. + */ +class RulesTestClassAction extends RulesActionHandlerBase { + + /** + * Defines the action. + */ + public static function getInfo() { + return array( + 'name' => 'rules_test_class_action', + 'label' => t('Test class based action'), + 'group' => t('Node'), + 'parameter' => array( + 'node' => array( + 'type' => 'node', + 'label' => t('Node'), + ), + ), + ); + } + + /** + * Executes the action. + */ + public function execute($node) { + rules_log('Action called with node ' . $node->nid); + } + +} + +/** + * Implements hook_rules_data_info(). + */ +function rules_test_rules_data_info() { + return array( + 'rules_test_type' => array( + 'label' => t('test type'), + 'wrap' => TRUE, + 'wrapper class' => 'RulesTestTypeWrapper', + ), + ); +} + +/** + * Implements hook_rules_data_info_alter(). + */ +function rules_test_rules_data_info_alter(&$data_info) { + $data_info['log_entry']['creation callback'] = 'rules_action_data_create_array'; +} + +/** + * The custom wrapper class for the rules test type. + * + * For testing we internally just make use of nodes. + */ +class RulesTestTypeWrapper extends RulesIdentifiableDataWrapper implements RulesDataWrapperSavableInterface { + + /** + * Overrides RulesIdentifiableDataWrapper::extractIdentifier(). + */ + protected function extractIdentifier($data) { + return $data->nid; + } + + /** + * Overrides RulesIdentifiableDataWrapper::load(). + */ + protected function load($id) { + return node_load($id); + } + + /** + * Implements RulesDataWrapperSavableInterface::save(). + */ + public function save() { + node_save($this->value()); + } + +} + +/** + * Implements hook_rules_plugin_info(). + */ +function rules_test_rules_plugin_info() { + return array( + 'rules test container' => array( + 'label' => t('Test container'), + 'class' => 'RulesTestContainer', + 'embeddable' => 'RulesActionContainer', + ), + ); +} + +/** + * Test container plugin. + */ +class RulesTestContainer extends RulesContainerPlugin { + protected $itemName = 'rules test container'; + + /** + * Evaluate the element on a given rules evaluation state. + */ + public function evaluate(RulesState $state) { + // Do nothing. + } + +} + +/** + * Test event handler class. + */ +class RulesTestEventHandler extends RulesEventDefaultHandler implements RulesEventDispatcherInterface { + + /** + * Name of the variable in which to store the state of the event handler. + * + * @var string + */ + protected $variableName = 'rules_test_event_handler_watch'; + + /** + * Implements RulesEventDispatcherInterface::startWatching(). + */ + public function startWatching() { + variable_set($this->variableName, TRUE); + } + + /** + * Implements RulesEventDispatcherInterface::stopWatching(). + */ + public function stopWatching() { + variable_set($this->variableName, FALSE); + } + + /** + * Implements RulesEventDispatcherInterface::isWatching(). + */ + public function isWatching() { + return (bool) variable_get($this->variableName); + } + +} diff --git a/tests/rules_test.rules_defaults.inc b/tests/rules_test.rules_defaults.inc new file mode 100644 index 0000000..6d270a5 --- /dev/null +++ b/tests/rules_test.rules_defaults.inc @@ -0,0 +1,124 @@ +label = 'example default rule'; + // Add rules tags. + $rule->tags = array('Admin', 'Tag2'); + $rule->active = FALSE; + $rule->event('node_update') + ->condition(rules_condition('data_is', array('data:select' => 'node:status', 'value' => TRUE))->negate()) + ->condition('data_is', array('data:select' => 'node:type', 'value' => 'page')) + ->action('drupal_message', array('message' => 'A node has been updated.')); + + $configs['rules_test_default_1'] = $rule; + + $action_set = rules_action_set(array('node' => array('type' => 'node', 'label' => 'Content'))); + $action_set->action('node_publish'); + $configs['rules_test_action_set'] = $action_set; + + // Test providing a rule using an export. + $configs['rules_export_test'] = rules_import(_rules_export_get_test_export()); + + // An action set used to test merging term parents. + $configs['rules_retrieve_term_parents'] = rules_import('{ "rules_retrieve_term_parents" : { + "LABEL" : "Retrieve term parents", + "PLUGIN" : "action set", + "REQUIRES" : [ "rules" ], + "USES VARIABLES" : { + "terms" : { "label" : "Terms", "type" : "list\u003ctaxonomy_term\u003e" }, + "term_parents" : { + "label" : "Term parents", + "type" : "list\u003ctaxonomy_term\u003e", + "parameter" : false + } + }, + "ACTION SET" : [ + { "LOOP" : { + "USING" : { "list" : [ "terms" ] }, + "ITEM" : { "current_term" : "Current term" }, + "DO" : [ + { "LOOP" : { + "USING" : { "list" : [ "current-term:parent" ] }, + "ITEM" : { "current_parent" : "Current parent" }, + "DO" : [ + { "list_add" : { + "list" : [ "term-parents" ], + "item" : [ "current-parent" ], + "unique" : 1 + } + } + ] + } + } + ] + } + } + ], + "PROVIDES VARIABLES" : [ "term_parents" ] + } +}'); + + return $configs; +} + +/** + * Defines the export of rule for testing import/export. + */ +function _rules_export_get_test_export() { + return '{ "rules_export_test" : { + "LABEL" : "Test import rule2", + "PLUGIN" : "reaction rule", + "WEIGHT" : "-1", + "ACTIVE" : false, + "OWNER" : "rules", + "TAGS" : [ "bar", "baz", "foo" ], + "REQUIRES" : [ "rules", "comment" ], + "ON" : { "comment_insert" : [] }, + "IF" : [ + { "OR" : [ + { "NOT node_is_sticky" : { "node" : [ "comment:node" ] } }, + { "node_is_of_type" : { + "node" : [ "comment:node" ], + "type" : { "value" : { "page" : "page" } } + } + }, + { "NOT AND" : [ { "OR" : [] } ] } + ] + } + ], + "DO" : [ + { "data_set" : { + "data" : [ "comment:node:created" ], + "value" : { "select" : "site:current-date", "date_offset" : { "value" : -604800 } } + } + }, + { "node_make_sticky" : { "node" : [ "comment:node" ] } }, + { "variable_add" : { + "USING" : { "type" : "token", "value" : "error" }, + "PROVIDE" : { "variable_added" : { "level" : "Error level" } } + } + }, + { "drupal_message" : { + "message" : "fein, [comment:node:title] has been made sticky!", + "type" : [ "level" ] + } + }, + { "LOOP" : { + "USING" : { "list" : [ "site:current-user:roles" ] }, + "ITEM" : { "current_role" : "Current role" }, + "DO" : [ { "drupal_message" : { "message" : [ "current-role" ] } } ] + } + } + ] + } +}'; +} diff --git a/tests/rules_test.test.inc b/tests/rules_test.test.inc new file mode 100644 index 0000000..7c24243 --- /dev/null +++ b/tests/rules_test.test.inc @@ -0,0 +1,75 @@ + $data); +} + +/** + * Condition: Check for selected content types. + */ +function rules_condition_content_is_type($node, $type) { + return in_array($node->type, $type); +} + +/** + * Condition: Check if the node is published. + */ +function rules_condition_content_is_published($node, $settings) { + return $node->status == 1; +} + +/** + * Loads a node. + */ +function rules_action_load_node($nid, $vid = NULL) { + return array('node_loaded' => node_load($nid, $vid ? $vid : NULL)); +} + +/** + * Action "Delete a node". + */ +function rules_action_delete_node($node) { + node_delete($node->nid); +} + +/** + * An action making use of named parameters. + */ +function rules_action_node_set_title($arguments) { + // Make sure the data is unwrapped. + if ($arguments['node'] instanceof EntityMetadataWrapper) { + throw new Exception('Argument has not been correctly unwrapped.'); + } + $arguments['node']->title = $arguments['title']; + return $arguments; +} + +/** + * Action: Test saving - nothing to do here. + */ +function rules_test_type_save($node) { + +} diff --git a/tests/rules_test_invocation.info b/tests/rules_test_invocation.info new file mode 100644 index 0000000..5bcba88 --- /dev/null +++ b/tests/rules_test_invocation.info @@ -0,0 +1,11 @@ +name = "Rules Test invocation" +description = "Helper module to test Rules invocations." +package = Testing +core = 7.x +hidden = TRUE + +; Information added by Drupal.org packaging script on 2020-03-15 +version = "7.x-2.x-dev" +core = "7.x" +project = "rules" +datestamp = "1584309182" diff --git a/tests/rules_test_invocation.module b/tests/rules_test_invocation.module new file mode 100644 index 0000000..5664c5e --- /dev/null +++ b/tests/rules_test_invocation.module @@ -0,0 +1,13 @@ + '); + this.button.attr( { + 'tabIndex': -1, + 'title': 'Show all items' + }); + this.button.insertAfter(this.jqObject); + + this.button.button( { + icons: { + primary: 'ui-icon-triangle-1-s' + }, + text: false + }); + + // Don't round the left corners. + this.button.removeClass('ui-corner-all'); + this.button.addClass('ui-corner-right ui-button-icon rules-autocomplete-button'); + + this.jqObject.autocomplete(); + this.jqObject.autocomplete("option", "minLength", 0); + // Add a custom class, so we can style the autocomplete box without + // interfering with other jquery autocomplete widgets. + this.jqObject.autocomplete("widget").addClass('rules-autocomplete'); + + // Save the current rules_autocomplete object, so it can be used in + // handlers. + var instance = this; + + // Event handlers + this.jqObject.focus(function() { + if (instance.focusOpens) { + instance.toggle(); + instance.opendByFocus = true; + } + else { + instance.focusOpens = true; + } + }); + + // Needed when the window is closed but the textfield has the focus. + this.jqObject.click(function() { + // Since the focus event happens earlier then the focus event, we need to + // check here, if the window should be opened. + if (!instance.opendByFocus) { + instance.toggle(); + } + else { + instance.opendByFocus = false; + } + }); + + this.jqObject.bind("autocompleteselect", function(event, ui) { + // If a group was selected then set the groupSelected to true for the + // overridden close function from jquery autocomplete. + if (ui.item.value.substring(ui.item.value.length - 1, ui.item.value.length) == ":") { + instance.groupSelected = true; + } + instance.focusOpens = false; + instance.opendByFocus = false; + }); + + this.jqObject.autocomplete("option", "source", function(request, response) { + if (request.term in instance.cache) { + response(instance.cache[request.term]); + return; + } + $.ajax( { + url: instance.uri + '/' + request.term, + dataType: "json", + success: function(data) { + instance.success(data, request, response); + } + }); + }); + + // Newer versions of jQuery UI use element.data('ui-autocomplete'), older + // versions use element.data('autocomplete'). + var autocompleteDataKey = typeof(this.jqObject.data('autocomplete')) === 'object' ? 'autocomplete' : 'ui-autocomplete'; + + // Since jquery autocomplete by default strips html text by using .text() + // we need our own _renderItem function to display html content. + this.jqObject.data(autocompleteDataKey)._renderItem = function(ul, item) { + return $("
  • ").data("item.autocomplete", item).append("" + item.label + "").appendTo(ul); + }; + + // Override close function + this.jqObject.data(autocompleteDataKey).close = function (event) { + var value = this.element.val(); + // If the selector is not a group, then trigger the close event an and + // hide the menu. + if (value === undefined || instance.groupSelected === false) { + clearTimeout(this.closing); + if (this.menu.element.is(":visible")) { + this._trigger("close", event); + this.menu.element.hide(); + // Use deactivate for older versions of jQuery UI. + if (typeof(this.menu.deactivate) === 'function') { + this.menu.deactivate(); + } + } + } + else { + // Else keep all open and trigger a search for the group. + instance.jqObject.autocomplete("search", instance.jqObject.val()); + // After the suggestion box was opened again, we want to be able to + // close it. + instance.groupSelected = false; + } + }; + + this.button.click(function() { + instance.toggle(); + }); + + }; + + /** + * Success function for Rules autocomplete object. + */ + Drupal.rules.autocomplete.prototype.success = function(data, request, response) { + var list = new Array(); + jQuery.each(data, function(index, value) { + list.push( { + label: value, + value: index + }); + }); + + this.cache[request.term] = list; + response(list); + }; + + /** + * Open the autocomplete window. + * @param searchFor The term for will be searched for. If undefined then the + * entered input text will be used. + */ + Drupal.rules.autocomplete.prototype.open = function(searchFor) { + // If searchFor is undefined, we want to search for the passed argument. + this.jqObject.autocomplete("search", ((searchFor === undefined) ? this.jqObject.val() : searchFor)); + this.button.addClass("ui-state-focus"); + }; + + /** + * Close the autocomplete window. + */ + Drupal.rules.autocomplete.prototype.close = function() { + this.jqObject.autocomplete("close"); + this.button.removeClass("ui-state-focus"); + }; + + /** + * Toggle the autocomplete window. + */ + Drupal.rules.autocomplete.prototype.toggle = function() { + if (this.jqObject.autocomplete("widget").is(":visible")) { + this.close(); + this.focusOpens = true; + } + else { + var groups = this.jqObject.val().split(":"); + var selector = ""; + for (var i=0; i 'rules_get_title', + 'title arguments' => array('Editing !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_form_edit_rules_config', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'type' => MENU_VISIBLE_IN_BREADCRUMB, + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/add/%rules_element'] = array( + // Adding another part to the path would hit the menu path-part-limit + // for base paths like admin/config/workflow/rules. Therefore we have to + // use this ugly way for setting the title. + 'title callback' => 'rules_menu_add_element_title', + // Wrap the integer in an array, so it is passed as is. + 'title arguments' => array(array($base_count + 4)), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_add_element', $base_count + 1, $base_count + 4, $base_count + 3, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'load arguments' => array($base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/add/event'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Adding event to !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_add_event_page', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'load arguments' => array($base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/delete/event'] = array( + // @todo Improve title. + 'title' => 'Remove event', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_remove_event', $base_count + 1, $base_count + 4, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'description' => 'Remove an event from a reaction rule.', + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/edit/%rules_element'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Editing !plugin "!label"', $base_count + 3), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_edit_element', $base_count + 1, $base_count + 3, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'load arguments' => array($base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/autocomplete'] = array( + 'page callback' => 'rules_ui_form_data_selection_auto_completion', + 'page arguments' => array($base_count + 3, $base_count + 4, $base_count + 5), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'type' => MENU_CALLBACK, + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/delete/%rules_element'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Editing !plugin "!label"', $base_count + 3), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_delete_element', $base_count + 1, $base_count + 3, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'load arguments' => array($base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/%'] = array( + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_form_rules_config_confirm_op', $base_count + 1, $base_count + 2, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/clone'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Cloning !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_form_clone_rules_config', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/export'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Export of !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_form_export_rules_config', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('view', $base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + $items[$base_path . '/manage/%rules_config/execute'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Executing !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_ui_form_execute_rules_config', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'ui/ui.forms.inc', + 'file path' => drupal_get_path('module', 'rules'), + ); + drupal_alter('rules_ui_menu', $items, $base_path, $base_count); + + if (module_exists('rules_scheduler')) { + $items[$base_path . '/manage/%rules_config/schedule'] = array( + 'title callback' => 'rules_get_title', + 'title arguments' => array('Schedule !plugin "!label"', $base_count + 1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('rules_scheduler_schedule_form', $base_count + 1, $base_path), + 'access callback' => 'rules_config_access', + 'access arguments' => array('update', $base_count + 1), + 'file' => 'rules_scheduler.admin.inc', + 'file path' => drupal_get_path('module', 'rules_scheduler'), + ); + } + return $items; + } + + /** + * Generates the render array for an overview configuration table. + * + * Generates the render array for an overview configuration table for + * arbitrary rule configs that match the given conditions. + * + * Note: The generated overview table contains multiple links for editing the + * rule configurations. For the links to properly work use + * RulesUIController::config_menu($base_path) to generate appropriate menu + * items for the path at which the overview table is displayed. + * + * @param array $conditions + * An array of conditions as needed by rules_config_load_multiple(). + * @param array $options + * An array with optional options. Known keys are: + * - 'hide status op': If set to TRUE, enable/disable links are not added. + * Defaults to FALSE. + * - 'show plugin': If set to FALSE, the plugin is not shown. Defaults to + * TRUE. + * - 'show events': If set to TRUE, the event column is shown. Defaults to + * TRUE if only reaction rules are listed. + * - 'show execution op': If set to TRUE an operation for execution a + * component is shown for components, as well as a link to schedule a + * component if the rules scheduler module is enabled. + * - 'base path': Optionally, a different base path to use instead of the + * currently set RulesPluginUI::$basePath. If no base path has been set + * yet, the current path is used by default. + * + * @return array + * A renderable array. + */ + public function overviewTable($conditions = array(), $options = array()) { + $options += array( + 'hide status op' => FALSE, + 'show plugin' => TRUE, + 'show events' => isset($conditions['plugin']) && $conditions['plugin'] == 'reaction rule', + 'show execution op' => !(isset($conditions['plugin']) && $conditions['plugin'] == 'reaction rule'), + ); + // By default show only configurations owned by rules. + $conditions += array( + 'owner' => 'rules', + ); + if (!empty($options['base path'])) { + RulesPluginUI::$basePath = $options['base path']; + } + elseif (!isset(RulesPluginUI::$basePath)) { + // Default to the current path, only if no path has been set yet. + RulesPluginUI::$basePath = current_path(); + } + + $entities = entity_load('rules_config', FALSE, $conditions); + ksort($entities); + + // Prepare some variables used by overviewTableRow(). + $this->event_info = rules_fetch_data('event_info'); + $this->cache = rules_get_cache(); + + $rows = array(); + foreach ($entities as $id => $entity) { + if (user_access('bypass rules access') || $entity->access()) { + $rows[] = $this->overviewTableRow($conditions, $id, $entity, $options); + } + } + // Assemble the right table header. + $header = array(t('Name'), t('Event'), t('Plugin'), t('Status'), array('data' => t('Operations'))); + if (!$options['show events']) { + // Remove the event heading as there is no such column. + unset($header[1]); + } + if (!$options['show plugin']) { + unset($header[2]); + } + // Fix the header operation column colspan. + $num_cols = isset($rows[0]) ? count($rows[0]) : 0; + if (($addition = $num_cols - count($header)) > 0) { + $header[4]['colspan'] = $addition + 1; + } + + $table = array( + '#theme' => 'table', + '#header' => $header, + '#rows' => $rows, + '#empty' => t('None.'), + ); + $table['#attributes']['class'][] = 'rules-overview-table'; + $table['#attached']['css'][] = drupal_get_path('module', 'rules') . '/ui/rules.ui.css'; + + // @todo Hide configs where access() is FALSE. + return $table; + } + + /** + * Generates the row for a single rules config. + * + * @param array $conditions + * An array of conditions as needed by rules_config_load_multiple(). + * @param array $options + * An array with optional options. Known keys are: + * - 'hide status op': If set to TRUE, enable/disable links are not added. + * Defaults to FALSE. + * - 'show plugin': If set to FALSE, the plugin is not shown. Defaults to + * TRUE. + * - 'show events': If set to TRUE, the event column is shown. Defaults to + * TRUE if only reaction rules are listed. + * - 'show execution op': If set to TRUE an operation for execution a + * component is shown for components, as well as a link to schedule a + * component if the rules scheduler module is enabled. + * - 'base path': Optionally, a different base path to use instead of the + * currently set RulesPluginUI::$basePath. If no base path has been set + * yet, the current path is used by default. + */ + protected function overviewTableRow($conditions, $name, $config, $options) { + // Build content includes the label, as well as a short overview including + // the machine name. + $row[] = array('data' => $config->buildContent()); + + // Add events if the configs are assigned to events. + if ($options['show events']) { + $events = array(); + if ($config instanceof RulesTriggerableInterface) { + foreach ($config->events() as $event_name) { + $event_handler = rules_get_event_handler($event_name, $config->getEventSettings($event_name)); + $events[] = $event_handler->summary(); + } + } + $row[] = implode(", ", $events); + } + if ($options['show plugin']) { + $plugin = $config->plugin(); + $row[] = isset($this->cache['plugin_info'][$plugin]['label']) ? $this->cache['plugin_info'][$plugin]['label'] : $plugin; + } + + $row[] = array('data' => array( + '#theme' => 'entity_status', + '#status' => $config->status, + )); + + // Add operations depending on the options and the exportable status. + if (!$config->hasStatus(ENTITY_FIXED)) { + $row[] = l(t('edit'), RulesPluginUI::path($name), array('attributes' => array('class' => array('edit', 'action')))); + if (module_exists('rules_i18n')) { + $row[] = l(t('translate'), RulesPluginUI::path($name, 'translate'), array('attributes' => array('class' => array('translate', 'action')))); + } + } + else { + $row[] = ''; + if (module_exists('rules_i18n')) { + $row[] = ''; + } + } + + if (!$options['hide status op']) { + // Add either an enable or disable link. + $text = $config->active ? t('disable') : t('enable'); + $active_class = $config->active ? 'disable' : 'enable'; + $link_path = RulesPluginUI::path($name, $active_class); + $row[] = $config->hasStatus(ENTITY_FIXED) ? '' : l($text, $link_path, array('attributes' => array('class' => array($active_class, 'action')), 'query' => drupal_get_destination())); + } + $row[] = l(t('clone'), RulesPluginUI::path($name, 'clone'), array('attributes' => array('class' => array('clone', 'action')))); + + // Add execute link for for components. + if ($options['show execution op']) { + $row[] = ($config instanceof RulesTriggerableInterface) ? '' : l(t('execute'), RulesPluginUI::path($name, 'execute'), array('attributes' => array('class' => array('execute', 'action')), 'query' => drupal_get_destination())); + if (module_exists('rules_scheduler')) { + // Add schedule link for action components only. + $row[] = $config instanceof RulesActionInterface ? l(t('schedule'), RulesPluginUI::path($name, 'schedule'), array('attributes' => array('class' => array('schedule', 'action')), 'query' => drupal_get_destination())) : ''; + } + } + + if (!$config->hasStatus(ENTITY_IN_CODE) && !$config->hasStatus(ENTITY_FIXED)) { + $row[] = l(t('delete'), RulesPluginUI::path($name, 'delete'), array('attributes' => array('class' => array('delete', 'action')), 'query' => drupal_get_destination())); + } + elseif ($config->hasStatus(ENTITY_OVERRIDDEN) && !$config->hasStatus(ENTITY_FIXED)) { + $row[] = l(t('revert'), RulesPluginUI::path($name, 'revert'), array('attributes' => array('class' => array('revert', 'action')), 'query' => drupal_get_destination())); + } + else { + $row[] = ''; + } + $row[] = l(t('export'), RulesPluginUI::path($name, 'export'), array('attributes' => array('class' => array('export', 'action')))); + return $row; + } + +} diff --git a/ui/ui.core.inc b/ui/ui.core.inc new file mode 100644 index 0000000..3669565 --- /dev/null +++ b/ui/ui.core.inc @@ -0,0 +1,1370 @@ +save() afterwards. + * + * @param array $form + * The form array. + * @param array $form_state + * The current form state. + */ + public function form_submit($form, &$form_state); + + /** + * Returns a structured array for rendering this element in overviews. + */ + public function buildContent(); + + /** + * Returns the help text for editing this plugin. + */ + public function help(); + + /** + * Returns ui operations for this element. + */ + public function operations(); + +} + +/** + * Helper object for mapping elements to ids. + */ +class RulesElementMap { + + /** + * @var RulesPlugin + */ + protected $configuration; + protected $index = array(); + protected $counter = 0; + + /** + * Constructor. + */ + public function __construct(RulesPlugin $config) { + $this->configuration = $config->root(); + } + + /** + * Makes sure each element has an assigned id. + */ + public function index() { + foreach ($this->getUnIndexedElements($this->configuration) as $element) { + $id = &$element->property('elementId'); + $id = ++$this->counter; + $this->index[$id] = $element; + } + } + + protected function getUnIndexedElements($element, &$unindexed = array()) { + // Remember unindexed elements. + $id = $element->property('elementId'); + if (!isset($id)) { + $unindexed[] = $element; + } + else { + // Make sure $this->counter refers to the highest id. + if ($id > $this->counter) { + $this->counter = $id; + } + $this->index[$id] = $element; + } + // Recurse down the tree. + if ($element instanceof RulesContainerPlugin) { + foreach ($element as $child) { + $this->getUnIndexedElements($child, $unindexed); + } + } + return $unindexed; + } + + /** + * Looks up the element with the given id. + */ + public function lookup($id) { + if (!$this->index) { + $this->index(); + } + return isset($this->index[$id]) ? $this->index[$id] : FALSE; + } + +} + +/** + * Faces UI extender for all kind of Rules plugins. + * + * Provides various useful methods for any rules UI. + */ +class RulesPluginUI extends FacesExtender implements RulesPluginUIInterface { + + /** + * @var RulesPlugin + */ + protected $element; + + /** + * The base path determines where a Rules overview UI lives. + * + * All forms that want to display Rules (overview) forms need to set this + * variable. This is necessary in order to get correct operation links, + * paths, redirects, breadcrumbs, etc. for the form() and overviewTable() + * methods. + * + * @see RulesUIController + * @see rules_admin_reaction_overview() + * @see rules_admin_components_overview() + */ + public static $basePath = NULL; + + /** + * Provide $this->element to make the code more meaningful. + */ + public function __construct(FacesExtendable $object) { + parent::__construct($object); + $this->element = $object; + } + + /** + * Returns the state values for $form, possibly only a part of the whole form. + * + * In case the form is embedded somewhere, this function figures out the + * location of its form values and returns them for further use. + * + * @param array $form + * A form array, or an array of form elements to get the value for. + * @param array $form_state + * The form state as usual. + */ + public static function &getFormStateValues($form, &$form_state) { + $values = NULL; + if (isset($form_state['values'])) { + // Assume the top level if parents are not yet set. + $form += array('#parents' => array()); + $values = &$form_state['values']; + foreach ($form['#parents'] as $parent) { + $values = &$values[$parent]; + } + } + return $values; + } + + /** + * Implements RulesPluginUIInterface::form(). + * + * Generates the element edit form. + * Note: Make sure that you set RulesPluginUI::$basePath before using this + * method, otherwise paths, links, redirects etc. won't be correct. + */ + public function form(&$form, &$form_state, $options = array()) { + self::formDefaults($form, $form_state); + $form_state += array('rules_element' => $this->element); + + // Add the help to the top of the form. + $help = $this->element->help(); + $form['help'] = is_array($help) ? $help : array('#markup' => $help); + + // We use $form_state['element_settings'] to store the settings of both + // parameter modes. That way one can switch between the parameter modes + // without losing the settings of those. + $form_state += array('element_settings' => $this->element->settings); + $settings = $this->element->settings + $form_state['element_settings']; + + $form['parameter'] = array( + '#tree' => TRUE, + ); + + foreach ($this->element->pluginParameterInfo() as $name => $parameter) { + if ($parameter['type'] == 'hidden') { + continue; + } + + $form['parameter'][$name] = array( + '#type' => 'fieldset', + '#title' => check_plain($parameter['label']), + '#description' => filter_xss(isset($parameter['description']) ? $parameter['description'] : ''), + ); + + // Init the parameter input mode. + $form_state['parameter_mode'][$name] = !isset($form_state['parameter_mode'][$name]) ? NULL : $form_state['parameter_mode'][$name]; + $form['parameter'][$name] += $this->getParameterForm($name, $parameter, $settings, $form_state['parameter_mode'][$name]); + } + + // Provide a form for editing the label and name of provided variables. + $settings = $this->element->settings; + foreach ($this->element->pluginProvidesVariables() as $var_name => $var_info) { + $form['provides'][$var_name] = array( + '#type' => 'fieldset', + '#title' => check_plain($var_info['label']), + '#description' => filter_xss(isset($var_info['description']) ? $var_info['description'] : ''), + ); + $form['provides'][$var_name]['label'] = array( + '#type' => 'textfield', + '#title' => t('Variable label'), + '#default_value' => isset($settings[$var_name . ':label']) ? $settings[$var_name . ':label'] : $var_info['label'], + '#required' => TRUE, + ); + $form['provides'][$var_name]['var'] = array( + '#type' => 'textfield', + '#title' => t('Variable name'), + '#default_value' => isset($settings[$var_name . ':var']) ? $settings[$var_name . ':var'] : $var_name, + '#description' => t('The variable name must contain only lowercase letters, numbers, and underscores and must be unique in the current scope.'), + '#element_validate' => array('rules_ui_element_machine_name_validate'), + '#required' => TRUE, + ); + } + if (!empty($form['provides'])) { + $help = '
    ' . t('Adjust the names and labels of provided variables, but note that renaming of already utilized variables invalidates the existing uses.') . '
    '; + $form['provides'] += array( + '#tree' => TRUE, + '#prefix' => '

    ' . t('Provided variables') . '

    ' . $help, + ); + } + + // Add settings form, if specified. + if (!empty($options['show settings'])) { + $this->settingsForm($form, $form_state); + } + // Add submit button, if specified. + if (!empty($options['button'])) { + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#weight' => 10, + ); + } + } + + /** + * Actually generates the parameter form for the given data type. + */ + protected function getParameterForm($name, $info, $settings, &$mode) { + $class = $this->getDataTypeClass($info['type'], $info); + $supports_input_mode = in_array('RulesDataDirectInputFormInterface', class_implements($class)); + + // Init the mode. + if (!isset($mode)) { + if (isset($settings[$name . ':select'])) { + $mode = 'selector'; + } + elseif (isset($settings[$name]) && $supports_input_mode) { + $mode = 'input'; + } + elseif (isset($info['restriction'])) { + $mode = $info['restriction']; + } + else { + // Allow the parameter to define the 'default mode' and fallback to the + // data type default. + $mode = !empty($info['default mode']) ? $info['default mode'] : call_user_func(array($class, 'getDefaultMode')); + } + } + + // For translatable parameters, pre-populate an internal translation source + // key so data type forms or input evaluators (i18n) may show a suitable + // help message. + if (drupal_multilingual() && !empty($info['translatable'])) { + $parameter = $this->element->pluginParameterInfo(); + $info['custom translation language'] = !empty($parameter['language']); + } + + // Add the parameter form. + if ($mode == 'input' && $supports_input_mode) { + $form['settings'] = call_user_func(array($class, 'inputForm'), $name, $info, $settings, $this->element); + } + else { + $form['settings'] = call_user_func(array($class, 'selectionForm'), $name, $info, $settings, $this->element); + } + + // Add a link for switching the input mode when JS is enabled and a button + // to switch it without JavaScript, in case switching is possible. + if ($supports_input_mode && empty($info['restriction'])) { + $value = $mode == 'selector' ? t('Switch to the direct input mode') : t('Switch to data selection'); + + $form['switch_button'] = array( + '#type' => 'submit', + '#name' => 'param_' . $name, + '#attributes' => array('class' => array('rules-switch-button')), + '#parameter' => $name, + '#value' => $value, + '#submit' => array('rules_ui_parameter_replace_submit'), + '#ajax' => rules_ui_form_default_ajax('none'), + // Do not validate! + '#limit_validation_errors' => array(), + ); + } + return $form; + } + + /** + * Implements RulesPluginUIInterface. + */ + public function form_validate($form, &$form_state) { + $this->form_extract_values($form, $form_state); + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + + if (isset($form_values['provides'])) { + $vars = $this->element->availableVariables(); + foreach ($form_values['provides'] as $name => $values) { + if (isset($vars[$values['var']])) { + form_error($form['provides'][$name]['var'], t('The variable name %name is already taken.', array('%name' => $values['var']))); + } + } + } + // Settings have been updated, so process them now. + $this->element->processSettings(TRUE); + + // Make sure the current user really has access to configure this element + // as well as the used input evaluators and data processors. + if (!user_access('bypass rules access') && !$this->element->root()->access()) { + form_set_error('', t('Access violation! You have insufficient access permissions to edit this configuration.')); + } + if (!empty($form['settings'])) { + $this->settingsFormValidate($form, $form_state); + } + } + + /** + * Applies the values of the form to the element. + */ + public function form_extract_values($form, &$form_state) { + $this->element->settings = array(); + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + if (isset($form_values['parameter'])) { + foreach ($form_values['parameter'] as $name => $values) { + $this->element->settings += $values['settings']; + } + } + if (isset($form_values['provides'])) { + foreach ($form_values['provides'] as $name => $values) { + $this->element->settings[$name . ':label'] = $values['label']; + $this->element->settings[$name . ':var'] = $values['var']; + } + } + if (!empty($form['settings'])) { + $this->settingsFormExtractValues($form, $form_state); + } + } + + /** + * Implements RulesPluginUIInterface. + */ + public function form_submit($form, &$form_state) { + // Need to save the element first, before trying to set the component + // permissions in settingsFormSubmit(), because hook_permission() needs + // to be able to load the modified element from the DB in order to work + // properly. + // @see https://www.drupal.org/project/rules/issues/2340505 + $this->element->save(); + if (!empty($form['settings'])) { + $this->settingsFormSubmit($form, $form_state); + } + } + + /** + * Adds the configuration settings form (label, tags, description, ...). + */ + public function settingsForm(&$form, &$form_state) { + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + // Add the settings in a separate fieldset below. + $form['settings'] = array( + '#type' => 'fieldset', + '#title' => t('Settings'), + '#collapsible' => TRUE, + '#collapsed' => empty($form_values['settings']['vars']['more']), + '#weight' => 5, + '#tree' => TRUE, + ); + $form['settings']['label'] = array( + '#type' => 'textfield', + '#title' => t('Name'), + '#default_value' => $this->element->label(), + '#required' => TRUE, + '#weight' => -5, + ); + // @todo For Drupal 8 use "owner" for generating machine names and + // module only for the modules providing default configurations. + if (!empty($this->element->module) && !empty($this->element->name) && $this->element->module == 'rules' && strpos($this->element->name, 'rules_') === 0) { + // Remove the Rules module prefix from the machine name. + $machine_name = substr($this->element->name, strlen($this->element->module) + 1); + } + else { + $machine_name = $this->element->name; + } + $form['settings']['name'] = array( + '#type' => 'machine_name', + '#default_value' => isset($machine_name) ? $machine_name : '', + // The string 'rules_' is pre-pended to machine names, so the + // maxlength must be less than the field length of 64 characters. + '#maxlength' => 58, + '#disabled' => entity_has_status('rules_config', $this->element, ENTITY_IN_CODE) && !(isset($form_state['op']) && $form_state['op'] == 'clone'), + '#machine_name' => array( + 'exists' => 'rules_config_load', + 'source' => array('settings', 'label'), + ), + '#required' => TRUE, + '#description' => t('The machine-readable name of this configuration is used by rules internally to identify the configuration. This name must contain only lowercase letters, numbers, and underscores and must be unique.'), + ); + $form['settings']['tags'] = array( + '#type' => 'textfield', + '#title' => t('Tags'), + '#default_value' => isset($this->element->tags) ? drupal_implode_tags($this->element->tags) : '', + '#autocomplete_path' => 'admin/config/workflow/rules/autocomplete_tags', + '#description' => t('Tags associated with this configuration, used for filtering in the admin interface. Separate multiple tags with commas.'), + ); + + // Show a form for editing variables for components. + if (($plugin_info = $this->element->pluginInfo()) && !empty($plugin_info['component'])) { + if ($this->element->hasStatus(ENTITY_IN_CODE)) { + $description = t('The variables used by the component. They can not be edited for configurations that are provided in code.'); + } + else { + $description = t('Variables are normally input parameters for the component – data that should be available for the component to act on. Additionally, action components may provide variables back to the caller. Each variable must have a specified data type, a label and a unique machine readable name containing only lowercase alphanumeric characters and underscores. See the online documentation for more information about variables.', + array('@url' => rules_external_help('variables')) + ); + } + $form['settings']['vars'] = array( + '#prefix' => '
    ', + '#suffix' => '
    ', + '#tree' => TRUE, + '#element_validate' => array('rules_ui_element_variable_form_validate'), + '#theme' => 'rules_ui_variable_form', + '#title' => t('Variables'), + '#description' => $description, + // Variables can not be edited on configurations in code. + '#disabled' => $this->element->hasStatus(ENTITY_IN_CODE), + ); + + $weight = 0; + $provides = $this->element->providesVariables(); + foreach ($this->element->componentVariables() as $name => $var_info) { + $form['settings']['vars']['items'][$name] = array( + 'weight' => array('#default_value' => $weight++), + ) + RulesPluginUI::getVariableForm($name, $var_info, isset($provides[$name])); + } + // Add one empty row in case user wants to add an additional variable. + $form['settings']['vars']['items'][] = array( + 'weight' => array('#default_value' => $weight++), + ) + RulesPluginUI::getVariableForm(); + + // Submit button will cause a form rebuild using the currently-entered + // values. If a variable has been added, a new empty row will also appear. + $form['settings']['vars']['more'] = array( + '#type' => 'submit', + '#value' => t('Add more'), + '#ajax' => rules_ui_form_default_ajax('none'), + '#limit_validation_errors' => array(array('vars')), + '#submit' => array('rules_form_submit_rebuild'), + ); + if (!empty($this->element->id)) { + // Display a setting to manage access. + $form['settings']['access'] = array( + '#weight' => 50, + ); + $plugin_type = $this->element instanceof RulesActionInterface ? t('action') : t('condition'); + $form['settings']['access']['access_exposed'] = array( + '#type' => 'checkbox', + '#title' => t('Configure access for using this component with a permission.'), + '#default_value' => !empty($this->element->access_exposed), + '#description' => t('By default, the @plugin-type for using this component may be only used by users that have access to configure the component. If checked, access is determined by a permission instead.', array('@plugin-type' => $plugin_type)), + ); + $form['settings']['access']['permissions'] = array( + '#type' => 'container', + '#states' => array( + 'visible' => array( + ':input[name="settings[access][access_exposed]"]' => array('checked' => TRUE), + ), + ), + ); + $form['settings']['access']['permissions']['matrix'] = $this->settingsFormPermissionMatrix(); + } + } + + // @todo Attach field form thus description. + } + + /** + * Provides a matrix permission for the component based in the existing roles. + * + * @return array + * Form elements with the matrix of permissions for a component. + */ + protected function settingsFormPermissionMatrix() { + $form['#theme'] = 'user_admin_permissions'; + $status = array(); + $options = array(); + + $role_names = user_roles(); + $role_permissions = user_role_permissions($role_names); + $component_permission = rules_permissions_by_component(array($this->element)); + $component_permission_name = key($component_permission); + + $form['permission'][$component_permission_name] = array( + '#type' => 'item', + '#markup' => $component_permission[$component_permission_name]['title'], + ); + $options[$component_permission_name] = ''; + foreach ($role_names as $rid => $name) { + if (isset($role_permissions[$rid][$component_permission_name])) { + $status[$rid][] = $component_permission_name; + } + } + + // Build the checkboxes for each role. + foreach ($role_names as $rid => $name) { + $form['checkboxes'][$rid] = array( + '#type' => 'checkboxes', + '#options' => $options, + '#default_value' => isset($status[$rid]) ? $status[$rid] : array(), + '#attributes' => array('class' => array('rid-' . $rid)), + ); + $form['role_names'][$rid] = array('#markup' => check_plain($name), '#tree' => TRUE); + } + + // Attach the default permissions page JavaScript. + $form['#attached']['js'][] = drupal_get_path('module', 'user') . '/user.permissions.js'; + + return $form; + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function settingsFormExtractValues($form, &$form_state) { + $form_values = RulesPluginUI::getFormStateValues($form['settings'], $form_state); + $this->element->label = $form_values['label']; + // If the name was changed we have to redirect to the URL that contains + // the new name, instead of rebuilding on the old URL with the old name. + if ($form['settings']['name']['#default_value'] != $form_values['name']) { + $module = isset($this->element->module) ? $this->element->module : 'rules'; + $this->element->name = $module . '_' . $form_values['name']; + $form_state['redirect'] = RulesPluginUI::path($this->element->name, 'edit', $this->element); + } + $this->element->tags = empty($form_values['tags']) ? array() : drupal_explode_tags($form_values['tags']); + + if (isset($form_values['vars']['items'])) { + $vars = &$this->element->componentVariables(); + $vars = array(); + if ($this->element instanceof RulesActionContainer) { + $provides = &$this->element->componentProvidesVariables(); + $provides = array(); + } + + usort($form_values['vars']['items'], 'rules_element_sort_helper'); + foreach ($form_values['vars']['items'] as $item) { + if ($item['type'] && $item['name'] && $item['label']) { + $vars[$item['name']] = array('label' => $item['label'], 'type' => $item['type']); + if (!$item['usage'][0]) { + $vars[$item['name']]['parameter'] = FALSE; + } + if ($item['usage'][1] && isset($provides)) { + $provides[] = $item['name']; + } + } + } + // Disable FAPI persistence for the variable form so renumbering works. + $input = &$form_state['input']; + foreach ($form['settings']['#parents'] as $parent) { + $input = &$input[$parent]; + } + unset($input['vars']); + } + $this->element->access_exposed = isset($form_values['access']['access_exposed']) ? $form_values['access']['access_exposed'] : FALSE; + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function settingsFormValidate($form, &$form_state) { + $form_values = RulesPluginUI::getFormStateValues($form['settings'], $form_state); + if ($form['settings']['name']['#default_value'] != $form_values['name'] && rules_config_load($this->element->name)) { + form_error($form['settings']['name'], t('The machine-readable name %name is already taken.', array('%name' => $form_values['name']))); + } + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function settingsFormSubmit($form, &$form_state) { + if (isset($form_state['values']['settings']['access']) && !empty($this->element->access_exposed)) { + // Save the permission matrix. + foreach ($form_state['values']['settings']['access']['permissions']['matrix']['checkboxes'] as $rid => $value) { + // Need to account for the case where the machine name has been changed, + // because then the $value array variable will be keyed with the wrong + // permission name. So here we recompute the permission name to use as + // a key and extract the value from the $value array. + $component_permission = rules_permissions_by_component(array($this->element)); + $component_permission_name = key($component_permission); + user_role_change_permissions($rid, array($component_permission_name => current($value))); + } + } + } + + /** + * Returns the form for configuring the info of a single variable. + */ + public function getVariableForm($name = '', $info = array(), $provided = FALSE) { + $form['type'] = array( + '#type' => 'select', + '#options' => array(0 => '--') + RulesPluginUI::getOptions('data'), + '#default_value' => isset($info['type']) ? $info['type'] : 0, + ); + $form['label'] = array( + '#type' => 'textfield', + '#size' => 40, + '#default_value' => isset($info['label']) ? $info['label'] : '', + ); + $form['name'] = array( + '#type' => 'textfield', + '#size' => 40, + '#default_value' => $name, + '#element_validate' => array('rules_ui_element_machine_name_validate'), + ); + + $usage[0] = !isset($info['parameter']) || $info['parameter'] ? 1 : 0; + $usage[1] = $provided ? 1 : 0; + + $form['usage'] = array( + '#type' => 'select', + '#default_value' => implode('', $usage), + '#options' => array( + '10' => t('Parameter'), + '11' => t('Parameter + Provided'), + '01' => t('Provided'), + ), + ); + if ($this->element instanceof RulesConditionContainer) { + $form['usage']['#disabled'] = TRUE; + } + + // Just set the weight #default_value for the returned form. + $form['weight'] = array( + '#type' => 'weight', + ); + return $form; + } + + /** + * Returns the name of class for the given data type. + * + * @param string $data_type + * The name of the data type + * @param array $parameter_info + * (optional) An array of info about the to be configured parameter. If + * given, this array is complemented with data type defaults also. + */ + public function getDataTypeClass($data_type, &$parameter_info = array()) { + $cache = rules_get_cache(); + $data_info = $cache['data_info']; + // Add in data-type defaults. + if (empty($parameter_info['ui class'])) { + $parameter_info['ui class'] = (is_string($data_type) && isset($data_info[$data_type]['ui class'])) ? $data_info[$data_type]['ui class'] : 'RulesDataUI'; + } + if (is_subclass_of($parameter_info['ui class'], 'RulesDataInputOptionsListInterface')) { + $parameter_info['options list'] = array($parameter_info['ui class'], 'optionsList'); + } + return $parameter_info['ui class']; + } + + /** + * Implements RulesPluginUIInterface. + * + * Shows a preview of the configuration settings. + */ + public function buildContent() { + $config_name = $this->element->root()->name; + $content['label'] = array( + '#type' => 'link', + '#title' => $this->element->label(), + '#href' => $this->element->isRoot() ? RulesPluginUI::path($config_name) : RulesPluginUI::path($config_name, 'edit', $this->element), + '#prefix' => '
    ', + '#suffix' => '
    ', + ); + // Put the elements below in a "description" div. + $content['description'] = array( + '#prefix' => '
    ', + ); + $content['description']['parameter'] = array( + '#caption' => t('Parameter'), + '#theme' => 'rules_content_group', + ); + foreach ($this->element->pluginParameterInfo() as $name => $parameter) { + $element = array(); + if (!empty($this->element->settings[$name . ':select'])) { + $element['content'] = array( + '#markup' => '[' . $this->element->settings[$name . ':select'] . ']', + ); + } + elseif (isset($this->element->settings[$name])) { + $class = $this->getDataTypeClass($parameter['type'], $parameter); + $method = empty($parameter['options list']) ? 'render' : 'renderOptionsLabel'; + // We cannot use method_exists() here as it would trigger a PHP bug. + // @see https://www.drupal.org/node/1258284 + $element = call_user_func(array($class, $method), $this->element->settings[$name], $name, $parameter, $this->element); + } + // Only add parameters that are really configured / not default. + if ($element) { + $content['description']['parameter'][$name] = array( + '#theme' => 'rules_parameter_configuration', + '#info' => $parameter, + ) + $element; + } + } + foreach ($this->element->providesVariables() as $name => $var_info) { + $content['description']['provides'][$name] = array( + '#theme' => 'rules_variable_view', + '#info' => $var_info, + '#name' => $name, + ); + } + if (!empty($content['description']['provides'])) { + $content['description']['provides'] += array( + '#caption' => t('Provides variables'), + '#theme' => 'rules_content_group', + ); + } + // Add integrity exception messages if there are any for this element. + try { + $this->element->integrityCheck(); + // A configuration is still marked as dirty, but already works again. + if (!empty($this->element->dirty)) { + rules_config_update_dirty_flag($this->element); + $variables = array('%label' => $this->element->label(), '%name' => $this->element->name, '@plugin' => $this->element->plugin()); + drupal_set_message(t('The @plugin %label (%name) was marked dirty, but passes the integrity check now and is active again.', $variables)); + rules_clear_cache(); + } + } + catch (RulesIntegrityException $e) { + $content['description']['integrity'] = array( + '#theme' => 'rules_content_group', + '#caption' => t('Error'), + '#attributes' => array('class' => array('rules-content-group-integrity-error')), + 'error' => array( + '#markup' => filter_xss($e->getMessage()), + ), + ); + // Also make sure the rule is marked as dirty. + if (empty($this->element->dirty)) { + rules_config_update_dirty_flag($this->element); + rules_clear_cache(); + } + } + + $content['#suffix'] = '
    '; + $content['#type'] = 'container'; + $content['#attributes']['class'][] = 'rules-element-content'; + return $content; + } + + /** + * Implements RulesPluginUIInterface. + */ + public function operations() { + $name = $this->element->root()->name; + $render = array( + '#theme' => 'links__rules', + ); + $render['#attributes']['class'][] = 'rules-operations'; + $render['#attributes']['class'][] = 'action-links'; + $render['#links']['edit'] = array( + 'title' => t('edit'), + 'href' => RulesPluginUI::path($name, 'edit', $this->element), + ); + $render['#links']['delete'] = array( + 'title' => t('delete'), + 'href' => RulesPluginUI::path($name, 'delete', $this->element), + ); + return $render; + } + + /** + * Implements RulesPluginUIInterface. + */ + public function help() {} + + /** + * Deprecated by the controllers overviewTable() method. + */ + public static function overviewTable($conditions = array(), $options = array()) { + return rules_ui()->overviewTable($conditions, $options); + } + + /** + * Generates an operation path. + * + * Generates a path using the given operation for the element with the given + * id of the configuration with the given name. + */ + public static function path($name, $op = NULL, RulesPlugin $element = NULL, $parameter = FALSE) { + $element_id = isset($element) ? $element->elementId() : FALSE; + if (isset(self::$basePath)) { + $base_path = self::$basePath; + } + // Default to the paths used by 'rules_admin', so modules can easily re-use + // its UI. + else { + $base_path = isset($element) && $element instanceof RulesTriggerableInterface ? 'admin/config/workflow/rules/reaction' : 'admin/config/workflow/rules/components'; + } + + // Only append the '/manage' path if it is not already present. + if (substr($base_path, -strlen('/manage')) != '/manage') { + $base_path .= '/manage'; + } + + return implode('/', array_filter(array($base_path, $name, $op, $element_id, $parameter))); + } + + /** + * Determines the default redirect target for an edited/deleted element. + * + * This is a parent element which is either a rule or the configuration root. + */ + public static function defaultRedirect(RulesPlugin $element) { + while (!$element->isRoot()) { + if ($element instanceof Rule) { + return self::path($element->root()->name, 'edit', $element); + } + $element = $element->parentElement(); + } + return self::path($element->name); + } + + /** + * @see RulesUICategory::getOptions() + */ + public static function getOptions($item_type, $items = NULL) { + return RulesUICategory::getOptions($item_type, $items = NULL); + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public static function formDefaults(&$form, &$form_state) { + form_load_include($form_state, 'inc', 'rules', 'ui/ui.forms'); + // Add our own css. + $form['#attached']['css'][] = drupal_get_path('module', 'rules') . '/ui/rules.ui.css'; + // Workaround for problems with jquery css in seven theme and the core + // autocomplete. + if ($GLOBALS['theme'] == 'seven') { + $form['#attached']['css'][] = drupal_get_path('module', 'rules') . '/ui/rules.ui.seven.css'; + } + + // Specify the wrapper div used by #ajax. + $form['#prefix'] = '
    '; + $form['#suffix'] = '
    '; + + // Preserve the base path in the form state. The after build handler will + // set self::$basePath again for cached forms. + if (isset(self::$basePath)) { + $form_state['_rules_base_path'] = RulesPluginUI::$basePath; + $form['#after_build'][] = 'rules_form_after_build_restore_base_path'; + } + } + + public static function getTags() { + $result = db_select('rules_tags') + ->distinct() + ->fields('rules_tags', array('tag')) + ->groupBy('tag') + ->execute() + ->fetchCol('tag'); + return drupal_map_assoc($result); + } + +} + +/** + * UI for abstract plugins (conditions & actions). + */ +class RulesAbstractPluginUI extends RulesPluginUI { + + /** + * Overrides RulesPluginUI::form(). + * + * Overridden to invoke the abstract plugins form alter callback and to add + * the negation checkbox for conditions. + */ + public function form(&$form, &$form_state, $options = array()) { + parent::form($form, $form_state, $options); + + if ($this->element instanceof RulesCondition) { + $form['negate'] = array( + '#title' => t('Negate'), + '#type' => 'checkbox', + '#description' => t('If checked, the condition result is negated such that it returns TRUE if it evaluates to FALSE.'), + '#default_value' => $this->element->isNegated(), + '#weight' => 5, + ); + } + $this->element->call('form_alter', array(&$form, &$form_state, $options)); + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function form_extract_values($form, &$form_state) { + parent::form_extract_values($form, $form_state); + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + if ($this->element instanceof RulesCondition && isset($form_values['negate'])) { + $this->element->negate($form_values['negate']); + } + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function form_validate($form, &$form_state) { + parent::form_validate($form, $form_state); + // Validate the edited element and throw validation errors if it fails. + try { + $this->element->integrityCheck(); + } + catch (RulesIntegrityException $e) { + form_set_error(implode('][', $e->keys), $e->getMessage()); + } + } + +} + +/** + * UI for Rules Container. + */ +class RulesContainerPluginUI extends RulesPluginUI { + + /** + * Generates a table for editing the contained elements. + */ + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + parent::form($form, $form_state, $options); + $form['elements'] = array( + // Hide during creation or for embedded elements. + '#access' => empty($options['init']) && $this->element->isRoot(), + '#tree' => TRUE, + '#theme' => 'rules_elements', + '#empty' => t('None'), + '#caption' => t('Elements'), + ); + $form['elements']['#attributes']['class'][] = 'rules-container-plugin'; + + // Recurse over all element children or use the provided iterator. + $iterator = isset($iterator) ? $iterator : $this->element->elements(); + $root_depth = $this->element->depth(); + foreach ($iterator as $key => $child) { + $id = $child->elementId(); + + // Do not render rules as container element when displayed in a rule set. + $is_container = $child instanceof RulesContainerPlugin && !($child instanceof Rule); + $form['elements'][$id] = array( + '#depth' => $child->depth() - $root_depth - 1, + '#container' => $is_container, + ); + $form['elements'][$id]['label'] = $child->buildContent(); + $form['elements'][$id]['weight'] = array( + '#type' => 'weight', + '#default_value' => $child->weight, + '#delta' => 50, + ); + $form['elements'][$id]['parent_id'] = array( + '#type' => 'hidden', + // If another iterator is passed in, the child parent may not equal + // the current element. Thus ask the child for its parent. + '#default_value' => $child->parentElement()->elementId(), + ); + $form['elements'][$id]['element_id'] = array( + '#type' => 'hidden', + '#default_value' => $id, + ); + $form['elements'][$id]['operations'] = $child->operations(); + } + + // Alter the submit button label. + if (!empty($options['button']) && !empty($options['init'])) { + $form['submit']['#value'] = t('Continue'); + } + elseif (!empty($options['button']) && $this->element->isRoot()) { + $form['submit']['#value'] = t('Save changes'); + } + } + + /** + * Applies the values of the form to the given rule configuration. + */ + public function form_extract_values($form, &$form_state) { + parent::form_extract_values($form, $form_state); + $values = RulesPluginUI::getFormStateValues($form, $form_state); + // Now apply the new hierarchy. + if (isset($values['elements'])) { + foreach ($values['elements'] as $id => $data) { + $child = $this->element->elementMap()->lookup($id); + $child->weight = $data['weight']; + $parent = $this->element->elementMap()->lookup($data['parent_id']); + $child->setParent($parent ? $parent : $this->element); + } + $this->element->sortChildren(TRUE); + } + } + + public function operations() { + $ops = parent::operations(); + $add_ops = self::addOperations(); + $ops['#links'] += $add_ops['#links']; + return $ops; + } + + /** + * Gets the Add-* operations for the given element. + */ + public function addOperations() { + $name = $this->element->root()->name; + $render = array( + '#theme' => 'links__rules', + ); + $render['#attributes']['class'][] = 'rules-operations-add'; + $render['#attributes']['class'][] = 'action-links'; + foreach (rules_fetch_data('plugin_info') as $plugin => $info) { + if (!empty($info['embeddable']) && $this->element instanceof $info['embeddable']) { + $render['#links']['add_' . $plugin] = array( + 'title' => t('Add !name', array('!name' => $plugin)), + 'href' => RulesPluginUI::path($name, 'add', $this->element, $plugin), + ); + } + } + return $render; + } + + public function buildContent() { + $content = parent::buildContent(); + // Don't link the title for embedded container plugins, except for rules. + if (!$this->element->isRoot() && !($this->element instanceof Rule)) { + // $content['label']['#type'] is currently set to 'link', but in this + // case we don't want a link, we just want 'markup' text. + $content['label']['#type'] = 'markup'; + $content['label']['#markup'] = check_plain($content['label']['#title']); + unset($content['label']['#title']); + } + elseif ($this->element->isRoot()) { + $content['description']['settings'] = array( + '#theme' => 'rules_content_group', + '#weight' => -4, + 'machine_name' => array( + '#markup' => t('Machine name') . ': ' . $this->element->name, + ), + 'weight' => array( + '#access' => $this->element instanceof RulesTriggerableInterface, + '#markup' => t('Weight') . ': ' . $this->element->weight, + ), + ); + if (!empty($this->element->tags)) { + $content['description']['tags'] = array( + '#theme' => 'rules_content_group', + '#caption' => t('Tags'), + 'tags' => array( + '#markup' => implode(', ', array_map(function($entry) { return l($entry, '/admin/config/workflow/rules', array('query' => array('event' => '0', 'tag' => $entry))); }, $this->element->tags)), + ), + ); + } + if ($vars = $this->element->componentVariables()) { + $content['description']['variables'] = array( + '#caption' => t('Parameter'), + '#theme' => 'rules_content_group', + ); + foreach ($vars as $name => $info) { + if (!isset($info['parameter']) || $info['parameter']) { + $content['description']['variables'][$name] = array( + '#theme' => 'rules_variable_view', + '#info' => $info, + '#name' => $name, + ); + } + } + } + } + return $content; + } + +} + +/** + * UI for Rules condition container. + */ +class RulesConditionContainerUI extends RulesContainerPluginUI { + + /** + * Implements RulesPluginUIInterface::form(). + */ + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + parent::form($form, $form_state, $options, $iterator); + // Add the add-* operation links. + $form['elements']['#add'] = self::addOperations(); + $form['elements']['#attributes']['class'][] = 'rules-condition-container'; + $form['elements']['#caption'] = t('Conditions'); + + // By default skip. + if (!empty($options['init']) && !$this->element->isRoot()) { + $config = $this->element->root(); + $form['init_help'] = array( + '#type' => 'container', + '#id' => 'rules-plugin-add-help', + 'content' => array( + '#markup' => t('You are about to add a new @plugin to the @config-plugin %label. Use indentation to make conditions a part of this logic group. See the online documentation for more information on condition sets.', + array('@plugin' => $this->element->plugin(), + '@config-plugin' => $config->plugin(), + '%label' => $config->label(), + '@url' => rules_external_help('condition-components'))), + ), + ); + } + $form['negate'] = array( + '#title' => t('Negate'), + '#type' => 'checkbox', + '#description' => t('If checked, the condition result is negated such that it returns TRUE if it evaluates to FALSE.'), + '#default_value' => $this->element->isNegated(), + '#weight' => 5, + ); + } + + /** + * @param array $form + * The form array where to add the form. + * @param array $form_state + * The current form state. + */ + public function form_extract_values($form, &$form_state) { + parent::form_extract_values($form, $form_state); + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + if (isset($form_values['negate'])) { + $this->element->negate($form_values['negate']); + } + } + +} + +/** + * UI for Rules action container. + */ +class RulesActionContainerUI extends RulesContainerPluginUI { + + /** + * Implements RulesPluginUIInterface::form(). + */ + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + parent::form($form, $form_state, $options, $iterator); + // Add the add-* operation links. + $form['elements']['#add'] = self::addOperations(); + $form['elements']['#attributes']['class'][] = 'rules-action-container'; + $form['elements']['#caption'] = t('Actions'); + } + +} + +/** + * Class holding category related methods. + */ +class RulesUICategory { + + /** + * Gets info about all available categories, or about a specific category. + * + * @return array + */ + public static function getInfo($category = NULL) { + $data = rules_fetch_data('category_info'); + if (isset($category)) { + return $data[$category]; + } + return $data; + } + + /** + * Returns a group label, e.g. as usable for opt-groups in a select list. + * + * @param array $item_info + * The info-array of an item, e.g. an entry of hook_rules_action_info(). + * @param bool $in_category + * (optional) Whether group labels for grouping inside a category should be + * return. Defaults to FALSE. + * + * @return string|bool + * The group label to use, or FALSE if none can be found. + */ + public static function getItemGroup($item_info, $in_category = FALSE) { + if (isset($item_info['category']) && !$in_category) { + return self::getCategory($item_info, 'label'); + } + elseif (!empty($item_info['group'])) { + return $item_info['group']; + } + return FALSE; + } + + /** + * Gets the category for the given item info array. + * + * @param array $item_info + * The info-array of an item, e.g. an entry of hook_rules_action_info(). + * @param string|null $key + * (optional) The key of the category info to return, e.g. 'label'. If none + * is given the whole info array is returned. + * + * @return array|mixed|false + * Either the whole category info array or the value of the given key. If + * no category can be found, FALSE is returned. + */ + public static function getCategory($item_info, $key = NULL) { + if (isset($item_info['category'])) { + $info = self::getInfo($item_info['category']); + return isset($key) ? $info[$key] : $info; + } + return FALSE; + } + + /** + * Returns an array of options to use with a select. + * + * Returns an array of options to use with a selectfor the items specified + * in the given hook. + * + * @param string $item_type + * The item type to get options for. One of 'data', 'event', 'condition' and + * 'action'. + * @param array|null $items + * (optional) An array of items to restrict the options to. + * + * @return array + * An array of options. + */ + public static function getOptions($item_type, $items = NULL) { + $sorted_data = array(); + $ungrouped = array(); + $data = $items ? $items : rules_fetch_data($item_type . '_info'); + foreach ($data as $name => $info) { + // Verify the current user has access to use it. + if (!user_access('bypass rules access') && !empty($info['access callback']) && !call_user_func($info['access callback'], $item_type, $name)) { + continue; + } + if ($group = RulesUICategory::getItemGroup($info)) { + $sorted_data[drupal_ucfirst($group)][$name] = drupal_ucfirst($info['label']); + } + else { + $ungrouped[$name] = drupal_ucfirst($info['label']); + } + } + asort($ungrouped); + foreach ($sorted_data as $key => $choices) { + asort($choices); + $sorted_data[$key] = $choices; + } + + // Sort the grouped data by category weights, defaulting to weight 0 for + // groups without a respective category. + $sorted_groups = array(); + foreach (array_keys($sorted_data) as $label) { + $sorted_groups[$label] = array('weight' => 0, 'label' => $label); + } + // Add in category weights. + foreach (RulesUICategory::getInfo() as $info) { + if (isset($sorted_groups[$info['label']])) { + $sorted_groups[$info['label']] = $info; + } + } + uasort($sorted_groups, '_rules_ui_sort_categories'); + + // Now replace weights with group content. + foreach ($sorted_groups as $group => $weight) { + $sorted_groups[$group] = $sorted_data[$group]; + } + return $ungrouped + $sorted_groups; + } + +} + +/** + * Helper for sorting categories. + */ +function _rules_ui_sort_categories($a, $b) { + // @see element_sort() + $a_weight = isset($a['weight']) ? $a['weight'] : 0; + $b_weight = isset($b['weight']) ? $b['weight'] : 0; + if ($a_weight == $b_weight) { + // @see element_sort_by_title() + $a_title = isset($a['label']) ? $a['label'] : ''; + $b_title = isset($b['label']) ? $b['label'] : ''; + return strnatcasecmp($a_title, $b_title); + } + return ($a_weight < $b_weight) ? -1 : 1; +} diff --git a/ui/ui.data.inc b/ui/ui.data.inc new file mode 100644 index 0000000..82d4b7a --- /dev/null +++ b/ui/ui.data.inc @@ -0,0 +1,702 @@ +availableVariables(); + // Default to variables with the same name as the parameter. + if (isset($vars[$name])) { + $settings[$name . ':select'] = $name; + } + // If there is only one match, use it by default. + elseif (count($matches = RulesData::matchingDataSelector($vars, $info, '', 1, FALSE)) == 1) { + $settings[$name . ':select'] = rules_array_key($matches); + } + } + $form[$name . ':select'] = array( + '#type' => 'rules_data_selection', + '#title' => t('Data selector'), + '#default_value' => $settings[$name . ':select'], + '#required' => empty($info['optional']), + '#autocomplete_path' => RulesPluginUI::path($element->root()->name, 'autocomplete' . '/' . $name), + // Make the autocomplete textfield big enough so that it can display + // descriptions without word wraps. + '#size' => 75, + '#description' => t("The data selector helps you drill down into the data available to Rules. To make entity fields appear in the data selector, you may have to use the condition 'entity has field' (or 'content is of type'). More useful tips about data selection is available in the online documentation.", + array('@url' => rules_external_help('data-selection'))), + ); + + $cache = rules_get_cache(); + $form['types_help'] = array( + '#theme' => 'rules_settings_help', + '#heading' => t('Data types'), + ); + if ($info['type'] == '*') { + $type_labels[] = t('any'); + } + else { + $types = is_array($info['type']) ? $info['type'] : array($info['type']); + $type_labels = array(); + foreach ($types as $type) { + $type_labels[] = drupal_ucfirst(isset($cache['data_info'][$type]['label']) ? $cache['data_info'][$type]['label'] : $type); + } + } + $form['types_help']['#text'] = format_plural(count($type_labels), 'Select data of the type %types.', 'Select data of the types %types.', array('%types' => implode(', ', $type_labels))); + + if (!empty($info['translatable'])) { + if (empty($info['custom translation language'])) { + $text = t('If a multilingual data source (i.e. a translatable field) is given, the argument is translated to the current interface language.'); + } + else { + $text = t('If a multilingual data source (i.e. a translatable field) is given, the argument is translated to the configured language.'); + } + $form['translation'] = array( + '#theme' => 'rules_settings_help', + '#text' => $text, + '#heading' => t('Translation'), + ); + } + $form['help'] = array( + '#theme' => 'rules_data_selector_help', + '#variables' => $element->availableVariables(), + '#parameter' => $info, + ); + + // Add data processor. + $settings += array($name . ':process' => array()); + $form[$name . ':process'] = array(); + RulesDataProcessor::attachForm($form[$name . ':process'], $settings[$name . ':process'], $info, $element->availableVariables()); + return $form; + } + + /** + * Renders the value with a label if an options list is available. + * + * Used for data UI classes implementing the + * RulesDataDirectInputFormInterface. + * + * In case an options list is available, the usual render() method won't + * be invoked, instead the selected entry is rendered via this method. + * + * @todo for Drupal 8: Refactor to avoid implementations have to care about + * option lists when generating the form, but not when rendering values. + */ + public static function renderOptionsLabel($value, $name, $info, RulesPlugin $element) { + if (!empty($info['options list'])) { + $element->call('loadBasicInclude'); + $options = entity_property_options_flatten(call_user_func($info['options list'], $element, $name)); + if (!is_array($value) && isset($options[$value])) { + $value = $options[$value]; + } + elseif (is_array($value)) { + foreach ($value as $key => $single_value) { + if (isset($options[$single_value])) { + $value[$key] = $options[$single_value]; + } + } + $value = implode(', ', $value); + } + return array( + 'content' => array('#markup' => check_plain($value)), + '#attributes' => array('class' => array('rules-parameter-options-entry')), + ); + } + } + + /** + * Returns the data type and parameter information for the given arguments. + * + * This helper may be used by options list callbacks operation at data-type + * level, see RulesDataInputOptionsListInterface. + */ + public static function getTypeInfo(RulesPlugin $element, $name) { + $parameters = $element->pluginParameterInfo(); + return array($parameters[$name]['type'], $parameters[$name]); + } + +} + +/** + * UI for textual data. + */ +class RulesDataUIText extends RulesDataUI implements RulesDataDirectInputFormInterface { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + if (!empty($info['options list'])) { + // Make sure the .rules.inc of the providing module is included as the + // options list callback may reside there. + $element->call('loadBasicInclude'); + $form[$name] = array( + '#type' => 'select', + '#options' => call_user_func($info['options list'], $element, $name), + ); + } + else { + $form[$name] = array( + '#type' => 'textarea', + '#rows' => 3, + ); + RulesDataInputEvaluator::attachForm($form, $settings, $info, $element->availableVariables()); + } + $settings += array($name => isset($info['default value']) ? $info['default value'] : NULL); + $form[$name] += array( + '#title' => t('Value'), + '#default_value' => $settings[$name], + '#required' => empty($info['optional']), + '#after_build' => array('rules_ui_element_fix_empty_after_build'), + ); + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + return array( + 'content' => array('#markup' => check_plain($value)), + '#attributes' => array('class' => array('rules-parameter-text')), + ); + } + +} + +/** + * UI for text tokens. + */ +class RulesDataUITextToken extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if ($form[$name]['#type'] == 'textarea') { + $form[$name]['#element_validate'][] = 'rules_ui_element_token_validate'; + $form[$name]['#description'] = t('May only contain lowercase letters, numbers, and underscores and has to start with a letter.'); + $form[$name]['#rows'] = 1; + } + return $form; + } + +} + +/** + * UI for formatted text. + */ +class RulesDataUITextFormatted extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + $settings += array($name => isset($info['default value']) ? $info['default value'] : array('value' => NULL, 'format' => NULL)); + + $form[$name]['#type'] = 'text_format'; + $form[$name]['#base_type'] = 'textarea'; + $form[$name]['#default_value'] = $settings[$name]['value']; + $form[$name]['#format'] = $settings[$name]['format']; + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + return array( + 'content' => array('#markup' => check_plain($value['value'])), + '#attributes' => array('class' => array('rules-parameter-text-formatted')), + ); + } + +} + +/** + * UI for decimal data. + */ +class RulesDataUIDecimal extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if (empty($info['options list'])) { + $form[$name]['#type'] = 'textfield'; + } + $form[$name]['#element_validate'][] = 'rules_ui_element_decimal_validate'; + $form[$name]['#rows'] = 1; + return $form; + } + +} + +/** + * UI for integers. + */ +class RulesDataUIInteger extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if (empty($info['options list'])) { + $form[$name]['#type'] = 'textfield'; + } + $form[$name]['#element_validate'][] = 'rules_ui_element_integer_validate'; + return $form; + } + +} + +/** + * UI for IP addresses. + */ +class RulesDataUIIPAddress extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if (empty($info['options list'])) { + $form[$name]['#type'] = 'textfield'; + $form[$name]['#description'] = t('If not provided, the IP address of the current user will be used.'); + } + $form[$name]['#element_validate'][] = 'rules_ui_element_ip_address_validate'; + $form[$name]['#rows'] = 1; + return $form; + } + +} + +/** + * UI for boolean data. + */ +class RulesDataUIBoolean extends RulesDataUI implements RulesDataDirectInputFormInterface { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $settings += array($name => isset($info['default value']) ? $info['default value'] : NULL); + // Note: Due to the checkbox even optional parameter always receive a value. + $form[$name] = array( + '#type' => 'radios', + '#default_value' => $settings[$name], + '#options' => array( + TRUE => t('@label: True.', array('@label' => $info['label'])), + FALSE => t('@label: False.', array('@label' => $info['label'])), + ), + ); + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + return array( + 'content' => array('#markup' => !empty($value) ? t('true') : t('false')), + '#attributes' => array('class' => array('rules-parameter-boolean')), + ); + } + +} + +/** + * UI for dates. + */ +class RulesDataUIDate extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $settings += array($name => isset($info['default value']) ? $info['default value'] : (empty($info['optional']) ? gmdate('Y-m-d H:i:s', time()) : NULL)); + + // Convert any configured timestamp into a readable format. + if (is_numeric($settings[$name])) { + $settings[$name] = gmdate('Y-m-d H:i:s', $settings[$name]); + } + $form = parent::inputForm($name, $info, $settings, $element); + $form[$name]['#type'] = 'textfield'; + $form[$name]['#element_validate'][] = 'rules_ui_element_date_validate'; + // Note that the date input evaluator takes care for parsing dates using + // strtotime() into a timestamp, which is the internal date format. + $form[$name]['#description'] = t('The date in GMT. You may enter a fixed time (like %format) or any other values in GMT known by the PHP !strtotime function (like "+1 day"). Relative dates like "+1 day" or "now" relate to the evaluation time.', + array('%format' => gmdate('Y-m-d H:i:s', time() + 86400), + '!strtotime' => l('strtotime()', 'http://php.net/strtotime'))); + + // @todo Leverage the jquery datepicker+timepicker once a module providing + // The timepicker is available. + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + $value = is_numeric($value) ? format_date($value, 'short') : check_plain($value); + return array( + 'content' => array('#markup' => $value), + '#attributes' => array('class' => array('rules-parameter-date')), + ); + } + +} + +/** + * UI for duration type parameter. + */ +class RulesDataUIDuration extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + $form[$name]['#type'] = 'rules_duration'; + $form[$name]['#after_build'][] = 'rules_ui_element_duration_after_build'; + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + $value = is_numeric($value) ? format_interval($value) : check_plain($value); + return array( + 'content' => array('#markup' => $value), + '#attributes' => array('class' => array('rules-parameter-duration')), + ); + } + +} + +/** + * UI for the URI type parameter. + */ +class RulesDataUIURI extends RulesDataUIText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + $form[$name]['#rows'] = 1; + $form[$name]['#description'] = t('You may enter relative URLs like %url as well as absolute URLs like %absolute-url.', array('%url' => 'user/login?destination=node', '%absolute-url' => 'https://www.drupal.org')); + return $form; + } + +} + +/** + * UI for lists of textual data. + */ +class RulesDataUIListText extends RulesDataUIText { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + * + * @todo This does not work for inputting textual values including "\n". + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $settings += array($name => isset($info['default value']) ? $info['default value'] : NULL); + $form = parent::inputForm($name, $info, $settings, $element); + + if ($form[$name]['#type'] == 'textarea') { + // Fix up the value to be an array during after build. + $form[$name]['#delimiter'] = "\n"; + $form[$name]['#after_build'][] = 'rules_ui_list_textarea_after_build'; + $form[$name]['#pre_render'][] = 'rules_ui_list_textarea_pre_render'; + $form[$name]['#default_value'] = !empty($settings[$name]) ? implode("\n", $settings[$name]) : NULL; + $form[$name]['#description'] = t('A list of values, one on each line.'); + } + else { + $form[$name]['#multiple'] = TRUE; + } + return $form; + } + + /** + * Implements RulesDataDirectInputFormInterface::render(). + */ + public static function render($value) { + return array( + 'content' => array('#markup' => check_plain(implode(', ', $value))), + '#attributes' => array('class' => array('rules-parameter-list')), + ); + } + +} + +/** + * UI for lists of integers. + */ +class RulesDataUIListInteger extends RulesDataUIListText { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $settings += array($name => isset($info['default value']) ? $info['default value'] : NULL); + $form = parent::inputForm($name, $info, $settings, $element); + + if ($form[$name]['#type'] == 'textarea') { + $form[$name]['#description'] = t('A list of integers, separated by commas. E.g. enter "1, 2, 3".'); + $form[$name]['#delimiter'] = ','; + $form[$name]['#default_value'] = !empty($settings[$name]) ? implode(", ", $settings[$name]) : NULL; + $form[$name]['#element_validate'][] = 'rules_ui_element_integer_list_validate'; + $form[$name]['#rows'] = 1; + } + return $form; + } + +} + +/** + * UI for lists of tokens. + */ +class RulesDataUIListToken extends RulesDataUIListInteger { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + + if ($form[$name]['#type'] == 'textarea') { + $form[$name]['#description'] = t('A list of text tokens, separated by commas. E.g. enter "one, two, three".'); + $form[$name]['#element_validate'] = array('rules_ui_element_token_list_validate'); + } + return $form; + } + +} + +/** + * UI for entity-based data types. + */ +class RulesDataUIEntity extends RulesDataUIText { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'selector'; + } + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if (empty($info['options list'])) { + $form[$name]['#type'] = 'textfield'; + + $entity_info = entity_get_info($info['type']); + if (empty($entity_info['entity keys']['name'])) { + $form[$name]['#element_validate'][] = 'rules_ui_element_integer_validate'; + } + $form[$name]['#title'] = t('@entity identifier', array('@entity' => $entity_info['label'])); + $entity_label = strtolower($entity_info['label'][0]) . substr($entity_info['label'], 1); + $form[$name]['#description'] = t('Specify an identifier of a @entity.', array('@entity' => $entity_label)); + } + return $form; + } + +} + +/** + * UI for exportable entity-based data types. + */ +class RulesDataUIEntityExportable extends RulesDataUIEntity { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + +} + +/** + * Data UI variant displaying a select list of available bundle entities. + * + * This is used for "bundle entities" implemented via the 'bundle of' feature + * of entity.module. + */ +class RulesDataUIBundleEntity extends RulesDataUIEntity implements RulesDataInputOptionsListInterface { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + + /** + * Implements RulesDataInputOptionsListInterface::optionsList(). + */ + public static function optionsList(RulesPlugin $element, $name) { + list($data_type, $parameter_info) = RulesDataUI::getTypeInfo($element, $name); + $bundles = array(); + $entity_info = entity_get_info(); + $bundle_of_type = $entity_info[$data_type]['bundle of']; + if (isset($entity_info[$bundle_of_type]['bundles'])) { + foreach ($entity_info[$bundle_of_type]['bundles'] as $bundle_name => $bundle_info) { + $bundles[$bundle_name] = $bundle_info['label']; + } + } + return $bundles; + } + +} + +/** + * UI for taxonomy vocabularies. + * + * @see RulesTaxonomyVocabularyWrapper + */ +class RulesDataUITaxonomyVocabulary extends RulesDataUIEntity implements RulesDataInputOptionsListInterface { + + /** + * Overrides RulesDataUI::getDefaultMode(). + */ + public static function getDefaultMode() { + return 'input'; + } + + /** + * Implements RulesDataInputOptionsListInterface::optionsList(). + */ + public static function optionsList(RulesPlugin $element, $name) { + $options = array(); + foreach (taxonomy_vocabulary_get_names() as $machine_name => $vocab) { + $options[$machine_name] = $vocab->name; + } + return $options; + } + +} + +/** + * UI for lists of entity-based data types. + */ +class RulesDataUIListEntity extends RulesDataUIListInteger { + + /** + * Implements RulesDataDirectInputFormInterface::inputForm(). + */ + public static function inputForm($name, $info, $settings, RulesPlugin $element) { + $form = parent::inputForm($name, $info, $settings, $element); + if (empty($info['options list'])) { + + $entity_info = entity_get_info(entity_property_list_extract_type($info['type'])); + if (!empty($entity_info['entity keys']['name'])) { + $form[$name]['#element_validate'] = array('rules_ui_element_token_list_validate'); + } + $form[$name]['#title'] = t('@entity identifiers', array('@entity' => $entity_info['label'])); + $entity_label = strtolower($entity_info['label'][0]) . substr($entity_info['label'], 1); + $form[$name]['#description'] = t('Specify a comma-separated list of identifiers of @entity entities.', array('@entity' => $entity_label)); + } + return $form; + } + +} diff --git a/ui/ui.forms.inc b/ui/ui.forms.inc new file mode 100644 index 0000000..66ab19b --- /dev/null +++ b/ui/ui.forms.inc @@ -0,0 +1,1005 @@ + 'rules_ui_form_ajax_reload_form', + 'wrapper' => 'rules-form-wrapper', + 'effect' => $effect, + 'speed' => 'fast', + ); +} + +/** + * Submit handler for switching the parameter input mode. + */ +function rules_ui_parameter_replace_submit($form, &$form_state) { + if (isset($form_state['triggering_element'])) { + $name = $form_state['triggering_element']['#parameter']; + $form_state['parameter_mode'][$name] = $form_state['parameter_mode'][$name] == 'selector' ? 'input' : 'selector'; + } + $form_state['rebuild'] = TRUE; +} + +/** + * General form submit handler, that rebuilds the form. + */ +function rules_form_submit_rebuild($form, &$form_state) { + $form_state['rebuild'] = TRUE; +} + +/** + * Edit a rules configuration. + */ +function rules_ui_form_edit_rules_config($form, &$form_state, $rules_config, $base_path) { + RulesPluginUI::$basePath = $base_path; + $form_state += array('rules_element' => $rules_config); + // Add the rule configuration's form. + $rules_config->form($form, $form_state, array('show settings' => TRUE, 'button' => TRUE)); + $form['#validate'] = array('rules_ui_form_rules_config_validate'); + return $form; +} + +/** + * General rules configuration form validation callback. + * + * Also populates the rules configuration with the form values. + */ +function rules_ui_form_rules_config_validate($form, &$form_state) { + $form_state['rules_element']->form_validate($form, $form_state); +} + +/** + * Edit a rules configuration form submit callback. + */ +function rules_ui_form_edit_rules_config_submit($form, &$form_state) { + $form_state['rules_element']->form_submit($form, $form_state); + drupal_set_message(t('Your changes have been saved.')); + if (empty($form_state['redirect'])) { + $form_state['redirect'] = RulesPluginUI::defaultRedirect($form_state['rules_element']); + } +} + +/** + * Clone a rules configuration form. + */ +function rules_ui_form_clone_rules_config($form, &$form_state, $rules_config, $base_path) { + RulesPluginUI::$basePath = $base_path; + $rules_config = clone $rules_config; + $rules_config->id = NULL; + $rules_config->name = ''; + $rules_config->label .= ' (' . t('cloned') . ')'; + $rules_config->status = ENTITY_CUSTOM; + + $form['#validate'][] = 'rules_ui_form_rules_config_validate'; + $form['#submit'][] = 'rules_ui_form_edit_rules_config_submit'; + $form_state += array('rules_element' => $rules_config, 'op' => 'clone'); + + // Add the rule configuration's form. + $rules_config->form($form, $form_state, array('show settings' => TRUE, 'button' => TRUE, 'init' => TRUE)); + + // Open the settings fieldset so altering the name is easier. + $form['settings']['#collapsed'] = FALSE; + return $form; +} + +/** + * A simple form just showing a textarea with the export. + */ +function rules_ui_form_export_rules_config($form, &$form_state, $rules_config, $base_path) { + $form['export'] = array( + '#type' => 'textarea', + '#title' => t('Export'), + '#description' => t('For importing copy the content of the text area and paste it into the import page.'), + '#rows' => 25, + '#default_value' => $rules_config->export(), + ); + return $form; +} + +/** + * Configuration form to directly execute a rules configuration. + */ +function rules_ui_form_execute_rules_config($form, &$form_state, $rules_config, $base_path) { + // Only components can be executed. + if (!($rules_config instanceof RulesTriggerableInterface)) { + RulesPluginUI::$basePath = $base_path; + // Create either the appropriate action or condition element. + $element = rules_plugin_factory($rules_config instanceof RulesActionInterface ? 'action' : 'condition', 'component_' . $rules_config->name); + $form['exec_help'] = array( + '#prefix' => '

    ', + '#markup' => t('This form allows you to manually trigger the execution of the @plugin "%label". If this component requires any parameters, input the suiting execution arguments below.', array('@plugin' => $rules_config->plugin(), '%label' => $rules_config->label())), + '#suffix' => '

    ', + ); + $element->form($form, $form_state); + + // For conditions hide the option to negate them. + if (isset($form['negate'])) { + $form['negate']['#access'] = FALSE; + } + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Execute'), + '#weight' => 20, + ); + // Re-use the validation callback, which will also populate the action with + // the configuration settings in the form. + $form['#validate'] = array('rules_ui_form_rules_config_validate'); + return $form; + } + drupal_not_found(); + exit; +} + +/** + * Submit callback for directly executing a component. + */ +function rules_ui_form_execute_rules_config_submit($form, &$form_state) { + $element = $form_state['rules_element']; + $result = $element->execute(); + if ($element instanceof RulesActionInterface) { + drupal_set_message(t('Component %label has been executed.', array('%label' => $element->label()))); + } + else { + drupal_set_message(t('Component %label evaluated to %result.', array('%label' => $element->label(), '%result' => $result ? 'true' : 'false'))); + } +} + +/** + * Gets the confirmation question for valid operations, or else FALSE. + */ +function rules_ui_confirm_operations($op, $rules_config) { + $vars = array('%plugin' => $rules_config->plugin(), '%label' => $rules_config->label()); + + switch ($op) { + case 'enable': + return array( + t('Are you sure you want to enable the %plugin %label?', $vars), + '', + ); + + case 'disable': + return array( + t('Are you sure you want to disable the %plugin %label?', $vars), + '', + ); + + case 'revert': + return array( + t('Are you sure you want to revert the %plugin %label?', $vars), + t('This action cannot be undone.'), + ); + + case 'delete': + return array( + t('Are you sure you want to delete the %plugin %label?', $vars), + t('This action cannot be undone.'), + ); + + default: + return FALSE; + } +} + +/** + * Confirmation form for applying the operation to the config. + */ +function rules_ui_form_rules_config_confirm_op($form, &$form_state, $rules_config, $op, $base_path) { + if (list($confirm_question, $description) = rules_ui_confirm_operations($op, $rules_config)) { + RulesPluginUI::$basePath = $base_path; + $form_state += array('rules_config' => $rules_config, 'op' => $op); + return confirm_form($form, $confirm_question, $base_path, $description, t('Confirm'), t('Cancel')); + } + else { + drupal_not_found(); + exit; + } +} + +/** + * Applies the operation and returns the message to show to the user. + * + * The operation is also logged to the watchdog. Note that the string is + * defined two times so that the translation extractor can find it. + */ +function rules_ui_confirm_operation_apply($op, $rules_config) { + $vars = array('%plugin' => $rules_config->plugin(), '%label' => $rules_config->label()); + $edit_link = l(t('edit'), RulesPluginUI::path($rules_config->name)); + + switch ($op) { + case 'enable': + $rules_config->active = TRUE; + $rules_config->save(); + watchdog('rules', 'Enabled %plugin %label.', $vars, WATCHDOG_NOTICE, $edit_link); + return t('Enabled %plugin %label.', $vars); + + case 'disable': + $rules_config->active = FALSE; + $rules_config->save(); + watchdog('rules', 'Disabled %plugin %label.', $vars, WATCHDOG_NOTICE, $edit_link); + return t('Disabled %plugin %label.', $vars); + + case 'revert': + $rules_config->delete(); + watchdog('rules', 'Reverted %plugin %label to the defaults.', $vars, WATCHDOG_NOTICE, $edit_link); + return t('Reverted %plugin %label to the defaults.', $vars); + + case 'delete': + $rules_config->delete(); + watchdog('rules', 'Deleted %plugin %label.', $vars); + return t('Deleted %plugin %label.', $vars); + } +} + +/** + * Rule config deletion form submit callback. + */ +function rules_ui_form_rules_config_confirm_op_submit($form, &$form_state) { + if ($form_state['values']['confirm']) { + $msg = rules_ui_confirm_operation_apply($form_state['op'], $form_state['rules_config']); + drupal_set_message($msg); + } +} + +/** + * Add a new element a rules configuration. + */ +function rules_ui_add_element($form, &$form_state, $rules_config, $plugin_name, RulesContainerPlugin $parent, $base_path) { + $cache = rules_get_cache(); + if (!isset($cache['plugin_info'][$plugin_name]['class'])) { + drupal_not_found(); + exit; + } + RulesPluginUI::$basePath = $base_path; + $plugin_is_abstract = in_array('RulesAbstractPlugin', class_parents($cache['plugin_info'][$plugin_name]['class'])); + // In the first step create the element and in the second step show its edit + // form. + if ($plugin_is_abstract && !isset($form_state['rules_element'])) { + RulesPluginUI::formDefaults($form, $form_state); + $form_state += array('parent_element' => $parent, 'plugin' => $plugin_name); + + $form['element_name'] = array( + '#type' => 'select', + '#title' => t('Select the %element to add', array('%element' => $plugin_name)), + '#options' => RulesPluginUI::getOptions($plugin_name), + '#ajax' => rules_ui_form_default_ajax() + array( + 'trigger_as' => array('name' => 'continue'), + ), + ); + $form['continue'] = array( + '#type' => 'submit', + '#name' => 'continue', + '#value' => t('Continue'), + '#ajax' => rules_ui_form_default_ajax(), + ); + } + elseif (!$plugin_is_abstract) { + // Create the initial, empty element. + $element = rules_plugin_factory($plugin_name); + // Always add the new element at the bottom, thus set an appropriate weight. + $iterator = $parent->getIterator(); + if ($sibling = end($iterator)) { + $element->weight = $sibling->weight + 1; + } + $element->setParent($parent); + $form_state['rules_element'] = $element; + } + + if (isset($form_state['rules_element'])) { + $form_state['rules_element']->form($form, $form_state, array('button' => TRUE, 'init' => TRUE)); + $form['#validate'][] = 'rules_ui_edit_element_validate'; + $form['#submit'][] = 'rules_ui_edit_element_submit'; + } + return $form; +} + +/** + * Add element submit callback. + * + * Used for "abstract plugins" to create the initial element object with the + * given implementation name and rebuild the form. + */ +function rules_ui_add_element_submit($form, &$form_state) { + $element = rules_plugin_factory($form_state['plugin'], $form_state['values']['element_name']); + + // Always add the new element at the bottom, thus set an appropriate weight. + $iterator = $form_state['parent_element']->getIterator(); + if ($sibling = end($iterator)) { + $element->weight = $sibling->weight + 1; + } + // Clear the element settings so they won't be processed on serialization as + // there is nothing to be processed yet. + $element->settings = array(); + $element->setParent($form_state['parent_element']); + + $form_state['rules_element'] = $element; + $form_state['rebuild'] = TRUE; +} + +/** + * Delete elements. + */ +function rules_ui_delete_element($form, &$form_state, $rules_config, $rules_element, $base_path) { + RulesPluginUI::$basePath = $base_path; + + if (empty($form_state['rules_config'])) { + // Before modifying the rules config we have to clone it, so any + // modifications won't appear in the static cache of the loading controller. + $rules_config = clone $rules_config; + // Also get the element from the cloned config. + $rules_element = $rules_config->elementMap()->lookup($rules_element->elementId()); + + $form_state['rules_config'] = $rules_config; + $form_state['rules_element'] = $rules_element; + $form_state['element_parent'] = $rules_element->parentElement(); + } + + // Try deleting the element and warn the user if something breaks, but + // save the parent for determining the right redirect target on submit. + $removed_plugin = $form_state['rules_element']->plugin(); + $rules_element->delete(); + + if (empty($rules_config->dirty) && empty($form_state['input'])) { + try { + $rules_config->integrityCheck(); + } + catch (RulesIntegrityException $e) { + $args = array( + '@plugin' => $e->element->plugin(), + '%label' => $e->element->label(), + '@removed-plugin' => $removed_plugin, + '!url' => url(RulesPluginUI::path($form_state['rules_config']->name, 'edit', $e->element)), + ); + drupal_set_message(t('Deleting this @removed-plugin would break your configuration as some of its provided variables are utilized by the @plugin %label.', $args), 'warning'); + } + } + + $confirm_question = t('Are you sure you want to delete the %element_plugin %element_name?', array( + '%element_plugin' => $rules_element->plugin(), + '%element_name' => $rules_element->label(), + )); + return confirm_form($form, $confirm_question, RulesPluginUI::path($rules_config->name), t('This action cannot be undone.'), t('Delete'), t('Cancel')); +} + +/** + * Rule config deletion form submit callback. + */ +function rules_ui_delete_element_submit($form, &$form_state) { + $rules_config = $form_state['rules_config']; + $rules_config->save(); + if (empty($form_state['redirect'])) { + $form_state['redirect'] = RulesPluginUI::defaultRedirect($form_state['element_parent']); + } +} + +/** + * Configure a rule element. + */ +function rules_ui_edit_element($form, &$form_state, $rules_config, $element, $base_path) { + RulesPluginUI::$basePath = $base_path; + $form_state += array('rules_element' => $element); + $form_state['rules_element']->form($form, $form_state, array('button' => TRUE)); + return $form; +} + +/** + * Validate the element configuration. + */ +function rules_ui_edit_element_validate($form, &$form_state) { + $form_state['rules_element']->form_validate($form, $form_state); +} + +/** + * Submit the element configuration. + */ +function rules_ui_edit_element_submit($form, &$form_state) { + $form_state['rules_element']->form_submit($form, $form_state); + drupal_set_message(t('Your changes have been saved.')); + if (empty($form_state['redirect'])) { + $form_state['redirect'] = RulesPluginUI::defaultRedirect($form_state['rules_element']); + } +} + +/** + * Form builder for the "add event" page. + */ +function rules_ui_add_event_page($form, &$form_state, RulesTriggerableInterface $rules_config, $base_path) { + RulesPluginUI::$basePath = $base_path; + RulesPluginUI::formDefaults($form, $form_state); + $form = rules_ui_add_event($form, $form_state, $rules_config, $base_path); + $form['#validate'][] = 'rules_ui_add_event_validate'; + return $form; +} + +/** + * Submit the event configuration. + */ +function rules_ui_add_event_page_submit($form, &$form_state) { + rules_ui_add_event_apply($form, $form_state); + $rules_config = $form_state['rules_config']; + + // Tell the user if this breaks something, but let him proceed. + if (empty($rules_config->dirty)) { + try { + $rules_config->integrityCheck(); + } + catch (RulesIntegrityException $e) { + $warning = TRUE; + drupal_set_message(t('Added the event, but it does not provide all variables utilized.'), 'warning'); + } + } + $rules_config->save(); + if (!isset($warning)) { + $events = rules_fetch_data('event_info'); + $label = $events[$form_state['values']['event']]['label']; + drupal_set_message(t('Added event %event.', array('%event' => $label))); + } +} + +/** + * Add a new event. + */ +function rules_ui_add_event($form, &$form_state, RulesReactionRule $rules_config, $base_path) { + $form_state += array('rules_config' => $rules_config); + $events = array_diff_key(rules_fetch_data('event_info'), array_flip($rules_config->events())); + + $form['help'] = array( + '#markup' => t('Select the event to add. However note that all added events need to provide all variables that should be available to your rule.'), + ); + $form['event'] = array( + '#type' => 'select', + '#title' => t('React on event'), + '#options' => RulesPluginUI::getOptions('event', $events), + '#description' => t('Whenever the event occurs, rule evaluation is triggered.'), + '#ajax' => rules_ui_form_default_ajax(), + '#required' => TRUE, + ); + if (!empty($form_state['values']['event'])) { + $handler = rules_get_event_handler($form_state['values']['event']); + $form['event_settings'] = $handler->buildForm($form_state); + } + else { + $form['event_settings'] = array(); + } + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Add'), + ); + $form_state['redirect'] = RulesPluginUI::path($rules_config->name); + return $form; +} + +/** + * Validation callback for adding an event. + */ +function rules_ui_add_event_validate($form, $form_state) { + $handler = rules_get_event_handler($form_state['values']['event']); + $handler->extractFormValues($form['event_settings'], $form_state); + try { + $handler->validate(); + } + catch (RulesIntegrityException $e) { + form_set_error(implode('][', $e->keys), $e->getMessage()); + } +} + +/** + * Submit callback that just adds the selected event. + * + * @see rules_admin_add_reaction_rule() + */ +function rules_ui_add_event_apply($form, &$form_state) { + $handler = rules_get_event_handler($form_state['values']['event']); + $handler->extractFormValues($form['event_settings'], $form_state); + $form_state['rules_config']->event($form_state['values']['event'], $handler->getSettings()); +} + +/** + * Form to remove an event from a rule. + */ +function rules_ui_remove_event($form, &$form_state, $rules_config, $event, $base_path) { + RulesPluginUI::$basePath = $base_path; + $form_state += array('rules_config' => $rules_config, 'rules_event' => $event); + $event_info = rules_get_event_info($event); + $form_state['event_label'] = $event_info['label']; + $confirm_question = t('Are you sure you want to remove the event?'); + return confirm_form($form, $confirm_question, RulesPluginUI::path($rules_config->name), t('You are about to remove the event %event.', array('%event' => $form_state['event_label'])), t('Remove'), t('Cancel')); +} + +/** + * Submit the event configuration. + */ +function rules_ui_remove_event_submit($form, &$form_state) { + $rules_config = $form_state['rules_config']; + $rules_config->removeEvent($form_state['rules_event']); + // Tell the user if this breaks something, but let him proceed. + if (empty($rules_config->dirty)) { + try { + $rules_config->integrityCheck(); + } + catch (RulesIntegrityException $e) { + $warning = TRUE; + drupal_set_message(t('Removed the event, but it had provided some variables which are now missing.'), 'warning'); + } + } + $rules_config->save(); + if (!isset($warning)) { + drupal_set_message(t('Event %event has been removed.', array('%event' => $form_state['event_label']))); + } + $form_state['redirect'] = RulesPluginUI::path($rules_config->name); +} + +/** + * Import form for rule configurations. + */ +function rules_ui_import_form($form, &$form_state, $base_path) { + RulesPluginUI::$basePath = $base_path; + RulesPluginUI::formDefaults($form, $form_state); + $form['import'] = array( + '#type' => 'textarea', + '#title' => t('Import'), + '#description' => t('Paste an exported Rules configuration here.'), + '#rows' => 20, + ); + $form['overwrite'] = array( + '#title' => t('Overwrite'), + '#type' => 'checkbox', + '#description' => t('If checked, any existing configuration with the same identifier will be replaced by the import.'), + '#default_value' => FALSE, + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Import'), + ); + return $form; +} + +/** + * Validation callback for the import form. + */ +function rules_ui_import_form_validate($form, &$form_state) { + if ($rules_config = rules_import($form_state['values']['import'], $error_msg)) { + // Store the successfully imported entity in $form_state. + $form_state['rules_config'] = $rules_config; + + // Check for existing entities with the same identifier. + if ($existing_config = rules_config_load($rules_config->name)) { + // Don't import and overwrite the existing configuration unless the user + // has checked the 'overwrite' box. + if (!$form_state['values']['overwrite']) { + $vars = array('@entity' => t('Rules configuration'), '%label' => $rules_config->label()); + form_set_error('import', t('Import of @entity %label failed, a @entity with the same machine name already exists. Check the overwrite option to replace it.', $vars)); + } + // Don't import if the existing configuration has the status ENTITY_FIXED + // because that means the configuration can't be modified. + if ($existing_config->status == ENTITY_FIXED) { + $vars = array('@entity' => t('Rules configuration'), '%label' => $rules_config->label()); + form_set_error('import', t("Import of @entity %label failed, a @entity with the same machine name already exists and is marked as ENTITY_FIXED meaning it can't be mofified.", $vars)); + } + } + + try { + $rules_config->integrityCheck(); + } + catch (RulesIntegrityException $e) { + form_set_error('import', t('Integrity check for the imported configuration failed. Error message: %message.', array('%message' => $e->getMessage()))); + } + if (!user_access('bypass rules access') && !$rules_config->access()) { + form_set_error('import', t('You have insufficient access permissions for importing this Rules configuration.')); + } + } + else { + form_set_error('import', t('Import failed.')); + if ($error_msg) { + drupal_set_message($error_msg, 'error'); + } + } +} + +/** + * Submit callback for the import form. + */ +function rules_ui_import_form_submit($form, &$form_state) { + $rules_config = $form_state['rules_config']; + + if ($existing_config = rules_config_load($rules_config->name)) { + // Copy DB id and remove the new indicator to overwrite the existing record. + $rules_config->id = $existing_config->id; + // Set the ENTITY_CUSTOM bit in the status bitmask, because the + // configuration has now been customized by the import. + $rules_config->status = $existing_config->status | ENTITY_CUSTOM; + unset($rules_config->is_new); + } + $rules_config->save(); + $vars = array('@entity' => t('Rules configuration'), '%label' => $rules_config->label()); + watchdog('rules_config', 'Imported @entity %label.', $vars); + drupal_set_message(t('Imported @entity %label.', $vars)); + $form_state['redirect'] = RulesPluginUI::$basePath; +} + +/** + * FAPI process callback for the data selection widget. + * + * This finalises the auto completion callback path by appending the form build + * id. + */ +function rules_data_selection_process($element, &$form_state, $form) { + $element['#autocomplete_path'] .= '/' . $form['#build_id']; + $form_state['cache'] = TRUE; + return $element; +} + +/** + * Autocomplete data selection results. + */ +function rules_ui_form_data_selection_auto_completion($parameter, $form_build_id, $string = '') { + // Get the form and its state from the cache to get the currently edited + // or created element. + $form_state = form_state_defaults(); + $form = form_get_cache($form_build_id, $form_state); + if (!isset($form_state['rules_element'])) { + return; + } + $element = $form_state['rules_element']; + + $params = $element->pluginParameterInfo(); + $matches = array(); + if (isset($params[$parameter])) { + $parts = explode(':', $string); + // Remove the last part as it might be unfinished. + $last_part = array_pop($parts); + $selector = implode(':', $parts); + + // Start with the partly given selector or from scratch. + $result = array(); + if ($selector && $wrapper = $element->applyDataSelector($selector)) { + $result = RulesData::matchingDataSelector($wrapper, $params[$parameter], $selector . ':', 0); + } + elseif (!$selector) { + $result = RulesData::matchingDataSelector($element->availableVariables(), $params[$parameter], '', 0); + } + + foreach ($result as $selector => $info) { + // If we have an uncomplete last part, take it into account now. + $attributes = array(); + if (!$last_part || strpos($selector, $string) === 0) { + $attributes['class'][] = 'rules-dsac-item'; + $attributes['title'] = isset($info['description']) ? strip_tags($info['description']) : ''; + if ($selector[strlen($selector) - 1] == ':') { + $attributes['class'][] = 'rules-dsac-group'; + $text = check_plain($selector) . '... (' . check_plain($info['label']) . ')'; + } + else { + $text = check_plain($selector) . ' (' . check_plain($info['label']) . ')'; + } + $matches[$selector] = "$text"; + } + } + } + drupal_json_output($matches); +} + +/** + * FAPI validation of an integer element. + * + * Copy of the core Drupal private function _element_validate_integer(). + */ +function rules_ui_element_integer_validate($element, &$form_state) { + $value = $element['#value']; + if (isset($value) && $value !== '' && (!is_numeric($value) || intval($value) != $value)) { + form_error($element, t('%name must be an integer value.', array('%name' => isset($element['#title']) ? $element['#title'] : t('Element')))); + } +} + +/** + * FAPI validation of a decimal element. + * + * Improved version of the private function _element_validate_number(). + */ +function rules_ui_element_decimal_validate($element, &$form_state) { + // Substitute the decimal separator ",". + $value = strtr($element['#value'], ',', '.'); + if ($value != '' && !is_numeric($value)) { + form_error($element, t('%name must be a number.', array('%name' => $element['#title']))); + } + elseif ($value != $element['#value']) { + form_set_value($element, $value, $form_state); + } +} + +/** + * FAPI callback to validate an IP address. + */ +function rules_ui_element_ip_address_validate($element, &$form_state) { + $value = $element['#value']; + if ($value != '' && !filter_var($value, FILTER_VALIDATE_IP)) { + form_error($element, t('%name is not a valid IP address.', array('%name' => $element['#title']))); + } +} + +/** + * FAPI validation of a date element. + * + * Makes sure the specified date format is correct and converts date values + * specify a fixed (= non relative) date to a timestamp. Relative dates are + * handled by the date input evaluator. + */ +function rules_ui_element_date_validate($element, &$form_state) { + $value = $element['#value']; + if ($value == '' || (is_numeric($value) && intval($value) == $value)) { + // The value is a timestamp. + return; + } + elseif (is_string($value) && RulesDateInputEvaluator::gmstrtotime($value) === FALSE) { + form_error($element, t('Wrong date format. Specify the date in the format %format.', array('%format' => gmdate('Y-m-d H:i:s', time() + 86400)))); + } + elseif (is_string($value) && RulesDateInputEvaluator::isFixedDateString($value)) { + // As the date string specifies a fixed format, we can convert it now. + $value = RulesDateInputEvaluator::gmstrtotime($value); + form_set_value($element, $value, $form_state); + } +} + +/** + * FAPI process callback for the duration element type. + */ +function rules_ui_element_duration_process($element, &$form_state) { + $element['value'] = array( + '#type' => 'textfield', + '#size' => 8, + '#element_validate' => array('rules_ui_element_integer_validate'), + '#default_value' => $element['#default_value'], + '#required' => !empty($element['#required']), + ); + $element['multiplier'] = array( + '#type' => 'select', + '#options' => rules_ui_element_duration_multipliers(), + '#default_value' => 1, + ); + + // Put the child elements in a container-inline div. + $element['value']['#prefix'] = '
    '; + $element['multiplier']['#suffix'] = '
    '; + + // Set an appropriate multiplier. + if (!empty($element['value']['#default_value'])) { + foreach (array_keys(rules_ui_element_duration_multipliers()) as $m) { + if ($element['value']['#default_value'] % $m == 0) { + $element['multiplier']['#default_value'] = $m; + } + } + // Divide value by the multiplier, so the display is correct. + $element['value']['#default_value'] /= $element['multiplier']['#default_value']; + } + return $element; +} + +/** + * Defines possible duration multiplier. + */ +function rules_ui_element_duration_multipliers() { + return array( + 1 => t('seconds'), + 60 => t('minutes'), + 3600 => t('hours'), + // Just use approximate numbers for days (might last 23h on DST change), + // months and years. + 86400 => t('days'), + 86400 * 30 => t('months'), + 86400 * 30 * 12 => t('years'), + ); +} + +/** + * Helper function a rules duration form element. + * + * Determines the value for a rules duration form element. + */ +function rules_ui_element_duration_value($element, $input = FALSE) { + // This runs before child elements are processed, so we cannot calculate the + // value here. But we have to make sure the value is an array, so the form + // API is able to process the children to set their values in the array. Thus + // once the form API has finished processing the element, the value is an + // array containing the child element values. Then finally the after build + // callback converts it back to the numeric value and sets that. + return array(); +} + +/** + * FAPI after build callback for the duration parameter type form. + * + * Fixes up the form value by applying the multiplier. + */ +function rules_ui_element_duration_after_build($element, &$form_state) { + if ($element['value']['#value'] !== '') { + $element['#value'] = $element['value']['#value'] * $element['multiplier']['#value']; + form_set_value($element, $element['#value'], $form_state); + } + else { + $element['#value'] = NULL; + form_set_value($element, NULL, $form_state); + } + return $element; +} + +/** + * FAPI after build callback to ensure empty form elements result in no value. + */ +function rules_ui_element_fix_empty_after_build($element, &$form_state) { + if (isset($element['#value']) && $element['#value'] === '') { + $element['#value'] = NULL; + form_set_value($element, NULL, $form_state); + } + // Work-a-round for the text_format element. + elseif ($element['#type'] == 'text_format' && !isset($element['value']['#value'])) { + form_set_value($element, NULL, $form_state); + } + return $element; +} + +/** + * FAPI after build callback for specifying a list of values. + * + * Turns the textual value in an array by splitting the text in chunks using the + * delimiter set at $element['#delimiter']. + */ +function rules_ui_list_textarea_after_build($element, &$form_state) { + $element['#value'] = $element['#value'] ? explode($element['#delimiter'], $element['#value']) : array(); + $element['#value'] = array_map('trim', $element['#value']); + form_set_value($element, $element['#value'], $form_state); + return $element; +} + +/** + * FAPI pre render callback. Turns the value back to a string for rendering. + * + * @see rules_ui_list_textarea_after_build() + */ +function rules_ui_list_textarea_pre_render($element) { + $element['#value'] = implode($element['#delimiter'], $element['#value']); + return $element; +} + +/** + * FAPI callback to validate a list of integers. + */ +function rules_ui_element_integer_list_validate($element, &$form_state) { + foreach ($element['#value'] as $value) { + if ($value !== '' && (!is_numeric($value) || intval($value) != $value)) { + form_error($element, t('Each value must be an integer.')); + } + } +} + +/** + * FAPI callback to validate a token. + */ +function rules_ui_element_token_validate($element) { + $value = $element['#value']; + if (isset($value) && $value !== '' && !entity_property_verify_data_type($value, 'token')) { + form_error($element, t('%name may only contain lowercase letters, numbers, and underscores and has to start with a letter.', array('%name' => isset($element['#title']) ? $element['#title'] : t('Element')))); + } +} + +/** + * FAPI callback to validate a list of tokens. + */ +function rules_ui_element_token_list_validate($element, &$form_state) { + foreach ($element['#value'] as $value) { + if ($value !== '' && !entity_property_verify_data_type($value, 'token')) { + form_error($element, t('Each value may only contain lowercase letters, numbers, and underscores and has to start with a letter.')); + } + } +} + +/** + * FAPI callback to validate a machine readable name. + */ +function rules_ui_element_machine_name_validate($element, &$form_state) { + if ($element['#value'] && !preg_match('!^[a-z0-9_]+$!', $element['#value'])) { + form_error($element, t('Machine-readable names must contain only lowercase letters, numbers, and underscores.')); + } +} + +/** + * FAPI callback to validate the form for editing variable info. + * + * @see RulesPluginUI::getVariableForm() + */ +function rules_ui_element_variable_form_validate($elements, &$form_state) { + $names = array(); + foreach (element_children($elements['items']) as $item_key) { + $element = &$elements['items'][$item_key]; + if ($element['name']['#value'] || $element['type']['#value'] || $element['label']['#value']) { + foreach (array('name' => t('Machine name'), 'label' => t('Label'), 'type' => t('Data type')) as $key => $title) { + if (!$element[$key]['#value']) { + form_error($element[$key], t('!name field is required.', array('!name' => $title))); + } + } + if (isset($names[$element['name']['#value']])) { + form_error($element['name'], t('The machine-readable name %name is already taken.', array('%name' => $element['name']['#value']))); + } + $names[$element['name']['#value']] = TRUE; + } + } +} + +/** + * Helper to sort elements by their 'weight' key. + */ +function rules_element_sort_helper($a, $b) { + $a += array('weight' => 0); + $b += array('weight' => 0); + if ($a['weight'] == $b['weight']) { + return 0; + } + return ($a['weight'] < $b['weight']) ? -1 : 1; +} + +/** + * Form after build handler to set the static base path. + * + * @see RulesPluginUI::formDefaults() + */ +function rules_form_after_build_restore_base_path($form, &$form_state) { + if (isset($form_state['_rules_base_path'])) { + RulesPluginUI::$basePath = $form_state['_rules_base_path']; + } + return $form; +} + +/** + * AJAX page callback to load tag suggestions. + * + * Largely copied from taxonomy_autocomplete(). + */ +function rules_autocomplete_tags($tags_typed = '') { + // The user enters a comma-separated list of tags. We only autocomplete the + // last tag. + $tags_typed = drupal_explode_tags($tags_typed); + $tag_last = drupal_strtolower(array_pop($tags_typed)); + + $tag_matches = array(); + if ($tag_last != '') { + $query = db_select('rules_tags', 'rt'); + // Do not select already entered terms. + if (!empty($tags_typed)) { + $query->condition('rt.tag', $tags_typed, 'NOT IN'); + } + // Select rows that match by tag name. + $tags_return = $query + ->distinct() + ->fields('rt', array('tag')) + ->condition('rt.tag', '%' . db_like($tag_last) . '%', 'LIKE') + ->groupBy('rt.tag') + ->range(0, 10) + ->execute() + ->fetchCol('rt.tag'); + + $prefix = count($tags_typed) ? drupal_implode_tags($tags_typed) . ', ' : ''; + + foreach ($tags_return as $name) { + $n = $name; + // Tag names containing commas or quotes must be wrapped in quotes. + if (strpos($name, ',') !== FALSE || strpos($name, '"') !== FALSE) { + $n = '"' . str_replace('"', '""', $name) . '"'; + } + $tag_matches[$prefix . $n] = check_plain($name); + } + } + drupal_json_output($tag_matches); +} diff --git a/ui/ui.plugins.inc b/ui/ui.plugins.inc new file mode 100644 index 0000000..d3a061a --- /dev/null +++ b/ui/ui.plugins.inc @@ -0,0 +1,264 @@ +rule = $object; + $this->conditions = $this->rule->conditionContainer(); + } + + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + $form_state['rules_element'] = $this->rule; + $label = $this->element->label(); + // Automatically add a counter to unlabelled rules. + if ($label == t('unlabeled') && !$this->element->isRoot() && !empty($options['init'])) { + $parent = $this->element->parentElement(); + $label .= ' ' . count($parent->getIterator()); + } + // Components have already a label. If used inside a rule-set add a label + // though. It's called 'Name' in the UI though. + if (!$this->element->isRoot()) { + $form['label'] = array( + '#type' => 'textfield', + '#title' => t('Name'), + '#default_value' => empty($options['init']) ? $label : '', + '#required' => TRUE, + '#weight' => 5, + '#description' => t('A human-readable name shortly describing the rule.'), + ); + } + + $form += array('conditions' => array('#weight' => -5, '#tree' => TRUE)); + $this->conditions->form($form['conditions'], $form_state); + unset($form['conditions']['negate']); + + // Add actions form. + $iterator = new RecursiveIteratorIterator($this->rule->actions(), RecursiveIteratorIterator::SELF_FIRST); + parent::form($form, $form_state, $options, $iterator); + // Hide nested elements during creation. + $form['elements']['#access'] = empty($options['init']); + $form['conditions']['elements']['#access'] = empty($options['init']); + + $form_state['redirect'] = RulesPluginUI::path($this->element->root()->name, 'edit', $this->element); + if (!empty($options['button'])) { + $form['submit']['#value'] = t('Save changes'); + } + } + + /** + * Applies the values of the form to the rule configuration. + */ + public function form_extract_values($form, &$form_state) { + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + // Run condition and action container value extraction. + if (isset($form['conditions'])) { + $this->conditions->extender('RulesConditionContainerUI')->form_extract_values($form['conditions'], $form_state); + } + if (!empty($form_values['label'])) { + $this->element->label = $form_values['label']; + } + parent::form_extract_values($form, $form_state); + } + + public function operations() { + // When rules are listed only show the edit and delete operations. + $ops = parent::operations(); + $ops['#links'] = array_intersect_key($ops['#links'], array_flip(array('edit', 'delete'))); + return $ops; + } + +} + +/** + * Reaction rule specific UI. + */ +class RulesReactionRuleUI extends RulesRuleUI { + + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + $form['events'] = array( + '#type' => 'container', + '#weight' => -10, + '#access' => empty($options['init']), + ); + + $form['events']['table'] = array( + '#theme' => 'table', + '#caption' => 'Events', + '#header' => array(t('Event'), t('Operations')), + '#empty' => t('None'), + ); + $form['events']['table']['#attributes']['class'][] = 'rules-elements-table'; + foreach ($this->rule->events() as $event_name) { + $event_handler = rules_get_event_handler($event_name, $this->rule->getEventSettings($event_name)); + + $event_operations = array( + '#theme' => 'links__rules', + '#attributes' => array( + 'class' => array( + 'rules-operations', + 'action-links', + 'rules_rule_event', + ), + ), + '#links' => array( + 'delete_event' => array( + 'title' => t('delete'), + 'href' => RulesPluginUI::path($this->rule->name, 'delete/event/' . $event_name), + 'query' => drupal_get_destination(), + ), + ), + ); + + $form['events']['table']['#rows'][$event_name] = array( + 'data' => array( + $event_handler->summary(), + array('data' => $event_operations), + ), + ); + } + + // Add the "add event" row. + $cell['colspan'] = 3; + $cell['data']['#theme'] = 'links__rules'; + $cell['data']['#attributes']['class'][] = 'rules-operations-add'; + $cell['data']['#attributes']['class'][] = 'action-links'; + $cell['data']['#links']['add_event'] = array( + 'title' => t('Add event'), + 'href' => RulesPluginUI::path($this->rule->name, 'add/event'), + 'query' => drupal_get_destination(), + ); + $form['events']['table']['#rows'][] = array('data' => array($cell), 'class' => array('rules-elements-add')); + + parent::form($form, $form_state, $options); + unset($form['label']); + } + + /** + * Adds the configuration settings form (label, tags, description, ..). + */ + public function settingsForm(&$form, &$form_state) { + parent::settingsForm($form, $form_state); + $form['settings']['active'] = array( + '#type' => 'checkbox', + '#title' => t('Active'), + '#default_value' => !isset($this->rule->active) || $this->rule->active, + ); + $form['settings']['weight'] = array( + '#type' => 'weight', + '#title' => t('Weight'), + '#default_value' => $this->element->weight, + '#weight' => 5, + '#delta' => 10, + '#description' => t('Order rules that react on the same event. Rules with a higher weight are evaluated after rules with less weight.'), + ); + unset($form['settings']['component_provides']); + } + + public function settingsFormExtractValues($form, &$form_state) { + $form_values = RulesPluginUI::getFormStateValues($form['settings'], $form_state); + parent::settingsFormExtractValues($form, $form_state); + $this->rule->active = $form_values['active']; + $this->rule->weight = $form_values['weight']; + } + +} + +/** + * Rule set specific UI. + */ +class RulesRuleSetUI extends RulesActionContainerUI { + + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + // Pass an iterator just iterating over the rules, thus no further child + // elements will be displayed. + parent::form($form, $form_state, $options, $this->element->getIterator()); + // Only show the add rule link. + $form['elements']['#add']['#links'] = array_intersect_key($form['elements']['#add']['#links'], array('add_rule' => 1)); + $form['elements']['#attributes']['class'][] = 'rules-rule-set'; + $form['elements']['#caption'] = t('Rules'); + } + +} + +/** + * UI for Rules loops. + */ +class RulesLoopUI extends RulesActionContainerUI { + + public function form(&$form, &$form_state, $options = array(), $iterator = NULL) { + parent::form($form, $form_state, $options); + $settings = $this->element->settings; + + $form['item'] = array( + '#type' => 'fieldset', + '#title' => t('Current list item'), + '#description' => t('The variable used for holding each list item in the loop. This variable will be available inside the loop only.'), + '#tree' => TRUE, + ); + $form['item']['label'] = array( + '#type' => 'textfield', + '#title' => t('Variable label'), + '#default_value' => $settings['item:label'], + '#required' => TRUE, + ); + $form['item']['var'] = array( + '#type' => 'textfield', + '#title' => t('Variable name'), + '#default_value' => $settings['item:var'], + '#description' => t('The variable name must contain only lowercase letters, numbers, and underscores and must be unique in the current scope.'), + '#element_validate' => array('rules_ui_element_machine_name_validate'), + '#required' => TRUE, + ); + } + + public function form_extract_values($form, &$form_state) { + parent::form_extract_values($form, $form_state); + $form_values = RulesPluginUI::getFormStateValues($form, $form_state); + + $this->element->settings['item:var'] = $form_values['item']['var']; + $this->element->settings['item:label'] = $form_values['item']['label']; + } + + public function form_validate($form, &$form_state) { + parent::form_validate($form, $form_state); + + $vars = $this->element->availableVariables(); + $name = $this->element->settings['item:var']; + if (isset($vars[$name])) { + form_error($form['item']['var'], t('The variable name %name is already taken.', array('%name' => $name))); + } + } + + public function buildContent() { + $content = parent::buildContent(); + + $content['description']['item'] = array( + '#caption' => t('List item'), + '#theme' => 'rules_content_group', + ); + $content['description']['item']['var'] = array( + '#theme' => 'rules_variable_view', + '#info' => $this->element->listItemInfo(), + '#name' => $this->element->settings['item:var'], + ); + return $content; + } + +} diff --git a/ui/ui.theme.inc b/ui/ui.theme.inc new file mode 100644 index 0000000..52212fe --- /dev/null +++ b/ui/ui.theme.inc @@ -0,0 +1,306 @@ + $element['#depth'])) . drupal_render($element['label']); + + $row[] = drupal_render($element['weight']) . drupal_render($element['parent_id']) . drupal_render($element['element_id']); + $row[] = array('class' => 'rules-operations', 'data' => $element['operations']); + + $row = array('data' => $row) + $element['#attributes']; + $row['class'][] = 'draggable'; + if (!$element['#container']) { + $row['class'][] = 'tabledrag-leaf'; + } + $form['#rows'][] = $row; + } + if (!empty($form['#rows'])) { + drupal_add_tabledrag($form['#attributes']['id'], 'match', 'parent', 'rules-parent-id', 'rules-parent-id', 'rules-element-id', TRUE, 10); + drupal_add_tabledrag($form['#attributes']['id'], 'order', 'sibling', 'rules-element-weight'); + } + else { + $form['#rows'][] = array(array('data' => t('None'), 'colspan' => 3)); + } + if (!empty($form['#add'])) { + $row = array(); + $row[] = array('data' => $form['#add'], 'colspan' => 3); + $form['#rows'][] = array('data' => $row, 'class' => array('rules-elements-add')); + } + + // Add a wrapping div. + return '
    ' . drupal_render($form) . '
    '; +} + +/** + * Themes the rules form for editing the used variables. + * + * @see RulesPluginUI::getVariableForm() + * + * @ingroup themeable + */ +function theme_rules_ui_variable_form($variables) { + $elements = $variables['element']; + + $table['#theme'] = 'table'; + $table['#header'] = array( + t('Data type'), + t('Label'), + t('Machine name'), + t('Usage'), + array('data' => t('Weight'), 'class' => array('tabledrag-hide')), + ); + $table['#attributes']['id'] = 'rules-' . drupal_html_id($elements['#title']) . '-id'; + + foreach (element_children($elements['items']) as $key) { + $element = &$elements['items'][$key]; + // Add special classes to be used for tabledrag.js. + $element['weight']['#attributes']['class'] = array('rules-element-weight'); + + $row = array(); + $row[] = array('data' => $element['type']); + $row[] = array('data' => $element['label']); + $row[] = array('data' => $element['name']); + $row[] = array('data' => $element['usage']); + $row[] = array('data' => $element['weight']); + $row = array('data' => $row) + $element['#attributes']; + $row['class'][] = 'draggable'; + $table['#rows'][] = $row; + } + $elements['items']['#printed'] = TRUE; + if (!empty($table['#rows'])) { + drupal_add_tabledrag($table['#attributes']['id'], 'order', 'sibling', 'rules-element-weight'); + } + + // Theme it like a form item, but with the description above the content. + $attributes['class'][] = 'form-item'; + $attributes['class'][] = 'rules-variables-form'; + + $output = '' . "\n"; + $output .= theme('form_element_label', $variables); + if (!empty($elements['#description'])) { + $output .= '
    ' . $elements['#description'] . "
    \n"; + } + $output .= ' ' . drupal_render($table) . "\n"; + // Add in any further children elements. + foreach (element_children($elements, TRUE) as $key) { + $output .= drupal_render($elements[$key]); + } + $output .= "\n"; + return $output; +} + +/** + * Themes a view of multiple configuration items. + * + * @ingroup themeable + */ +function theme_rules_content_group($variables) { + $element = $variables['element']; + $output = array(); + foreach (element_children($element) as $key) { + $output[] = drupal_render($element[$key]); + } + $output = array_filter($output); + $heading = !empty($element['#caption']) ? "" . $element['#caption'] . ': ' : ''; + if (!empty($output)) { + $element['#attributes']['class'][] = 'rules-element-content-group'; + return '' . $heading . implode(', ', $output) . ''; + } +} + +/** + * Themes the view of a single parameter configuration. + * + * @ingroup themeable + */ +function theme_rules_parameter_configuration($variables) { + $element = $variables['element']; + $content = drupal_render_children($element); + // Add the full content to the span's title, but don't use drupal_attributes + // for that as this would invoke check_plain() again. + $title = strip_tags($content); + $element['#attributes']['class'][] = 'rules-parameter-configuration'; + $attributes = drupal_attributes($element['#attributes']) . " title='$title'"; + + $label_attributes['class'][] = 'rules-parameter-label'; + if (!empty($element['#info']['description'])) { + $label_attributes['title'] = $element['#info']['description']; + } + $label_attributes = drupal_attributes($label_attributes); + + $output = "" . check_plain($element['#info']['label']) . ': '; + $output .= "" . truncate_utf8($content, 30, TRUE, TRUE) . ""; + return $output; +} + +/** + * Themes info about variables. + * + * @ingroup themeable + */ +function theme_rules_variable_view($variables) { + $element = $variables['element']; + + $label_attributes['class'][] = 'rules-variable-label'; + $label_attributes['title'] = ''; + if (!empty($element['#info']['description'])) { + $label_attributes['title'] = $element['#info']['description'] . ' '; + } + $label_attributes['title'] .= t('Data type: !type', array('!type' => $element['#info']['type'])); + $label_attributes = drupal_attributes($label_attributes); + + $output = check_plain($element['#info']['label']); + $output .= ' (' . check_plain($element['#name']) . ')'; + return "" . $output . ''; +} + +/** + * Themes help for using the data selector. + * + * @ingroup themeable + */ +function theme_rules_data_selector_help($variables) { + $variables_info = $variables['variables']; + $param_info = $variables['parameter']; + + $render = array( + '#type' => 'fieldset', + '#title' => t('Data selectors'), + '#pre_render' => array(), + '#attributes' => array(), + ); + // Make it manually collapsible as we cannot use #collapsible without the + // FAPI element processor. + $render['#attached']['js'][] = 'misc/collapse.js'; + $render['#attributes']['class'][] = 'collapsible'; + $render['#attributes']['class'][] = 'collapsed'; + + $render['table'] = array( + '#theme' => 'table', + '#header' => array(t('Selector'), t('Label'), t('Description')), + ); + foreach (RulesData::matchingDataSelector($variables_info, $param_info) as $selector => $info) { + $info += array('label' => '', 'description' => ''); + $render['table']['#rows'][] = array( + check_plain($selector), + check_plain(drupal_ucfirst($info['label'])), + check_plain($info['description']), + ); + } + return drupal_render($render); +} + +/** + * Themes the rules log debug output. + * + * @ingroup themeable + */ +function theme_rules_log($variables) { + $element = $variables['element']; + drupal_add_css(drupal_get_path('module', 'rules') . '/ui/rules.ui.css'); + // Add jquery ui core css and functions, which are needed for the icons. + drupal_add_library('system', 'ui'); + drupal_add_js(drupal_get_path('module', 'rules') . '/ui/rules.debug.js'); + + $output = '
    '; + + $output .= '

    '; + $output .= ''; + $output .= ' '; + $output .= t('Rules evaluation log'); + $output .= ''; + $output .= ''; + $output .= '-' . t('Open all') . '-'; + $output .= '

    '; + + $output .= '
    '; + $output .= $element['#children']; + $output .= '
    '; + $output .= '
    '; + + return $output; +} + +/** + * Theme rules debug log elements. + * + * @ingroup themeable + */ +function theme_rules_debug_element($variables) { + $output = '
    '; + $output .= ''; + $output .= $variables['log']; + $output .= '
    '; + return $output; +} + +/** + * Themes rules autocomplete forms. + * + * @ingroup themeable + */ +function theme_rules_autocomplete($variables) { + $element = $variables['element']; + drupal_add_js(drupal_get_path('module', 'rules') . '/ui/rules.autocomplete.js'); + drupal_add_library('system', 'ui.autocomplete'); + drupal_add_library('system', 'ui.button'); + + $element['#attributes']['type'] = 'text'; + element_set_attributes($element, array('id', 'name', 'value', 'size', 'maxlength')); + _form_set_class($element, array('form-text', 'rules-autocomplete')); + + $id = $element['#attributes']['id']; + $id_button = drupal_html_id($id . '-button'); + + $js_vars['rules_autocomplete'][$id] = array( + 'inputId' => $id, + 'source' => url($element['#autocomplete_path'], array('absolute' => TRUE)), + ); + drupal_add_js($js_vars, 'setting'); + + $output = '
    '; + $output .= ''; + $output .= '
    '; + + return $output; +} + +/** + * General theme function for displaying settings related help. + * + * @ingroup themeable + */ +function theme_rules_settings_help($variables) { + $text = $variables['text']; + $heading = $variables['heading']; + return "

    $heading: $text

    "; +}