diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
commit | 4ce65d59ca91871cfd126497158200a818720bce (patch) | |
tree | e277def01fc7eba7dbc21c4a4ae5576e8aa2cf1f /vendor/ipl | |
parent | Initial commit. (diff) | |
download | icinga-php-library-4ce65d59ca91871cfd126497158200a818720bce.tar.xz icinga-php-library-4ce65d59ca91871cfd126497158200a818720bce.zip |
Adding upstream version 0.13.1.upstream/0.13.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
281 files changed, 32106 insertions, 0 deletions
diff --git a/vendor/ipl/html/LICENSE b/vendor/ipl/html/LICENSE new file mode 100644 index 0000000..58005ec --- /dev/null +++ b/vendor/ipl/html/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/html/composer.json b/vendor/ipl/html/composer.json new file mode 100644 index 0000000..99c5525 --- /dev/null +++ b/vendor/ipl/html/composer.json @@ -0,0 +1,33 @@ +{ + "name": "ipl/html", + "type": "library", + "description": "Icinga PHP Library - HTML abstraction layer", + "license": "MIT", + "keywords": ["html"], + "homepage": "https://github.com/Icinga/ipl-html", + "config": { + "sort-packages": true + }, + "require": { + "php": ">=7.2", + "ext-fileinfo": "*", + "ipl/stdlib": ">=0.12.0", + "ipl/validator": ">=0.5.0", + "psr/http-message": "^1.1", + "guzzlehttp/psr7": "^2.5" + }, + "require-dev": { + "ipl/stdlib": "dev-main", + "ipl/validator": "dev-main" + }, + "autoload": { + "psr-4": { + "ipl\\Html\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Html\\": "tests" + } + } +} diff --git a/vendor/ipl/html/src/Attribute.php b/vendor/ipl/html/src/Attribute.php new file mode 100644 index 0000000..c42485a --- /dev/null +++ b/vendor/ipl/html/src/Attribute.php @@ -0,0 +1,328 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +/** + * HTML Attribute + * + * Every single HTML attribute is (or should be) an instance of this class. + * This guarantees that every attribute is safe and escaped correctly. + * + * Usually attributes are not instantiated directly, but created through an HTML + * element's exposed methods. + */ +class Attribute +{ + /** @var string */ + protected $name; + + /** @var string The separator used if value is an array */ + protected $separator = ' '; + + /** @var string|array|bool|null */ + protected $value; + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public function __construct($name, $value = null) + { + $this->setName($name)->setValue($value); + } + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function create($name, $value) + { + return new static($name, $value); + } + + /** + * Create a new empty HTML attribute from the given name + * + * The value of the attribute will be null after construction. + * + * @param string $name The name of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function createEmpty($name) + { + return new static($name, null); + } + + /** + * Escape the name of an attribute + * + * Makes sure that the name of an attribute really is a string. + * + * @param string $name + * + * @return string + */ + public static function escapeName($name) + { + return (string) $name; + } + + /** + * Escape the value of an attribute + * + * If the value is an array, returns the string representation + * of all array elements joined with the specified glue string. + * + * Values are escaped according to the HTML5 double-quoted attribute value syntax: + * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }. + * + * @param string|array $value + * @param string $glue Glue string to join elements if value is an array + * + * @return string + */ + public static function escapeValue($value, $glue = ' ') + { + if (is_array($value)) { + $value = implode($glue, $value); + } + + // We force double-quoted attribute value syntax so let's start by escaping double quotes + $value = str_replace('"', '"', $value); + + // In addition, values must not contain ambiguous ampersands + $value = preg_replace_callback( + '/&[0-9A-Z]+;/i', + function ($match) { + $subject = $match[0]; + + if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { + // Ambiguous ampersand + return str_replace('&', '&', $subject); + } + + return $subject; + }, + $value + ); + + return $value; + } + + /** + * Get the name of the attribute + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the attribute + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException If the name contains special characters + */ + protected function setName($name) + { + if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { + throw new InvalidArgumentException(sprintf( + 'Attribute names with special characters are not yet allowed: %s', + $name + )); + } + + $this->name = $name; + + return $this; + } + + /** + * Get the separator by which multiple values are concatenated with + * + * @return string + */ + public function getSeparator(): string + { + return $this->separator; + } + + /** + * Set the separator to concatenate multiple values with + * + * @param string $separator + * + * @return $this + */ + public function setSeparator(string $separator): self + { + $this->separator = $separator; + + return $this; + } + + /** + * Get the value of the attribute + * + * @return string|bool|array|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the value of the attribute + * + * @param string|bool|array|null $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Add the given value(s) to the attribute + * + * @param string|array $value The value(s) to add + * + * @return $this + */ + public function addValue($value) + { + $this->value = array_merge((array) $this->value, (array) $value); + + return $this; + } + + /** + * Remove the given value(s) from the attribute + * + * The current value is set to null if it matches the value to remove + * or is in the array of values to remove. + * + * If the current value is an array, all elements are removed which + * match the value(s) to remove. + * + * Does nothing if there is no such value to remove. + * + * @param string|array $value The value(s) to remove + * + * @return $this + */ + public function removeValue($value) + { + $value = (array) $value; + + $current = $this->getValue(); + + if (is_array($current)) { + $this->setValue(array_diff($current, $value)); + } elseif (in_array($current, $value, true)) { + $this->setValue(null); + } + + return $this; + } + + /** + * Test and return true if the attribute is boolean, false otherwise + * + * @return bool + */ + public function isBoolean() + { + return is_bool($this->value); + } + + /** + * Test and return true if the attribute is empty, false otherwise + * + * Null and the empty array will be considered empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->value === null || $this->value === []; + } + + /** + * Render the attribute to HTML + * + * If the value of the attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * Note that in this case if the value of the attribute is false, the empty string will be returned. + * + * If the value of the attribute is null or an empty array, + * the empty string will be returned as well. + * + * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + */ + public function render() + { + if ($this->isEmpty()) { + return ''; + } + + if ($this->isBoolean()) { + if ($this->value) { + return $this->renderName(); + } + + return ''; + } else { + return sprintf( + '%s="%s"', + $this->renderName(), + $this->renderValue() + ); + } + } + + /** + * Render the name of the attribute to HTML + * + * @return string + */ + public function renderName() + { + return static::escapeName($this->name); + } + + /** + * Render the value of the attribute to HTML + * + * @return string + */ + public function renderValue() + { + return static::escapeValue($this->value, $this->separator); + } +} diff --git a/vendor/ipl/html/src/Attributes.php b/vendor/ipl/html/src/Attributes.php new file mode 100644 index 0000000..8df9bbd --- /dev/null +++ b/vendor/ipl/html/src/Attributes.php @@ -0,0 +1,518 @@ +<?php + +namespace ipl\Html; + +use ArrayAccess; +use ArrayIterator; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * HTML attributes + * + * HTML attributes provide additional information about HTML elements, that configure the elements or adjust their + * behavior in various ways. + * + * Attributes usually come in name-value pairs and are rendered as name="value". + */ +class Attributes implements ArrayAccess, IteratorAggregate +{ + /** @var Attribute[] */ + protected $attributes = []; + + /** @var callable[] */ + protected $callbacks = []; + + /** @var string */ + protected $prefix = ''; + + /** @var callable[] */ + protected $setterCallbacks = []; + + /** + * Create new HTML attributes + * + * @param array $attributes + */ + public function __construct(array $attributes = null) + { + if (empty($attributes)) { + return; + } + + foreach ($attributes as $key => $value) { + if ($value instanceof Attribute) { + $this->addAttribute($value); + } elseif (is_string($key)) { + $this->add($key, $value); + } elseif (is_array($value) && count($value) === 2) { + $this->add(array_shift($value), array_shift($value)); + } + } + } + + /** + * Create new HTML attributes + * + * @param array $attributes + * + * @return static + */ + public static function create(array $attributes = null) + { + return new static($attributes); + } + + /** + * Ensure that the given attributes of mixed type are converted to an instance of attributes + * + * The conversion procedure is as follows: + * + * If the given attributes is already an instance of Attributes, returns the very same element. + * If the attributes are given as an array of attribute name-value pairs, they are used to + * construct and return a new Attributes instance. + * If the attributes are null, an empty new instance of Attributes is returned. + * + * @param array|static|null $attributes + * + * @return static + * + * @throws InvalidArgumentException In case the given attributes are of an unsupported type + */ + public static function wantAttributes($attributes) + { + if ($attributes instanceof self) { + return $attributes; + } + + if (is_array($attributes)) { + return new static($attributes); + } + + if ($attributes === null) { + return new static(); + } + + throw new InvalidArgumentException(sprintf( + 'Attributes instance, array or null expected. Got %s instead.', + get_php_type($attributes) + )); + } + + /** + * Get the collection of attributes as array + * + * @return Attribute[] + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * Merge the given attributes + * + * @param Attributes $attributes + * + * @return $this + */ + public function merge(Attributes $attributes) + { + foreach ($attributes as $attribute) { + $this->addAttribute($attribute); + } + + foreach ($attributes->callbacks as $name => $getter) { + $setter = null; + if (isset($attributes->setterCallbacks[$name])) { + $setter = $attributes->setterCallbacks[$name]; + } + + $this->registerAttributeCallback($name, $getter, $setter); + } + + return $this; + } + + /** + * Return true if the attribute with the given name exists, false otherwise + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->attributes); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function get($name) + { + if (! $this->has($name)) { + $this->attributes[$name] = Attribute::createEmpty($name); + } + + return $this->attributes[$name]; + } + + /** + * Set the given attribute(s) + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function set($attribute, $value = null) + { + if ($attribute instanceof self) { + foreach ($attribute as $a) { + $this->setAttribute($a); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->setAttribute($attribute); + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->set($name, $value); + } + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + $this->attributes[$attribute] = Attribute::create($attribute, $value); + + return $this; + } + + /** + * Add the given attribute(s) + * + * If an attribute with the same name already exists, the attribute's value will be added to the current value of + * the attribute. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function add($attribute, $value = null) + { + if ($attribute === null) { + return $this; + } + + if ($attribute instanceof self) { + foreach ($attribute as $attr) { + $this->add($attr); + } + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->add($name, $value); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->addAttribute($attribute); + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + if (! array_key_exists($attribute, $this->attributes)) { + $this->attributes[$attribute] = Attribute::create($attribute, $value); + } else { + $this->attributes[$attribute]->addValue($value); + } + + return $this; + } + + /** + * Remove the attribute with the given name or remove the given value from the attribute + * + * @param string $name The name of the attribute + * @param null|string|array $value The value to remove if specified + * + * @return ?Attribute The removed or changed attribute, if any, otherwise null + */ + public function remove($name, $value = null): ?Attribute + { + if (! $this->has($name)) { + return null; + } + + $attribute = $this->attributes[$name]; + + if ($value === null) { + unset($this->attributes[$name]); + } else { + $attribute->removeValue($value); + } + + return $attribute; + } + + /** + * Set the specified attribute + * + * @param Attribute $attribute + * + * @return $this + */ + public function setAttribute(Attribute $attribute) + { + $this->attributes[$attribute->getName()] = $attribute; + + return $this; + } + + /** + * Add the specified attribute + * + * If an attribute with the same name already exists, the given attribute's value + * will be added to the current value of the attribute. + * + * @param Attribute $attribute + * + * @return $this + */ + public function addAttribute(Attribute $attribute) + { + $name = $attribute->getName(); + + if ($this->has($name)) { + $this->attributes[$name]->addValue($attribute->getValue()); + } else { + $this->attributes[$name] = $attribute; + } + + return $this; + } + + /** + * Get the attributes name prefix + * + * @return string|null + */ + public function getPrefix() + { + return $this->prefix; + } + + /** + * Set the attributes name prefix + * + * @param string $prefix + * + * @return $this + */ + public function setPrefix($prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * Register callback for an attribute + * + * @param string $name Name of the attribute to register the callback for + * @param ?callable $callback Callback to call when retrieving the attribute + * @param ?callable $setterCallback Callback to call when setting the attribute + * + * @return $this + */ + public function registerAttributeCallback(string $name, ?callable $callback, ?callable $setterCallback = null): self + { + if ($callback !== null) { + $this->callbacks[$name] = $callback; + } + + if ($setterCallback !== null) { + $this->setterCallbacks[$name] = $setterCallback; + } + + return $this; + } + + /** + * Render attributes to HTML + * + * If the value of an attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * + * If the value of an attribute is null, it will be skipped. + * + * HTML-escaping of the attributes' values takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + * + * @throws InvalidArgumentException If the result of a callback is invalid + */ + public function render() + { + $attributes = $this->attributes; + foreach ($this->callbacks as $name => $callback) { + $attribute = call_user_func($callback); + if ($attribute instanceof Attribute) { + if ($attribute->isEmpty()) { + continue; + } + } elseif ($attribute === null) { + continue; + } elseif (is_scalar($attribute)) { + $attribute = Attribute::create($name, $attribute); + } else { + throw new InvalidArgumentException(sprintf( + 'A registered attribute callback must return a scalar, null' + . ' or an Attribute, got a %s', + get_php_type($attribute) + )); + } + + $name = $attribute->getName(); + if (isset($attributes[$name])) { + $attributes[$name] = clone $attributes[$name]; + $attributes[$name]->addValue($attribute->getValue()); + } else { + $attributes[$name] = $attribute; + } + } + + $parts = []; + foreach ($attributes as $attribute) { + if ($attribute->isEmpty()) { + continue; + } + + $parts[] = $attribute->render(); + } + + if (empty($parts)) { + return ''; + } + + $separator = ' ' . $this->getPrefix(); + + return $separator . implode($separator, $parts); + } + + /** + * Get whether the attribute with the given name exists + * + * @param string $name Name of the attribute + * + * @return bool + */ + public function offsetExists($name): bool + { + return $this->has($name); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name Name of the attribute + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function offsetGet($name): Attribute + { + return $this->get($name); + } + + /** + * Set the given attribute + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name Name of the attribute + * @param mixed $value Value of the attribute + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function offsetSet($name, $value): void + { + $this->set($name, $value); + } + + /** + * Remove the attribute with the given name + * + * @param string $name Name of the attribute + */ + public function offsetUnset($name): void + { + $this->remove($name); + } + + /** + * Get an iterator for traversing the attributes + * + * @return Attribute[]|ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->attributes); + } + + public function __clone() + { + foreach ($this->attributes as &$attribute) { + $attribute = clone $attribute; + } + } +} diff --git a/vendor/ipl/html/src/BaseHtmlElement.php b/vendor/ipl/html/src/BaseHtmlElement.php new file mode 100644 index 0000000..5dc01ce --- /dev/null +++ b/vendor/ipl/html/src/BaseHtmlElement.php @@ -0,0 +1,406 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; +use RuntimeException; + +/** + * Base class for HTML elements + * + * Extend this class in order to provide concrete HTML elements or series of HTML elements, e.g. widgets. + * When extending this class you should provide the element's tag with {@link $tag}. Setting default attributes is + * possible via {@link $defaultAttributes}. And the content of the element is provided in {@link assemble()}. + * + * # Example Usage + * ``` + * namespace Acme\Widgets; + * + * use ipl\Html\BaseHtmlElement; + * + * class Dashboard extends BaseHtmlElement + * { + * protected $defaultAttributes = ['class' => 'acme-dashboard']; + * + * protected $tag = 'div'; + * + * protected function assemble() + * { + * // ... + * $this->add($content); + * } + * } + * ``` + */ +abstract class BaseHtmlElement extends HtmlDocument +{ + /** + * List of void elements which must not contain end tags or content + * + * If {@link $isVoid} is null, this property should be used to decide whether the content and end tag has to be + * rendered. + * + * @var array + * + * @see https://www.w3.org/TR/html5/syntax.html#void-elements + */ + protected static $voidElements = [ + 'area' => 1, + 'base' => 1, + 'br' => 1, + 'col' => 1, + 'embed' => 1, + 'hr' => 1, + 'img' => 1, + 'input' => 1, + 'link' => 1, + 'meta' => 1, + 'param' => 1, + 'source' => 1, + 'track' => 1, + 'wbr' => 1 + ]; + + /** @var Attributes */ + protected $attributes; + + /** @var bool Whether possible attribute callbacks have been registered */ + protected $attributeCallbacksRegistered = false; + + /** @var bool|null Whether the element is void. If null, void check should use {@link $voidElements} */ + protected $isVoid; + + /** @var array<string, mixed> You may want to set default attributes when extending this class */ + protected $defaultAttributes; + + /** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */ + protected $tag; + + /** + * Get the attributes of the element + * + * @return Attributes + */ + public function getAttributes() + { + if ($this->attributes === null) { + $default = $this->getDefaultAttributes(); + if (empty($default)) { + $this->attributes = new Attributes(); + } else { + $this->attributes = Attributes::wantAttributes($default); + } + + $this->ensureAttributeCallbacksRegistered(); + } + + return $this->attributes; + } + + /** + * Set the attributes of the element + * + * @param Attributes|array|null $attributes + * + * @return $this + */ + public function setAttributes($attributes) + { + $this->attributes = Attributes::wantAttributes($attributes); + + $this->attributeCallbacksRegistered = false; + $this->ensureAttributeCallbacksRegistered(); + + return $this; + } + + /** + * Return true if the attribute with the given name exists, false otherwise + * + * @param string $name + * + * @return bool + */ + public function hasAttribute(string $name): bool + { + return $this->getAttributes()->has($name); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not already exist, an empty one is automatically created and added to the attributes. + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function getAttribute(string $name): Attribute + { + return $this->getAttributes()->get($name); + } + + /** + * Set the attribute with the given name and value + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name The name of the attribute + * @param string|bool|array $value The value of the attribute + * + * @return $this + */ + public function setAttribute($name, $value) + { + $this->getAttributes()->set($name, $value); + + return $this; + } + + /** + * Remove the attribute with the given name or remove the given value from the attribute + * + * @param string $name The name of the attribute + * @param null|string|array $value The value to remove if specified + * + * @return ?Attribute The removed or changed attribute, if any, otherwise null + */ + public function removeAttribute(string $name, $value = null): ?Attribute + { + return $this->getAttributes()->remove($name, $value); + } + + /** + * Add the given attributes + * + * @param Attributes|array $attributes + * + * @return $this + */ + public function addAttributes($attributes) + { + $this->getAttributes()->add($attributes); + + return $this; + } + + /** + * Get the default attributes of the element + * + * @return array + */ + public function getDefaultAttributes() + { + return $this->defaultAttributes; + } + + /** + * Get the tag of the element + * + * Since HTML Elements must have a tag, this method throws an exception if the element does not have a tag. + * + * @return string + * + * @throws RuntimeException If the element does not have a tag + */ + final public function getTag() + { + $tag = $this->tag(); + + if (! $tag) { + throw new RuntimeException('Element must have a tag'); + } + + return $tag; + } + + /** + * Set the tag of the element + * + * @param string $tag + * + * @return $this + */ + public function setTag($tag) + { + $this->tag = $tag; + + return $this; + } + + /** + * Get whether the element is void + * + * The default void detection which checks whether the element's tag is in the list of void elements according to + * https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you want to override this behavior, use {@link setVoid()}. + * + * @return bool + */ + public function isVoid() + { + if ($this->isVoid !== null) { + return $this->isVoid; + } + + $tag = $this->getTag(); + + return isset(self::$voidElements[$tag]); + } + + /** + * Set whether the element is void + * + * You may use this method to override the default void detection which checks whether the element's tag is in the + * list of void elements according to https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you specify null, void detection is reset to its default behavior. + * + * @param bool|null $void + * + * @return $this + */ + public function setVoid($void = true) + { + $this->isVoid = $void === null ?: (bool) $void; + + return $this; + } + + /** + * Ensure that possible attribute callbacks have been registered + * + * Note that this method is automatically called in {@link getAttributes()} and {@link setAttributes()}. + * + * @return $this + */ + public function ensureAttributeCallbacksRegistered() + { + if (! $this->attributeCallbacksRegistered) { + $this->attributeCallbacksRegistered = true; + $this->registerAttributeCallbacks($this->attributes); + } + + return $this; + } + + /** + * Render the content of the element to HTML + * + * @return string + */ + public function renderContent() + { + return parent::renderUnwrapped(); + } + + /** + * Get whether the closing tag should be rendered + * + * @return bool True for void elements, false otherwise + */ + public function wantsClosingTag() + { + // TODO: There is more. SVG and MathML namespaces + return ! $this->isVoid(); + } + + /** + * Use this element to wrap the given document + * + * @param HtmlDocument $document + * + * @return $this + */ + public function wrap(HtmlDocument $document) + { + $document->addWrapper($this); + + return $this; + } + + /** + * Internal method for accessing the tag + * + * You may override this method in order to provide the tag dynamically + * + * @return string + */ + protected function tag() + { + return $this->tag; + } + + /** + * Register attribute callbacks + * + * Override this method in order to register attribute callbacks in concrete classes. + */ + protected function registerAttributeCallbacks(Attributes $attributes) + { + } + + public function addHtml(ValidHtml ...$content) + { + $this->ensureAssembled(); + + parent::addHtml(...$content); + + return $this; + } + + /** + * @inheritdoc + * + * @throws RuntimeException If the element does not have a tag or is void but has content + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + + $tag = $this->getTag(); + $content = $this->renderContent(); + $attributes = $this->getAttributes()->render(); + + if (strlen($this->contentSeparator)) { + $length = strlen($content); + if ($length > 0) { + if ($content[0] === '<') { + $content = $this->contentSeparator . $content; + $length++; + } + if ($content[$length - 1] === '>') { + $content .= $this->contentSeparator; + } + } + } + + if (! $this->wantsClosingTag()) { + if (strlen($content)) { + throw new RuntimeException('Void elements must not have content'); + } + + return sprintf('<%s%s />', $tag, $attributes); + } + + return sprintf( + '<%s%s>%s</%s>', + $tag, + $attributes, + $content, + $tag + ); + } + + public function __clone() + { + parent::__clone(); + + if ($this->attributes !== null) { + $this->attributes = clone $this->attributes; + } + } +} diff --git a/vendor/ipl/html/src/Common/MultipleAttribute.php b/vendor/ipl/html/src/Common/MultipleAttribute.php new file mode 100644 index 0000000..00a68b2 --- /dev/null +++ b/vendor/ipl/html/src/Common/MultipleAttribute.php @@ -0,0 +1,70 @@ +<?php + +namespace ipl\Html\Common; + +use ipl\Html\Attributes; +use ipl\Html\Contract\FormElement; + +/** + * Trait for form elements that can have the `multiple` attribute + * + * **Example usage:** + * + * ``` + * namespace ipl\Html\FormElement; + * + * use ipl\Html\Common\MultipleAttribute; + * + * class SelectElement extends BaseFormElement + * { + * protected function registerAttributeCallbacks(Attributes $attributes) + * { + * // ... + * $this->registerMultipleAttributeCallback($attributes); + * } + * } + * ``` + */ +trait MultipleAttribute +{ + /** @var bool Whether the attribute `multiple` is set to `true` */ + protected $multiple = false; + + /** + * Get whether the attribute `multiple` is set to `true` + * + * @return bool + */ + public function isMultiple(): bool + { + return $this->multiple; + } + + /** + * Set the `multiple` attribute + * + * @param bool $multiple + * + * @return $this + */ + public function setMultiple(bool $multiple): self + { + $this->multiple = $multiple; + + return $this; + } + + /** + * Register the callback for `multiple` Attribute + * + * @param Attributes $attributes + */ + protected function registerMultipleAttributeCallback(Attributes $attributes): void + { + $attributes->registerAttributeCallback( + 'multiple', + [$this, 'isMultiple'], + [$this, 'setMultiple'] + ); + } +} diff --git a/vendor/ipl/html/src/Contract/FormElement.php b/vendor/ipl/html/src/Contract/FormElement.php new file mode 100644 index 0000000..1467c50 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElement.php @@ -0,0 +1,132 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +/** + * Representation of form elements + */ +interface FormElement extends Wrappable +{ + /** + * Get the attributes or options of the element + * + * @return Attributes + */ + public function getAttributes(); + + /** + * Add attributes or options to the form element + * + * @param iterable $attributes + * + * @return $this + */ + public function addAttributes($attributes); + + /** + * Get the description for the element, if any + * + * @return string|null + */ + public function getDescription(); + + /** + * Get the label for the element, if any + * + * @return string|null + */ + public function getLabel(); + + /** + * Get the validation error messages + * + * @return array + */ + public function getMessages(); + + /** + * Add a validation error message + * + * @param string $message + * + * @return $this + */ + public function addMessage($message); + + /** + * Get the name of the element + * + * @return string + */ + public function getName(); + + /** + * Get whether the element has a value + * + * @return bool False if the element's value is null, the empty string or the empty array; true otherwise + */ + public function hasValue(); + + /** + * Get the value of the element + * + * @return mixed + */ + public function getValue(); + + /** + * Set the value of the element + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value); + + /** + * Get whether the element has been validated + * + * @return bool + */ + public function hasBeenValidated(); + + /** + * Get whether the element is ignored + * + * @return bool + */ + public function isIgnored(); + + /** + * Get whether the element is required + * + * @return bool + */ + public function isRequired(); + + /** + * Get whether the element is valid + * + * @return bool + */ + public function isValid(); + + /** + * Validate the element + * + * @return $this + */ + public function validate(); + + /** + * Handler which is called after this element has been registered + * + * @param Form $form + * + * @return void + */ + public function onRegistered(Form $form); +} diff --git a/vendor/ipl/html/src/Contract/FormElementDecorator.php b/vendor/ipl/html/src/Contract/FormElementDecorator.php new file mode 100644 index 0000000..ca770ea --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElementDecorator.php @@ -0,0 +1,40 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of form element decorators + */ +interface FormElementDecorator extends ValidHtml +{ + /** + * Decorate the given form element + * + * Decoration works by calling `prependWrapper()` on the form element, + * passing a clone of the decorator. Hidden elements are to be ignored. + * + * **Reference implementation:** + * + * ```php + * public function decorate(FormElement $formElement) + * { + * if ($formElement instanceof HiddenElement) { + * return; + * } + * + * $decorator = clone $this; + * + * // Wrapper logic can be overridden to adjust or propagate the decorator. + * // So here we make sure that a yet unbound decorator is passed. + * $formElement->prependWrapper($decorator); + * + * ... + * } + * ``` + * + * @param FormElement $formElement + */ + public function decorate(FormElement $formElement); +} diff --git a/vendor/ipl/html/src/Contract/FormSubmitElement.php b/vendor/ipl/html/src/Contract/FormSubmitElement.php new file mode 100644 index 0000000..4909df8 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormSubmitElement.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html\Contract; + +interface FormSubmitElement extends FormElement +{ + /** + * Get whether the element has been pressed + * + * @return bool + */ + public function hasBeenPressed(); +} diff --git a/vendor/ipl/html/src/Contract/ValueCandidates.php b/vendor/ipl/html/src/Contract/ValueCandidates.php new file mode 100644 index 0000000..1cd2d6f --- /dev/null +++ b/vendor/ipl/html/src/Contract/ValueCandidates.php @@ -0,0 +1,22 @@ +<?php + +namespace ipl\Html\Contract; + +interface ValueCandidates +{ + /** + * Get value candidates of this element + * + * @return array<int, mixed> + */ + public function getValueCandidates(); + + /** + * Set value candidates of this element + * + * @param array<int, mixed> $values + * + * @return $this + */ + public function setValueCandidates(array $values); +} diff --git a/vendor/ipl/html/src/Contract/Wrappable.php b/vendor/ipl/html/src/Contract/Wrappable.php new file mode 100644 index 0000000..c8cf924 --- /dev/null +++ b/vendor/ipl/html/src/Contract/Wrappable.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of wrappable elements + */ +interface Wrappable extends ValidHtml +{ + /** + * Get the wrapper, if any + * + * @return Wrappable|null + */ + public function getWrapper(); + + /** + * Set the wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function setWrapper(Wrappable $wrapper); + + /** + * Add a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function addWrapper(Wrappable $wrapper); + + /** + * Prepend a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function prependWrapper(Wrappable $wrapper); +} diff --git a/vendor/ipl/html/src/DeferredText.php b/vendor/ipl/html/src/DeferredText.php new file mode 100644 index 0000000..2d308f1 --- /dev/null +++ b/vendor/ipl/html/src/DeferredText.php @@ -0,0 +1,114 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Text node where content creation is deferred until rendering + * + * The content is created by a passed in callback which is only called when the node is going to be rendered and + * automatically escaped to HTML. + * If the created content is already escaped, see {@link setEscaped()} to indicate this. + * + * # Example Usage + * ``` + * $benchmark = new Benchmark(); + * + * $performance = new DeferredText(function () use ($benchmark) { + * return $benchmark->summary(); + * }); + * + * execute_query(); + * + * $benchmark->tick('Fetched results'); + * + * generate_report(); + * + * $benchmark->tick('Report generated'); + * + * echo $performance; + * ``` + */ +class DeferredText implements ValidHtml +{ + /** @var callable will return the text that should be rendered */ + protected $callback; + + /** @var bool */ + protected $escaped = false; + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + * + * @return static + */ + public static function create(callable $callback) + { + return new static($callback); + } + + /** + * Get whether the callback promises that its content is already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the callback's content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + $callback = $this->callback; + + if ($this->escaped) { + return $callback(); + } else { + return Html::escape($callback()); + } + } +} diff --git a/vendor/ipl/html/src/Error.php b/vendor/ipl/html/src/Error.php new file mode 100644 index 0000000..316d237 --- /dev/null +++ b/vendor/ipl/html/src/Error.php @@ -0,0 +1,114 @@ +<?php + +namespace ipl\Html; + +use Exception; +use Throwable; + +use function ipl\Stdlib\get_php_type; + +/** + * Class Error + * + * TODO: Eventually allow to (statically) inject a custom error renderer. + * + * @package ipl\Html + */ +abstract class Error +{ + /** @var bool */ + protected static $showTraces = true; + + /** + * + * @param Exception|Throwable|string $error + * @return HtmlDocument + */ + public static function show($error) + { + if ($error instanceof Throwable) { + // PHP 7+ + $msg = static::createMessageForException($error); + } elseif (is_string($error)) { + $msg = $error; + } else { + // TODO: translate? + $msg = 'Got an invalid error'; + } + + $result = static::renderErrorMessage($msg); + if (static::showTraces() && $error instanceof Throwable) { + $result->addHtml(Html::tag('pre', $error->getTraceAsString())); + } + + return $result; + } + + /** + * + * @param Exception|Throwable|string $error + * @return string + */ + public static function render($error) + { + return static::show($error)->render(); + } + + /** + * @param bool|null $show + * @return bool + */ + public static function showTraces($show = null) + { + if ($show !== null) { + self::$showTraces = (bool) $show; + } + + return self::$showTraces; + } + + /** + * @deprecated Use {@link get_php_type()} instead + */ + public static function getPhpTypeName($any) + { + return get_php_type($any); + } + + /** + * @param Exception|Throwable $exception + * @return string + */ + protected static function createMessageForException($exception) + { + $file = preg_split('/[\/\\\]/', $exception->getFile(), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $file = array_pop($file); + return sprintf( + '%s (%s:%d)', + $exception->getMessage(), + $file, + $exception->getLine() + ); + } + + /** + * @param string + * @return HtmlDocument + */ + protected static function renderErrorMessage($message) + { + $output = new HtmlDocument(); + $output->addHtml( + Html::tag('div', ['class' => 'exception'], [ + Html::tag('h1', [ + Html::tag('i', ['class' => 'icon-bug']), + // TODO: Translate? More hints? + 'Oops, an error occurred!' + ]), + Html::tag('pre', $message) + ]) + ); + + return $output; + } +} diff --git a/vendor/ipl/html/src/Form.php b/vendor/ipl/html/src/Form.php new file mode 100644 index 0000000..a7360c7 --- /dev/null +++ b/vendor/ipl/html/src/Form.php @@ -0,0 +1,402 @@ +<?php + +namespace ipl\Html; + +use Exception; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\FormElements; +use ipl\Stdlib\Messages; +use Psr\Http\Message\ServerRequestInterface; + +class Form extends BaseHtmlElement +{ + use FormElements { + FormElements::remove as private removeElement; + } + use Messages; + + public const ON_ELEMENT_REGISTERED = 'elementRegistered'; + public const ON_ERROR = 'error'; + public const ON_REQUEST = 'request'; + public const ON_SUCCESS = 'success'; + public const ON_SENT = 'sent'; + public const ON_VALIDATE = 'validate'; + + /** @var string Form submission URL */ + protected $action; + + /** @var string HTTP method to submit the form with */ + protected $method = 'POST'; + + /** @var FormSubmitElement Primary submit button */ + protected $submitButton; + + /** @var FormSubmitElement[] Other elements that may submit the form */ + protected $submitElements = []; + + /** @var bool Whether the form is valid */ + protected $isValid; + + /** @var ServerRequestInterface The server request being processed */ + protected $request; + + /** @var string */ + protected $redirectUrl; + + protected $tag = 'form'; + + /** + * Get whether the given value is empty + * + * @param mixed $value + * + * @return bool + */ + public static function isEmptyValue($value): bool + { + return $value === null || $value === '' || $value === []; + } + + /** + * Get the Form submission URL + * + * @return string|null + */ + public function getAction() + { + return $this->action; + } + + /** + * Set the Form submission URL + * + * @param string $action + * + * @return $this + */ + public function setAction($action) + { + $this->action = $action; + + return $this; + } + + /** + * Get the HTTP method to submit the form with + * + * @return string + */ + public function getMethod() + { + return $this->method; + } + + /** + * Set the HTTP method to submit the form with + * + * @param string $method + * + * @return $this + */ + public function setMethod($method) + { + $this->method = strtoupper($method); + + return $this; + } + + /** + * Get whether the form has a primary submit button + * + * @return bool + */ + public function hasSubmitButton() + { + return $this->submitButton !== null; + } + + /** + * Get the primary submit button + * + * @return FormSubmitElement|null + */ + public function getSubmitButton() + { + return $this->submitButton; + } + + /** + * Set the primary submit button + * + * @param FormSubmitElement $element + * + * @return $this + */ + public function setSubmitButton(FormSubmitElement $element) + { + $this->submitButton = $element; + + return $this; + } + + /** + * Get the submit element used to send the form + * + * @return FormSubmitElement|null + */ + public function getPressedSubmitElement() + { + foreach ($this->submitElements as $submitElement) { + if ($submitElement->hasBeenPressed()) { + return $submitElement; + } + } + + return null; + } + + /** + * @return ServerRequestInterface|null + */ + public function getRequest() + { + return $this->request; + } + + public function setRequest($request) + { + $this->request = $request; + $this->emit(Form::ON_REQUEST, [$request]); + + return $this; + } + + /** + * Get the url to redirect to on success + * + * @return string + */ + public function getRedirectUrl() + { + return $this->redirectUrl; + } + + /** + * Set the url to redirect to on success + * + * @param string $url + * + * @return $this + */ + public function setRedirectUrl($url) + { + $this->redirectUrl = $url; + + return $this; + } + + /** + * @param ServerRequestInterface $request + * + * @return $this + */ + public function handleRequest(ServerRequestInterface $request) + { + $this->setRequest($request); + + if (! $this->hasBeenSent()) { + // Always assemble + $this->ensureAssembled(); + + return $this; + } + + switch ($request->getMethod()) { + case 'POST': + $params = $request->getParsedBody(); + + break; + case 'GET': + parse_str($request->getUri()->getQuery(), $params); + + break; + default: + $params = []; + } + + $params = array_merge_recursive($params, $request->getUploadedFiles()); + $this->populate($params); + + // Assemble after populate in order to conditionally provide form elements + $this->ensureAssembled(); + + if ($this->hasBeenSubmitted()) { + if ($this->isValid()) { + try { + $this->emit(Form::ON_SENT, [$this]); + $this->onSuccess(); + $this->emitOnce(Form::ON_SUCCESS, [$this]); + } catch (Exception $e) { + $this->addMessage($e); + $this->onError(); + $this->emit(Form::ON_ERROR, [$e, $this]); + } + } else { + $this->onError(); + } + } else { + $this->validatePartial(); + $this->emit(Form::ON_SENT, [$this]); + } + + return $this; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method. + * + * @return bool + */ + public function hasBeenSent() + { + if ($this->request === null) { + return false; + } + + return $this->request->getMethod() === $this->getMethod(); + } + + /** + * Get whether the form has been submitted + * + * A form is submitted when it has been sent and when the primary submit button, if set, has been pressed. + * This method calls {@link hasBeenSent()} in order to detect whether the form has been sent. + * + * @return bool + */ + public function hasBeenSubmitted() + { + if (! $this->hasBeenSent()) { + return false; + } + + if ($this->hasSubmitButton()) { + return $this->getSubmitButton()->hasBeenPressed(); + } + + return true; + } + + /** + * Get whether the form is valid + * + * {@link validate()} is called automatically if the form has not been validated before. + * + * @return bool + */ + public function isValid() + { + if ($this->isValid === null) { + $this->validate(); + + $this->emit(self::ON_VALIDATE, [$this]); + } + + return $this->isValid; + } + + /** + * Validate all elements + * + * @return $this + */ + public function validate() + { + $this->ensureAssembled(); + + $valid = true; + foreach ($this->getElements() as $element) { + $element->validate(); + if (! $element->isValid()) { + $valid = false; + } + } + + $this->isValid = $valid; + + return $this; + } + + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial() + { + $this->ensureAssembled(); + + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + $element->validate(); + } + } + + return $this; + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($this->submitButton === $elementOrHtml) { + $this->submitButton = null; + } + + $this->removeElement($elementOrHtml); + + return $this; + } + + protected function onError() + { + $errors = Html::tag('ul', ['class' => 'errors']); + foreach ($this->getMessages() as $message) { + if ($message instanceof Exception) { + $message = $message->getMessage(); + } + + $errors->addHtml(Html::tag('li', $message)); + } + + if (! $errors->isEmpty()) { + $this->prependHtml($errors); + } + } + + protected function onSuccess() + { + // $this->redirectOnSuccess(); + } + + protected function onElementRegistered(FormElement $element) + { + if ($element instanceof FormSubmitElement) { + $this->submitElements[$element->getName()] = $element; + + if (! $this->hasSubmitButton()) { + $this->setSubmitButton($element); + } + } + + $element->onRegistered($this); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $attributes + ->registerAttributeCallback('action', [$this, 'getAction'], [$this, 'setAction']) + ->registerAttributeCallback('method', [$this, 'getMethod'], [$this, 'setMethod']); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php new file mode 100644 index 0000000..1048bfb --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use Closure; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\HtmlDocument; + +class CallbackDecorator extends HtmlDocument implements FormElementDecorator +{ + /** @var Closure The decorating callback */ + protected $callback; + + /** @var FormElement The decorated form element */ + protected $formElement; + + /** + * Create a new CallbackDecorator + * + * @param Closure $callback + */ + public function __construct(Closure $callback) + { + $this->callback = $callback; + } + + public function decorate(FormElement $formElement) + { + $decorator = clone $this; + + $decorator->formElement = $formElement; + + $formElement->prependWrapper($decorator); + } + + protected function assemble() + { + call_user_func($this->callback, $this->formElement, $this); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php new file mode 100644 index 0000000..9cfec20 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php @@ -0,0 +1,140 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\Html; +use ipl\Html\ValidHtml; + +class DdDtDecorator extends BaseHtmlElement implements DecoratorInterface +{ + protected $tag = 'dl'; + + protected $dt; + + protected $dd; + + /** @var BaseFormElement */ + protected $wrappedElement; + + protected $ready = false; + + /** + * @param BaseFormElement $element + * @return static + */ + public function decorate(BaseFormElement $element) + { + // TODO: ignore hidden? + $newWrapper = clone($this); + $newWrapper->wrappedElement = $element; + $element->prependWrapper($newWrapper); + + return $newWrapper; + } + + protected function renderLabel() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $label = $this->wrappedElement->getLabel(); + if ($label) { + return Html::tag('label', null, $label); + } + } + + return null; + } + + public function getAttributes() + { + $attributes = parent::getAttributes(); + + if ($this->wrappedElement->hasBeenValidated() && ! $this->wrappedElement->isValid()) { + $classes = $attributes->get('class')->getValue(); + if ( + empty($classes) + || (is_array($classes) && ! in_array('errors', $classes)) + || (is_string($classes) && $classes !== 'errors') + ) { + $attributes->add('class', 'errors'); + } + } + + return $attributes; + } + + protected function renderDescription() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $description = $this->wrappedElement->getDescription(); + if ($description) { + return Html::tag('p', ['class' => 'description'], $description); + } + } + + return null; + } + + protected function renderErrors() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $errors = []; + foreach ($this->wrappedElement->getMessages() as $message) { + $errors[] = Html::tag('p', ['class' => 'error'], $message); + } + + if (! empty($errors)) { + return $errors; + } + } + + return null; + } + + public function addHtml(ValidHtml ...$content) + { + // TODO: is this required? + if (! in_array($this->wrappedElement, $content, true)) { + parent::addHtml(...$content); + } + + return $this; + } + + protected function assemble() + { + $this->addHtml($this->dt(), $this->dd()); + $this->ready = true; + } + + protected function dt() + { + if ($this->dt === null) { + $this->dt = Html::tag('dt', null, $this->renderLabel()); + } + + return $this->dt; + } + + /** + * @return \ipl\Html\HtmlElement + */ + protected function dd() + { + if ($this->dd === null) { + $this->dd = Html::tag('dd', null, [ + $this->wrappedElement, + $this->renderErrors(), + $this->renderDescription() + ]); + } + + return $this->dd; + } + + public function __destruct() + { + $this->wrapper = null; + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php new file mode 100644 index 0000000..1ed1a38 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php @@ -0,0 +1,19 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\FormElement\BaseFormElement; + +/** @deprecated Use {@link FormElementDecorator} instead */ +interface DecoratorInterface +{ + /** + * Set the form element to decorate + * + * @param BaseFormElement $formElement + * + * @return static + */ + public function decorate(BaseFormElement $formElement); +} diff --git a/vendor/ipl/html/src/FormDecorator/DivDecorator.php b/vendor/ipl/html/src/FormDecorator/DivDecorator.php new file mode 100644 index 0000000..4935f01 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DivDecorator.php @@ -0,0 +1,156 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; + +/** + * Form element decorator based on div elements + */ +class DivDecorator extends BaseHtmlElement implements FormElementDecorator +{ + /** @var string CSS class to use for submit elements */ + public const SUBMIT_ELEMENT_CLASS = 'form-control'; + + /** @var string CSS class to use for all input elements */ + public const INPUT_ELEMENT_CLASS = 'form-element'; + + /** @var string CSS class to use for form descriptions */ + public const DESCRIPTION_CLASS = 'form-element-description'; + + /** @var string CSS class to use for form errors */ + public const ERROR_CLASS = 'form-element-errors'; + + /** @var string CSS class to set on the decorator if the element has errors */ + public const ERROR_HINT_CLASS = 'has-error'; + + /** @var FormElement The decorated form element */ + protected $formElement; + + protected $tag = 'div'; + + public function decorate(FormElement $formElement) + { + if ($formElement instanceof HiddenElement) { + return; + } + + $decorator = clone $this; + + /** + * Wrapper logic can be overridden to propagate the decorator. + * So here we make sure that a yet unbound decorator is passed. + * + * {@see FieldsetElement::setWrapper()} + */ + $formElement->prependWrapper($decorator); + + $decorator->formElement = $formElement; + + $classes = [static::INPUT_ELEMENT_CLASS]; + if ($formElement instanceof FormSubmitElement) { + $classes[] = static::SUBMIT_ELEMENT_CLASS; + } + + $decorator->getAttributes()->add('class', $classes); + } + + protected function assembleDescription() + { + $description = $this->formElement->getDescription(); + + if ($description !== null) { + $descriptionId = null; + if ($this->formElement->getAttributes()->has('id')) { + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return Html::tag('p', ['id' => $descriptionId, 'class' => static::DESCRIPTION_CLASS], $description); + } + + return null; + } + + protected function assembleElement() + { + if ($this->formElement->isRequired()) { + $this->formElement->getAttributes()->set('aria-required', 'true'); + } + + return $this->formElement->ensureAssembled(); + } + + protected function assembleErrors() + { + $errors = new HtmlElement('ul', Attributes::create(['class' => static::ERROR_CLASS])); + + foreach ($this->formElement->getMessages() as $message) { + $errors->addHtml( + new HtmlElement('li', Attributes::create(['class' => static::ERROR_CLASS]), Text::create($message)) + ); + } + + if (! $errors->isEmpty()) { + return $errors; + } + + return null; + } + + protected function assembleLabel() + { + $label = $this->formElement->getLabel(); + + if ($label !== null) { + if ($this->formElement instanceof FieldsetElement) { + return new HtmlElement('legend', null, Text::create($label)); + } else { + $attributes = null; + if ($this->formElement->getAttributes()->has('id')) { + $attributes = new Attributes(['for' => $this->formElement->getAttributes()->get('id')->getValue()]); + } + + return Html::tag('label', $attributes, $label); + } + } + + return null; + } + + protected function assemble() + { + if ($this->formElement->hasBeenValidated() && ! $this->formElement->isValid()) { + $this->getAttributes()->add('class', static::ERROR_HINT_CLASS); + } + + if ($this->formElement instanceof FieldsetElement) { + $element = $this->assembleElement(); + $element->prependHtml(...Html::wantHtmlList([ + $this->assembleLabel(), + $this->assembleDescription() + ])); + + $this->addHtml(...Html::wantHtmlList([ + $element, + $this->assembleErrors() + ])); + } else { + $this->addHtml(...Html::wantHtmlList([ + $this->assembleLabel(), + $this->assembleElement(), + $this->assembleDescription(), + $this->assembleErrors() + ])); + } + } +} diff --git a/vendor/ipl/html/src/FormElement/BaseFormElement.php b/vendor/ipl/html/src/FormElement/BaseFormElement.php new file mode 100644 index 0000000..837ac45 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/BaseFormElement.php @@ -0,0 +1,390 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\I18n\Translation; +use ipl\Stdlib\Messages; +use ipl\Validator\ValidatorChain; +use ReflectionProperty; + +abstract class BaseFormElement extends BaseHtmlElement implements FormElement, ValueCandidates +{ + use Messages; + use Translation; + + /** @var string Description of the element */ + protected $description; + + /** @var string Label of the element */ + protected $label; + + /** @var string Name of the element */ + protected $name; + + /** @var bool Whether the element is ignored */ + protected $ignored = false; + + /** @var bool Whether the element is required */ + protected $required = false; + + /** @var null|bool Whether the element is valid; null if the element has not been validated yet, bool otherwise */ + protected $valid; + + /** @var ValidatorChain Registered validators */ + protected $validators; + + /** @var mixed Value of the element */ + protected $value; + + /** @var array<int, mixed> Value candidates of the element */ + protected $valueCandidates = []; + + /** + * Create a new form element + * + * @param string $name Name of the form element + * @param mixed $attributes Attributes of the form element + */ + public function __construct($name, $attributes = null) + { + $this->setName($name); + $this->init(); + + if ($attributes !== null) { + $this->addAttributes($attributes); + } + } + + public function getDescription() + { + return $this->description; + } + + /** + * Set the description of the element + * + * @param string $description + * + * @return $this + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + /** + * Set the label of the element + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + + return $this; + } + + public function getName() + { + return $this->name; + } + + /** + * Set the name for the element + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + public function isIgnored() + { + return $this->ignored; + } + + /** + * Set whether the element is ignored + * + * @param bool $ignored + * + * @return $this + */ + public function setIgnored($ignored = true) + { + $this->ignored = (bool) $ignored; + + return $this; + } + + public function isRequired() + { + return $this->required; + } + + /** + * Set whether the element is required + * + * @param bool $required + * + * @return $this + */ + public function setRequired($required = true) + { + $this->required = (bool) $required; + + return $this; + } + + public function isValid() + { + if ($this->valid === null) { + $this->validate(); + } + + return $this->valid; + } + + /** + * Get the validators + * + * @return ValidatorChain + */ + public function getValidators() + { + if ($this->validators === null) { + $chain = new ValidatorChain(); + $this->addDefaultValidators($chain); + $this->validators = $chain; + } + + return $this->validators; + } + + /** + * Set the validators + * + * @param iterable $validators + * + * @return $this + */ + public function setValidators($validators) + { + $this + ->getValidators() + ->clearValidators() + ->addValidators($validators); + + return $this; + } + + /** + * Add validators + * + * @param iterable $validators + * + * @return $this + */ + public function addValidators($validators) + { + $this->getValidators()->addValidators($validators); + + return $this; + } + + public function hasValue() + { + $value = $this->getValue(); + + return ! Form::isEmptyValue($value); + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if ($value === '') { + $this->value = null; + } else { + $this->value = $value; + } + + $this->valid = null; + + return $this; + } + + public function getValueCandidates() + { + return $this->valueCandidates; + } + + public function setValueCandidates(array $values) + { + $this->valueCandidates = $values; + + return $this; + } + + public function onRegistered(Form $form) + { + } + + /** + * Validate the element using all registered validators + * + * @return $this + */ + public function validate() + { + $this->ensureAssembled(); + + if (! $this->hasValue()) { + if ($this->isRequired()) { + $this->setMessages([$this->translate('This field is required.')]); + $this->valid = false; + } else { + $this->valid = true; + } + } else { + $this->valid = $this->getValidators()->isValid($this->getValue()); + $this->setMessages($this->getValidators()->getMessages()); + } + + return $this; + } + + public function hasBeenValidated() + { + return $this->valid !== null; + } + + /** + * Callback for the name attribute + * + * @return Attribute|string + */ + public function getNameAttribute() + { + return $this->getName(); + } + + /** + * Callback for the required attribute + * + * @return Attribute|null + */ + public function getRequiredAttribute() + { + if ($this->isRequired()) { + return new Attribute('required', true); + } + + return null; + } + + /** + * Callback for the value attribute + * + * @return mixed + */ + public function getValueAttribute() + { + return $this->getValue(); + } + + /** + * Initialize this form element + * + * If you want to initialize this element after construction, override this method + */ + protected function init(): void + { + } + + /** + * Add default validators + */ + protected function addDefaultValidators(ValidatorChain $chain): void + { + } + + protected function registerValueCallback(Attributes $attributes) + { + $attributes->registerAttributeCallback( + 'value', + [$this, 'getValueAttribute'], + [$this, 'setValue'] + ); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $this->registerValueCallback($attributes); + + $attributes + ->registerAttributeCallback('label', null, [$this, 'setLabel']) + ->registerAttributeCallback('name', [$this, 'getNameAttribute'], [$this, 'setName']) + ->registerAttributeCallback('description', null, [$this, 'setDescription']) + ->registerAttributeCallback('validators', null, [$this, 'setValidators']) + ->registerAttributeCallback('ignore', null, [$this, 'setIgnored']) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute'], [$this, 'setRequired']); + + $this->registerCallbacks(); + } + + /** @deprecated Use {@link registerAttributeCallbacks()} instead */ + protected function registerCallbacks() + { + } + + /** + * @deprecated + * + * {@see Attributes::get()} does not respect callbacks, + * but we need the value of the callback to nest attribute names. + */ + protected function getValueOfNameAttribute() + { + $attributes = $this->getAttributes(); + + $callbacksProperty = new ReflectionProperty(get_class($attributes), 'callbacks'); + $callbacksProperty->setAccessible(true); + $callbacks = $callbacksProperty->getValue($attributes); + + if (isset($callbacks['name'])) { + $name = $callbacks['name'](); + + if ($name instanceof Attribute) { + return $name->getValue(); + } + + return $name; + } + + return $this->getName(); + } +} diff --git a/vendor/ipl/html/src/FormElement/ButtonElement.php b/vendor/ipl/html/src/FormElement/ButtonElement.php new file mode 100644 index 0000000..63ae540 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/ButtonElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class ButtonElement extends BaseFormElement +{ + protected $tag = 'button'; +} diff --git a/vendor/ipl/html/src/FormElement/CheckboxElement.php b/vendor/ipl/html/src/FormElement/CheckboxElement.php new file mode 100644 index 0000000..37e036a --- /dev/null +++ b/vendor/ipl/html/src/FormElement/CheckboxElement.php @@ -0,0 +1,124 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class CheckboxElement extends InputElement +{ + /** @var bool Whether the checkbox is checked */ + protected $checked = false; + + /** @var string Value of the checkbox when it is checked */ + protected $checkedValue = 'y'; + + /** @var string Value of the checkbox when it is not checked */ + protected $uncheckedValue = 'n'; + + protected $type = 'checkbox'; + + /** + * Get whether the checkbox is checked + * + * @return bool + */ + public function isChecked() + { + return $this->checked; + } + + /** + * Set whether the checkbox is checked + * + * @param bool $checked + * + * @return $this + */ + public function setChecked($checked) + { + $this->checked = (bool) $checked; + + return $this; + } + + /** + * Get the value of the checkbox when it is checked + * + * @return string + */ + public function getCheckedValue() + { + return $this->checkedValue; + } + + /** + * Set the value of the checkbox when it is checked + * + * @param string $checkedValue + * + * @return $this + */ + public function setCheckedValue($checkedValue) + { + $this->checkedValue = $checkedValue; + + return $this; + } + + /** + * Get the value of the checkbox when it is not checked + * + * @return string + */ + public function getUncheckedValue() + { + return $this->uncheckedValue; + } + + /** + * Set the value of the checkbox when it is not checked + * + * @param string $uncheckedValue + * + * @return $this + */ + public function setUncheckedValue($uncheckedValue) + { + $this->uncheckedValue = $uncheckedValue; + + return $this; + } + + public function setValue($value) + { + if (is_bool($value)) { + $value = $value ? $this->getCheckedValue() : $this->getUncheckedValue(); + } + + $this->setChecked($value === $this->getCheckedValue()); + + return parent::setValue($value); + } + + public function getValueAttribute() + { + return $this->getCheckedValue(); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('checked', [$this, 'isChecked'], [$this, 'setChecked']) + ->registerAttributeCallback('checkedValue', null, [$this, 'setCheckedValue']) + ->registerAttributeCallback('uncheckedValue', null, [$this, 'setUncheckedValue']); + } + + public function renderUnwrapped() + { + $html = parent::renderUnwrapped(); + + return (new HiddenElement($this->getValueOfNameAttribute(), ['value' => $this->getUncheckedValue()])) . $html; + } +} diff --git a/vendor/ipl/html/src/FormElement/ColorElement.php b/vendor/ipl/html/src/FormElement/ColorElement.php new file mode 100644 index 0000000..21d6c3a --- /dev/null +++ b/vendor/ipl/html/src/FormElement/ColorElement.php @@ -0,0 +1,16 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Validator\HexColorValidator; +use ipl\Validator\ValidatorChain; + +class ColorElement extends InputElement +{ + protected $type = 'color'; + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new HexColorValidator()); + } +} diff --git a/vendor/ipl/html/src/FormElement/DateElement.php b/vendor/ipl/html/src/FormElement/DateElement.php new file mode 100644 index 0000000..2f73b3c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/DateElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class DateElement extends InputElement +{ + protected $type = 'date'; +} diff --git a/vendor/ipl/html/src/FormElement/FieldsetElement.php b/vendor/ipl/html/src/FormElement/FieldsetElement.php new file mode 100644 index 0000000..0d70ea4 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FieldsetElement.php @@ -0,0 +1,122 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\Wrappable; +use LogicException; + +use function ipl\Stdlib\get_php_type; + +class FieldsetElement extends BaseFormElement +{ + use FormElements { + FormElements::getValue as private getElementValue; + } + + protected $tag = 'fieldset'; + + /** + * Get whether any of this set's elements has a value + * + * @return bool + */ + public function hasValue() + { + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + return true; + } + } + + return false; + } + + public function getValue($name = null, $default = null) + { + if ($name === null) { + if ($default !== null) { + throw new LogicException("Can't provide default without a name"); + } + + return $this->getValues(); + } + + return $this->getElementValue($name, $default); + } + + public function setValue($value) + { + if (! is_iterable($value)) { + throw new InvalidArgumentException( + sprintf( + '%s expects parameter $value to be an array|iterable, got %s instead', + __METHOD__, + get_php_type($value) + ) + ); + } + + // We expect an array/iterable here, + // so call populate to loop through it and apply values to the child elements of the fieldset. + $this->populate($value); + + return $this; + } + + public function validate() + { + $this->ensureAssembled(); + + $this->valid = true; + foreach ($this->getElements() as $element) { + $element->validate(); + if (! $element->isValid()) { + $this->valid = false; + } + } + + if ($this->valid) { + parent::validate(); + } + + return $this; + } + + public function getValueAttribute() + { + // Fieldsets do not have the value attribute. + return null; + } + + public function setWrapper(Wrappable $wrapper) + { + // TODO(lippserd): Revise decorator implementation to properly implement decorator propagation + if ( + ! $this->hasDefaultElementDecorator() + && $wrapper instanceof FormElementDecorator + ) { + $this->setDefaultElementDecorator(clone $wrapper); + } + + return parent::setWrapper($wrapper); + } + + protected function onElementRegistered(FormElement $element) + { + $element->getAttributes()->registerAttributeCallback('name', function () use ($element) { + /** + * We don't change the {@see BaseFormElement::$name} property of the element, + * otherwise methods like {@see FormElements::populate() and {@see FormElements::getElement()} would fail, + * but only change the name attribute to nest the names. + */ + return sprintf( + '%s[%s]', + $this->getValueOfNameAttribute(), + $element->getName() + ); + }); + } +} diff --git a/vendor/ipl/html/src/FormElement/FileElement.php b/vendor/ipl/html/src/FormElement/FileElement.php new file mode 100644 index 0000000..d9ca8fd --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FileElement.php @@ -0,0 +1,414 @@ +<?php + +namespace ipl\Html\FormElement; + +use GuzzleHttp\Psr7\UploadedFile; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Validator\FileValidator; +use ipl\Validator\ValidatorChain; +use Psr\Http\Message\UploadedFileInterface; +use ipl\Html\Common\MultipleAttribute; + +use function ipl\Stdlib\get_php_type; + +/** + * File upload element + * + * Once the file element is added to the form and the form attribute `enctype` is not set, + * it is automatically set to `multipart/form-data`. + */ +class FileElement extends InputElement +{ + use MultipleAttribute; + use Translation; + + protected $type = 'file'; + + /** @var UploadedFileInterface|UploadedFileInterface[] */ + protected $value; + + /** @var UploadedFileInterface[] Files that are stored on disk */ + protected $files = []; + + /** @var string[] Files to be removed from disk */ + protected $filesToRemove = []; + + /** @var ?string Path to store files to preserve them across requests */ + protected $destination; + + /** @var int The default maximum file size */ + protected static $defaultMaxFileSize; + + public function __construct($name, $attributes = null) + { + $this->getAttributes()->get('accept')->setSeparator(', '); + + parent::__construct($name, $attributes); + } + + /** + * Get the path to store files to preserve them across requests + * + * @return string + */ + public function getDestination(): ?string + { + return $this->destination; + } + + /** + * Set the path to store files to preserve them across requests + * + * Uploaded files are moved to the given directory to + * retain the file through automatic form submissions and failed form validations. + * + * Please note that using file persistence currently has the following drawbacks: + * + * * Works only if the file element is added to the form during {@link Form::assemble()}. + * * Persisted files are not removed automatically. + * * Files with the same name override each other. + * + * @param string $path + * + * @return $this + */ + public function setDestination(string $path): self + { + $this->destination = $path; + + return $this; + } + + public function getValueAttribute() + { + // Value attributes of file inputs are set only client-side. + return null; + } + + public function getNameAttribute() + { + $name = $this->getName(); + + return $this->isMultiple() ? ($name . '[]') : $name; + } + + public function hasValue() + { + if ($this->value === null) { + $files = $this->loadFiles(); + if (empty($files)) { + return false; + } + + if (! $this->isMultiple()) { + $files = $files[0]; + } + + $this->value = $files; + } + + return $this->value !== null; + } + + public function setValue($value) + { + if (! empty($value)) { + $fileToTest = $value; + if ($this->isMultiple()) { + $fileToTest = $value[0]; + } + + if (! $fileToTest instanceof UploadedFileInterface) { + throw new InvalidArgumentException( + sprintf('%s is not an uploaded file', get_php_type($fileToTest)) + ); + } + + if ($fileToTest->getError() === UPLOAD_ERR_NO_FILE && ! $fileToTest->getClientFilename()) { + // This is checked here as it's only about file elements for which no value has been chosen + $value = null; + } else { + $files = $value; + if (! $this->isMultiple()) { + $files = [$files]; + } + + /** @var UploadedFileInterface[] $files */ + $storedFiles = $this->storeFiles(...$files); + if (! $this->isMultiple()) { + $storedFiles = $storedFiles[0] ?? null; + } + + $value = $storedFiles; + } + } else { + $value = null; + } + + return parent::setValue($value); + } + + /** + * Get whether there are any files stored on disk + * + * @return bool + */ + protected function hasFiles(): bool + { + return $this->destination !== null && reset($this->files); + } + + /** + * Load and return all files stored on disk + * + * @return UploadedFileInterface[] + */ + protected function loadFiles(): array + { + if (empty($this->files) || $this->destination === null) { + return []; + } + + foreach ($this->files as $name => $_) { + $filePath = $this->getFilePath($name); + if (! is_readable($filePath) || ! is_file($filePath)) { + // If one file isn't accessible, none is + return []; + } + + if (in_array($name, $this->filesToRemove, true)) { + @unlink($filePath); + } else { + $this->files[$name] = new UploadedFile( + $filePath, + filesize($filePath) ?: null, + 0, + $name, + mime_content_type($filePath) ?: null + ); + } + } + + $this->files = array_diff_key($this->files, array_flip($this->filesToRemove)); + + return array_values($this->files); + } + + /** + * Store the given files on disk + * + * @param UploadedFileInterface ...$files + * + * @return UploadedFileInterface[] + */ + protected function storeFiles(UploadedFileInterface ...$files): array + { + if ($this->destination === null || ! is_writable($this->destination)) { + return $files; + } + + $storedFiles = []; + foreach ($files as $file) { + $name = $file->getClientFilename(); + $path = $this->getFilePath($name); + + if ($file->getError() !== UPLOAD_ERR_OK) { + // The file is still returned as otherwise it won't be validated + $storedFiles[] = $file; + continue; + } + + $file->moveTo($path); + + // Re-created to ensure moveTo() still works if called externally + $file = new UploadedFile( + $path, + $file->getSize(), + 0, + $name, + $file->getClientMediaType() + ); + + $this->files[$name] = $file; + $storedFiles[] = $file; + } + + return $storedFiles; + } + + /** + * Get the file path on disk of the given file + * + * @param string $name + * + * @return string + */ + protected function getFilePath(string $name): string + { + return implode(DIRECTORY_SEPARATOR, [$this->destination, sha1($name)]); + } + + public function onRegistered(Form $form) + { + if (! $form->hasAttribute('enctype')) { + $form->setAttribute('enctype', 'multipart/form-data'); + } + + $chosenFiles = (array) $form->getPopulatedValue('chosen_file_' . $this->getName(), []); + foreach ($chosenFiles as $chosenFile) { + $this->files[$chosenFile] = null; + } + + $this->filesToRemove = (array) $form->getPopulatedValue('remove_file_' . $this->getName(), []); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new FileValidator([ + 'maxSize' => $this->getDefaultMaxFileSize(), + 'mimeType' => array_filter( + (array) $this->getAttributes()->get('accept')->getValue(), + function ($type) { + // file inputs also allow file extensions in the accept attribute. These + // must not be passed as they don't resemble valid mime type definitions. + return is_string($type) && ltrim($type)[0] !== '.'; + } + ) + ])); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + $this->registerMultipleAttributeCallback($attributes); + $this->getAttributes()->registerAttributeCallback('destination', null, [$this, 'setDestination']); + } + + /** + * Get the system's default maximum file upload size + * + * @return int + */ + public function getDefaultMaxFileSize(): int + { + if (static::$defaultMaxFileSize === null) { + $ini = $this->convertIniToInteger(trim(static::getPostMaxSize())); + $max = $this->convertIniToInteger(trim(static::getUploadMaxFilesize())); + $min = max($ini, $max); + if ($ini > 0) { + $min = min($min, $ini); + } + + if ($max > 0) { + $min = min($min, $max); + } + + static::$defaultMaxFileSize = $min; + } + + return static::$defaultMaxFileSize; + } + + /** + * Converts a ini setting to a integer value + * + * @param string $setting + * + * @return int + */ + private function convertIniToInteger(string $setting): int + { + if (! is_numeric($setting)) { + $type = strtoupper(substr($setting, -1)); + $setting = (int) substr($setting, 0, -1); + + switch ($type) { + case 'K': + $setting *= 1024; + break; + + case 'M': + $setting *= 1024 * 1024; + break; + + case 'G': + $setting *= 1024 * 1024 * 1024; + break; + + default: + break; + } + } + + return (int) $setting; + } + + /** + * Get the `post_max_size` INI setting + * + * @return string + */ + protected static function getPostMaxSize(): string + { + return ini_get('post_max_size') ?: '8M'; + } + + /** + * Get the `upload_max_filesize` INI setting + * + * @return string + */ + protected static function getUploadMaxFilesize(): string + { + return ini_get('upload_max_filesize') ?: '2M'; + } + + protected function assemble() + { + $doc = new HtmlDocument(); + if ($this->hasFiles()) { + foreach ($this->files as $file) { + $doc->addHtml(new HiddenElement('chosen_file_' . $this->getValueOfNameAttribute(), [ + 'value' => $file->getClientFilename() + ])); + } + + $this->prependWrapper($doc); + } + } + + public function renderUnwrapped() + { + if (! $this->hasValue() || ! $this->hasFiles()) { + return parent::renderUnwrapped(); + } + + $uploadedFiles = new HtmlElement('ul', Attributes::create(['class' => 'uploaded-files'])); + foreach ($this->files as $file) { + $uploadedFiles->addHtml(new HtmlElement( + 'li', + null, + (new ButtonElement('remove_file_' . $this->getValueOfNameAttribute(), Attributes::create([ + 'type' => 'submit', + 'formnovalidate' => true, + 'class' => 'remove-uploaded-file', + 'value' => $file->getClientFilename(), + 'title' => sprintf($this->translate('Remove file "%s"'), $file->getClientFilename()) + ])))->addHtml(new HtmlElement( + 'span', + null, + new HtmlElement('i', Attributes::create(['class' => ['icon', 'fa', 'fa-xmark']])), + Text::create($file->getClientFilename()) + )) + )); + } + + return $uploadedFiles->render(); + } +} diff --git a/vendor/ipl/html/src/FormElement/FormElements.php b/vendor/ipl/html/src/FormElement/FormElements.php new file mode 100644 index 0000000..4a2c598 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FormElements.php @@ -0,0 +1,509 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DecoratorInterface; +use ipl\Html\ValidHtml; +use ipl\Stdlib\Events; +use ipl\Stdlib\Plugins; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +trait FormElements +{ + use Events; + use Plugins; + + /** @var FormElementDecorator|null */ + private $defaultElementDecorator; + + /** @var bool Whether the default element decorator loader has been registered */ + protected $defaultElementDecoratorLoaderRegistered = false; + + /** @var bool Whether the default element loader has been registered */ + protected $defaultElementLoaderRegistered = false; + + /** @var FormElement[] */ + private $elements = []; + + /** @var array<string, array<int, mixed>> */ + private $populatedValues = []; + + /** + * Get all elements + * + * @return FormElement[] + */ + public function getElements() + { + return $this->elements; + } + + /** + * Get whether the given element exists + * + * @param string|FormElement $element + * + * @return bool + */ + public function hasElement($element) + { + if (is_string($element)) { + return array_key_exists($element, $this->elements); + } + + if ($element instanceof FormElement) { + return in_array($element, $this->elements, true); + } + + return false; + } + + /** + * Get the element by the given name + * + * @param string $name + * + * @return FormElement + * + * @throws InvalidArgumentException If no element with the given name exists + */ + public function getElement($name) + { + if (! array_key_exists($name, $this->elements)) { + throw new InvalidArgumentException(sprintf( + "Can't get element '%s'. Element does not exist", + $name + )); + } + + return $this->elements[$name]; + } + + /** + * Add an element + * + * @param string|FormElement $typeOrElement Type of the element as string or an instance of FormElement + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If $typeOrElement is neither a string nor an instance of FormElement + * or if $typeOrElement is a string and $name is not set + * or if $typeOrElement is a string but type is unknown + * or if $typeOrElement is an instance of FormElement but does not have a name + */ + public function addElement($typeOrElement, $name = null, $options = null) + { + if (is_string($typeOrElement)) { + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter 2 to be set if parameter 1 is a string', + __METHOD__ + )); + } + + $element = $this->createElement($typeOrElement, $name, $options); + } elseif ($typeOrElement instanceof FormElement) { + $element = $typeOrElement; + } else { + throw new InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a string or an instance of %s, %s given', + __METHOD__, + FormElement::class, + get_php_type($typeOrElement) + )); + } + + $this + ->registerElement($element) // registerElement() must be called first because of the name check + ->decorate($element) + ->addHtml($element); + + return $this; + } + + /** + * Create an element + * + * @param string $type Type of the element + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return FormElement + * + * @throws InvalidArgumentException If the type of the element is unknown + */ + public function createElement($type, $name, $options = null) + { + $this->ensureDefaultElementLoaderRegistered(); + + $class = $this->loadPlugin('element', $type); + + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't create element of unknown type '%s", + $type + )); + } + + /** @var FormElement $element */ + $element = new $class($name); + + if ($options !== null) { + $element->addAttributes($options); + } + + return $element; + } + + /** + * Register an element + * + * Registers the element for value and validation handling but does not add it to the render stack. + * + * @param FormElement $element + * + * @return $this + * + * @throws InvalidArgumentException If $element does not provide a name + */ + public function registerElement(FormElement $element) + { + $name = $element->getName(); + + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects the element to provide a name', + __METHOD__ + )); + } + + $this->elements[$name] = $element; + + if (array_key_exists($name, $this->populatedValues)) { + $element->setValue($this->populatedValues[$name][count($this->populatedValues[$name]) - 1]); + + if ($element instanceof ValueCandidates) { + $element->setValueCandidates($this->populatedValues[$name]); + } + } + + $this->onElementRegistered($element); + $this->emit(Form::ON_ELEMENT_REGISTERED, [$element]); + + return $this; + } + + /** + * Get whether a default element decorator exists + * + * @return bool + */ + public function hasDefaultElementDecorator() + { + return $this->defaultElementDecorator !== null; + } + + /** + * Get the default element decorator, if any + * + * @return FormElementDecorator|null + */ + public function getDefaultElementDecorator() + { + return $this->defaultElementDecorator; + } + + /** + * Set the default element decorator + * + * If $decorator is a string, the decorator will be automatically created from a registered decorator loader. + * A loader for the namespace ipl\\Html\\FormDecorator is automatically registered by default. + * See {@link addDecoratorLoader()} for registering a custom loader. + * + * @param FormElementDecorator|string $decorator + * + * @return $this + * + * @throws InvalidArgumentException If $decorator is a string and can't be loaded from registered decorator loaders + * or if a decorator loader does not return an instance of + * {@link FormElementDecorator} + */ + public function setDefaultElementDecorator($decorator) + { + if ($decorator instanceof FormElementDecorator || $decorator instanceof DecoratorInterface) { + $this->defaultElementDecorator = $decorator; + } else { + $this->ensureDefaultElementDecoratorLoaderRegistered(); + + $class = $this->loadPlugin('decorator', $decorator); + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't create decorator of unknown type '%s", + $decorator + )); + } + + $d = new $class(); + if (! $d instanceof FormElementDecorator && ! $d instanceof DecoratorInterface) { + throw new InvalidArgumentException(sprintf( + "Expected instance of %s for decorator '%s'," + . " got %s from a decorator loader instead", + FormElementDecorator::class, + $decorator, + get_php_type($d) + )); + } + + $this->defaultElementDecorator = $d; + } + + return $this; + } + + /** + * Get the value of the element specified by name + * + * Returns $default if the element does not exist or has no value. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getValue($name, $default = null) + { + if ($this->hasElement($name)) { + $value = $this->getElement($name)->getValue(); + if ($value !== null) { + return $value; + } + } + + return $default; + } + + /** + * Get the values for all but ignored elements + * + * @return array Values as name-value pairs + */ + public function getValues() + { + $values = []; + foreach ($this->getElements() as $element) { + if (! $element->isIgnored()) { + $values[$element->getName()] = $element->getValue(); + } + } + + return $values; + } + + /** + * Populate values of registered elements + * + * @param iterable<string, mixed> $values Values as name-value pairs + * + * @return $this + */ + public function populate($values) + { + foreach ($values as $name => $value) { + $this->populatedValues[$name][] = $value; + if ($this->hasElement($name)) { + $this->getElement($name)->setValue($value); + } + } + + return $this; + } + + /** + * Get the populated value of the element specified by name + * + * Returns $default if there is no populated value for this element. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getPopulatedValue($name, $default = null) + { + return isset($this->populatedValues[$name]) + ? $this->populatedValues[$name][count($this->populatedValues[$name]) - 1] + : $default; + } + + /** + * Clear populated value of the given element + * + * @param string $name + * + * @return $this + */ + public function clearPopulatedValue($name) + { + if (isset($this->populatedValues[$name])) { + unset($this->populatedValues[$name]); + } + + return $this; + } + + /** + * Add all elements from the given element collection + * + * @param Form $form + * + * @return $this + */ + public function addElementsFrom($form) + { + foreach ($form->getElements() as $element) { + $this->addElement($element); + } + + return $this; + } + + /** + * Add a decorator loader + * + * @param string $namespace Namespace of the decorators + * @param string $postfix Decorator name postfix, if any + * + * @return $this + */ + public function addDecoratorLoader($namespace, $postfix = null) + { + $this->addPluginLoader('decorator', $namespace, $postfix); + + return $this; + } + + /** + * Add an element loader + * + * @param string $namespace Namespace of the elements + * @param string $postfix Element name postfix, if any + * + * @return $this + */ + public function addElementLoader($namespace, $postfix = null) + { + $this->addPluginLoader('element', $namespace, $postfix); + + return $this; + } + + /** + * Ensure that our default element decorator loader is registered + * + * @return $this + */ + protected function ensureDefaultElementDecoratorLoaderRegistered() + { + if (! $this->defaultElementDecoratorLoaderRegistered) { + $this->addDefaultPluginLoader( + 'decorator', + 'ipl\\Html\\FormDecorator', + 'Decorator' + ); + + $this->defaultElementDecoratorLoaderRegistered = true; + } + + return $this; + } + + /** + * Ensure that our default element loader is registered + * + * @return $this + */ + protected function ensureDefaultElementLoaderRegistered() + { + if (! $this->defaultElementLoaderRegistered) { + $this->addDefaultPluginLoader('element', __NAMESPACE__, 'Element'); + + $this->defaultElementLoaderRegistered = true; + } + + return $this; + } + + /** + * Decorate the given element + * + * @param FormElement $element + * + * @return $this + * + * @throws UnexpectedValueException If the default decorator is set but not an instance of + * {@link FormElementDecorator} + */ + protected function decorate(FormElement $element) + { + if ($this->hasDefaultElementDecorator()) { + $decorator = $this->getDefaultElementDecorator(); + + if (! $decorator instanceof FormElementDecorator && ! $decorator instanceof DecoratorInterface) { + throw new UnexpectedValueException(sprintf( + '%s expects the default decorator to be an instance of %s, got %s instead', + __METHOD__, + FormElementDecorator::class, + get_php_type($decorator) + )); + } + + $decorator->decorate($element); + } + + return $this; + } + + public function isValidEvent($event) + { + return in_array($event, [ + Form::ON_SUCCESS, + Form::ON_SENT, + Form::ON_ERROR, + Form::ON_REQUEST, + Form::ON_VALIDATE, + Form::ON_ELEMENT_REGISTERED, + ]); + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($elementOrHtml instanceof FormElement) { + if ($this->hasElement($elementOrHtml)) { + $name = array_search($elementOrHtml, $this->elements, true); + if ($name !== false) { + unset($this->elements[$name]); + } + } + } + + return parent::remove($elementOrHtml); + } + + /** + * Handler which is called after an element has been registered + * + * @param FormElement $element + */ + protected function onElementRegistered(FormElement $element) + { + } +} diff --git a/vendor/ipl/html/src/FormElement/HiddenElement.php b/vendor/ipl/html/src/FormElement/HiddenElement.php new file mode 100644 index 0000000..bffc7eb --- /dev/null +++ b/vendor/ipl/html/src/FormElement/HiddenElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class HiddenElement extends InputElement +{ + protected $type = 'hidden'; +} diff --git a/vendor/ipl/html/src/FormElement/InputElement.php b/vendor/ipl/html/src/FormElement/InputElement.php new file mode 100644 index 0000000..d5f945d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/InputElement.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; + +class InputElement extends BaseFormElement +{ + /** @var string Type of the input */ + protected $type; + + protected $tag = 'input'; + + /** + * Get the type of the input + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Set the type of the input + * + * @param string $type + * + * @return $this + */ + public function setType($type) + { + $this->type = (string) $type; + + return $this; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'type', + [$this, 'getType'], + [$this, 'setType'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php new file mode 100644 index 0000000..a628b57 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Html\FormElement; + +use DateTime; +use ipl\Validator\DateTimeValidator; +use ipl\Validator\ValidatorChain; + +class LocalDateTimeElement extends InputElement +{ + public const FORMAT = 'Y-m-d\TH:i:s'; + + protected $type = 'datetime-local'; + + protected $defaultAttributes = ['step' => '1']; + + /** @var DateTime */ + protected $value; + + public function setValue($value) + { + if (is_string($value)) { + $originalVal = $value; + $value = DateTime::createFromFormat(static::FORMAT, $value); + // In Chrome, if the seconds are set to 00, DateTime::createFromFormat() returns false. + // Create DateTime without seconds in format + if ($value === false) { + $format = substr(static::FORMAT, 0, strrpos(static::FORMAT, ':') ?: null); + $value = DateTime::createFromFormat($format, $originalVal); + } + + if ($value === false) { + $value = $originalVal; + } + } + + return parent::setValue($value); + } + + public function getValueAttribute() + { + if (! $this->value instanceof DateTime) { + return $this->value; + } + + return $this->value->format(static::FORMAT); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new DateTimeValidator()); + } +} diff --git a/vendor/ipl/html/src/FormElement/NumberElement.php b/vendor/ipl/html/src/FormElement/NumberElement.php new file mode 100644 index 0000000..b593135 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/NumberElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class NumberElement extends InputElement +{ + protected $type = 'number'; +} diff --git a/vendor/ipl/html/src/FormElement/PasswordElement.php b/vendor/ipl/html/src/FormElement/PasswordElement.php new file mode 100644 index 0000000..dfa6d8c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/PasswordElement.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +class PasswordElement extends InputElement +{ + /** @var string Dummy passwd of this element to be rendered */ + public const DUMMYPASSWORD = '_ipl_form_5847ed1b5b8ca'; + + protected $type = 'password'; + + /** @var bool Status of the form */ + protected $isFormValid = true; + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'value', + function () { + if ($this->hasValue() && count($this->getValueCandidates()) === 1 && $this->isFormValid) { + return self::DUMMYPASSWORD; + } + + if (parent::getValue() === self::DUMMYPASSWORD) { + return self::DUMMYPASSWORD; + } + + return null; + } + ); + } + + public function onRegistered(Form $form) + { + $form->on(Form::ON_VALIDATE, function ($form) { + $this->isFormValid = $form->isValid(); + }); + } + + public function getValue() + { + $value = parent::getValue(); + $candidates = $this->getValueCandidates(); + while ($value === self::DUMMYPASSWORD) { + $value = array_pop($candidates); + } + + return $value; + } +} diff --git a/vendor/ipl/html/src/FormElement/RadioElement.php b/vendor/ipl/html/src/FormElement/RadioElement.php new file mode 100644 index 0000000..831671c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/RadioElement.php @@ -0,0 +1,177 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\ValidatorChain; + +class RadioElement extends BaseFormElement +{ + use Translation; + + /** @var string The element type */ + protected $type = 'radio'; + + /** @var RadioOption[] Radio options */ + protected $options = []; + + /** @var array Disabled radio options */ + protected $disabledOptions = []; + + /** + * Set the options + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self + { + $this->options = []; + foreach ($options as $value => $label) { + $option = (new RadioOption($value, $label)) + ->setDisabled( + in_array($value, $this->disabledOptions, ! is_int($value)) + || ($value === '' && in_array(null, $this->disabledOptions, true)) + ); + + $this->options[$value] = $option; + } + + $this->disabledOptions = []; + + return $this; + } + + /** + * Get the option with specified value + * + * @param string|int $value + * + * @return RadioOption + * + * @throws InvalidArgumentException If no option with the specified value exists + */ + public function getOption($value): RadioOption + { + if (! isset($this->options[$value])) { + throw new InvalidArgumentException(sprintf('There is no such option "%s"', $value)); + } + + return $this->options[$value]; + } + + /** + * Set the specified options as disable + * + * @param array $disabledOptions + * + * @return $this + */ + public function setDisabledOptions(array $disabledOptions): self + { + if (! empty($this->options)) { + foreach ($this->options as $value => $option) { + $option->setDisabled( + in_array($value, $disabledOptions, ! is_int($value)) + || ($value === '' && in_array(null, $disabledOptions, true)) + ); + } + + $this->disabledOptions = []; + } else { + $this->disabledOptions = $disabledOptions; + } + + return $this; + } + + public function renderUnwrapped() + { + // Parent::renderUnwrapped() requires $tag and the content should be empty. However, since we are wrapping + // each button in a label, the call to parent cannot work here and must be overridden. + return HtmlDocument::renderUnwrapped(); + } + + protected function assemble() + { + foreach ($this->options as $option) { + $radio = (new InputElement($this->getValueOfNameAttribute())) + ->setType($this->type) + ->setValue($option->getValue()); + + // Only add the non-callback attributes to all options + foreach ($this->getAttributes() as $attribute) { + $radio->getAttributes()->addAttribute(clone $attribute); + } + + $radio->getAttributes() + ->merge($option->getAttributes()) + ->registerAttributeCallback( + 'checked', + function () use ($option) { + $optionValue = $option->getValue(); + + return ! is_int($optionValue) + ? $this->getValue() === $optionValue + : $this->getValue() == $optionValue; + } + ) + ->registerAttributeCallback( + 'disabled', + function () use ($option) { + return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled(); + } + ); + + $label = new HtmlElement( + 'label', + new Attributes(['class' => $option->getLabelCssClass()]), + $radio, + Text::create($option->getLabel()) + ); + + $this->addHtml($label); + } + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new DeferredInArrayValidator(function (): array { + $possibleValues = []; + + foreach ($this->options as $option) { + if ($option->isDisabled()) { + continue; + } + + $possibleValues[] = $option->getValue(); + } + + return $possibleValues; + })); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $this->getAttributes()->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + + $this->getAttributes()->registerAttributeCallback( + 'disabledOptions', + null, + [$this, 'setDisabledOptions'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/RadioOption.php b/vendor/ipl/html/src/FormElement/RadioOption.php new file mode 100644 index 0000000..4968c35 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/RadioOption.php @@ -0,0 +1,148 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class RadioOption +{ + /** @var string The default label class */ + public const LABEL_CLASS = 'radio-label'; + + /** @var string|int|null Value of the option */ + protected $value; + + /** @var string Label of the option */ + protected $label; + + /** @var mixed Css class of the option's label */ + protected $labelCssClass = self::LABEL_CLASS; + + /** @var bool Whether the radio option is disabled */ + protected $disabled = false; + + /** @var Attributes */ + protected $attributes; + + /** + * RadioOption constructor. + * + * @param string|int|null $value + * @param string $label + */ + public function __construct($value, string $label) + { + $this->value = $value === '' ? null : $value; + $this->label = $label; + } + + /** + * Set the label of the option + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Get the label of the option + * + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Get the value of the option + * + * @return string|int|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Set css class to the option label + * + * @param string|string[] $labelCssClass + * + * @return $this + */ + public function setLabelCssClass($labelCssClass): self + { + $this->labelCssClass = $labelCssClass; + + return $this; + } + + /** + * Get css class of the option label + * + * @return string|string[] + */ + public function getLabelCssClass() + { + return $this->labelCssClass; + } + + /** + * Set whether to disable the option + * + * @param bool $disabled + * + * @return $this + */ + public function setDisabled(bool $disabled = true): self + { + $this->disabled = $disabled; + + return $this; + } + + /** + * Get whether the option is disabled + * + * @return bool + */ + public function isDisabled(): bool + { + return $this->disabled; + } + + /** + * Add the attributes + * + * @param Attributes $attributes + * + * @return $this + */ + public function addAttributes(Attributes $attributes): self + { + $this->attributes = $attributes; + + return $this; + } + + /** + * Get the attributes + * + * @return Attributes + */ + public function getAttributes(): Attributes + { + if ($this->attributes === null) { + $this->attributes = new Attributes(); + } + + return $this->attributes; + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectElement.php b/vendor/ipl/html/src/FormElement/SelectElement.php new file mode 100644 index 0000000..e6b4f21 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectElement.php @@ -0,0 +1,238 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\Common\MultipleAttribute; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\ValidatorChain; +use UnexpectedValueException; + +class SelectElement extends BaseFormElement +{ + use MultipleAttribute; + + protected $tag = 'select'; + + /** @var SelectOption[] */ + protected $options = []; + + /** @var SelectOption[]|HtmlElement[] */ + protected $optionContent = []; + + /** @var array Disabled select options */ + protected $disabledOptions = []; + + /** @var array|string|null */ + protected $value; + + /** + * Get the option with specified value + * + * @param string|int|null $value + * + * @return ?SelectOption + */ + public function getOption($value): ?SelectOption + { + return $this->options[$value] ?? null; + } + + /** + * Set the options from specified values + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self + { + $this->options = []; + $this->optionContent = []; + foreach ($options as $value => $label) { + $this->optionContent[$value] = $this->makeOption($value, $label); + } + + return $this; + } + + /** + * Set the specified options as disable + * + * @param array $disabledOptions + * + * @return $this + */ + public function setDisabledOptions(array $disabledOptions): self + { + if (! empty($this->options)) { + foreach ($this->options as $option) { + $optionValue = $option->getValue(); + + $option->setAttribute( + 'disabled', + in_array($optionValue, $disabledOptions, ! is_int($optionValue)) + || ($optionValue === null && in_array('', $disabledOptions, true)) + ); + } + + $this->disabledOptions = []; + } else { + $this->disabledOptions = $disabledOptions; + } + + return $this; + } + + /** + * Get the value of the element + * + * Returns `array` when the attribute `multiple` is set to `true`, `string` or `null` otherwise + * + * @return array|string|null + */ + public function getValue() + { + if ($this->isMultiple()) { + return parent::getValue() ?? []; + } + + return parent::getValue(); + } + + public function getValueAttribute() + { + // select elements don't have a value attribute + return null; + } + + public function getNameAttribute() + { + $name = $this->getName(); + + return $this->isMultiple() ? ($name . '[]') : $name; + } + + /** + * Make the selectOption for the specified value and the label + * + * @param string|int|null $value Value of the option + * @param string|array $label Label of the option + * + * @return SelectOption|HtmlElement + */ + protected function makeOption($value, $label) + { + if (is_array($label)) { + $grp = Html::tag('optgroup', ['label' => $value]); + foreach ($label as $option => $val) { + $grp->addHtml($this->makeOption($option, $val)); + } + + return $grp; + } + + $option = (new SelectOption($value, $label)) + ->setAttribute('disabled', in_array($value, $this->disabledOptions, ! is_int($value))); + + $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) { + return $this->isSelectedOption($option->getValue()); + }); + + $this->options[$value] = $option; + + return $this->options[$value]; + } + + /** + * Get whether the given option is selected + * + * @param int|string|null $optionValue + * + * @return bool + */ + protected function isSelectedOption($optionValue): bool + { + $value = $this->getValue(); + + if ($optionValue === '') { + $optionValue = null; + } + + if ($this->isMultiple()) { + if (! is_array($value)) { + throw new UnexpectedValueException( + 'Value must be an array when the `multiple` attribute is set to `true`' + ); + } + + return in_array($optionValue, $this->getValue(), ! is_int($optionValue)) + || ($optionValue === null && in_array('', $this->getValue(), true)); + } + + if (is_array($value)) { + throw new UnexpectedValueException( + 'Value cannot be an array without setting the `multiple` attribute to `true`' + ); + } + + return is_int($optionValue) + // The loose comparison is required because PHP casts + // numeric strings to integers if used as array keys + ? $value == $optionValue + : $value === $optionValue; + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new DeferredInArrayValidator(function (): array { + $possibleValues = []; + + foreach ($this->options as $option) { + if ($option->getAttributes()->get('disabled')->getValue()) { + continue; + } + + $possibleValues[] = $option->getValue(); + } + + return $possibleValues; + }) + ); + } + + protected function assemble() + { + $this->addHtml(...array_values($this->optionContent)); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + + $attributes->registerAttributeCallback( + 'disabledOptions', + null, + [$this, 'setDisabledOptions'] + ); + + // ZF1 compatibility: + $this->getAttributes()->registerAttributeCallback( + 'multiOptions', + null, + [$this, 'setOptions'] + ); + + $this->registerMultipleAttributeCallback($attributes); + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectOption.php b/vendor/ipl/html/src/FormElement/SelectOption.php new file mode 100644 index 0000000..3d799a2 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectOption.php @@ -0,0 +1,79 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\BaseHtmlElement; + +class SelectOption extends BaseHtmlElement +{ + protected $tag = 'option'; + + /** @var string|int|null Value of the option */ + protected $value; + + /** @var string Label of the option */ + protected $label; + + /** + * SelectOption constructor. + * + * @param string|int|null $value + * @param string $label + */ + public function __construct($value, string $label) + { + $this->value = $value === '' ? null : $value; + $this->label = $label; + + $this->getAttributes()->registerAttributeCallback('value', [$this, 'getValueAttribute']); + } + + /** + * Set the label of the option + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Get the label of the option + * + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Get the value of the option + * + * @return string|int|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Callback for the value attribute + * + * @return mixed + */ + public function getValueAttribute() + { + return (string) $this->getValue(); + } + + protected function assemble() + { + $this->setContent($this->getLabel()); + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitButtonElement.php b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php new file mode 100644 index 0000000..b880bb5 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Contract\FormSubmitElement; + +class SubmitButtonElement extends ButtonElement implements FormSubmitElement +{ + protected $defaultAttributes = ['type' => 'submit']; + + /** @var string The value that's transmitted once the button is pressed */ + protected $submitValue = 'y'; + + /** + * Get the value to transmit once the button is pressed + * + * @return string + */ + public function getSubmitValue(): string + { + return $this->submitValue; + } + + /** + * Set the value to transmit once the button is pressed + * + * @param string $value + * + * @return $this + */ + public function setSubmitValue(string $value): self + { + $this->submitValue = $value; + + return $this; + } + + public function setLabel($label) + { + return $this->setContent($label); + } + + public function hasBeenPressed() + { + return $this->getValue() === $this->getSubmitValue(); + } + + public function isIgnored() + { + return true; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('value', null, [$this, 'setSubmitValue']); + } + + public function getValueAttribute() + { + return $this->submitValue; + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitElement.php b/vendor/ipl/html/src/FormElement/SubmitElement.php new file mode 100644 index 0000000..51d4aa5 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitElement.php @@ -0,0 +1,50 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Contract\FormSubmitElement; + +class SubmitElement extends InputElement implements FormSubmitElement +{ + protected $type = 'submit'; + + protected $buttonLabel; + + public function setLabel($label) + { + $this->buttonLabel = $label; + + return $this; + } + + /** + * @return string + */ + public function getButtonLabel() + { + if ($this->buttonLabel === null) { + return $this->getName(); + } else { + return $this->buttonLabel; + } + } + + /** + * @return mixed|static + */ + public function getValueAttribute() + { + return new Attribute('value', $this->getButtonLabel()); + } + + public function hasBeenPressed() + { + return $this->getButtonLabel() === $this->getValue(); + } + + public function isIgnored() + { + return true; + } +} diff --git a/vendor/ipl/html/src/FormElement/TextElement.php b/vendor/ipl/html/src/FormElement/TextElement.php new file mode 100644 index 0000000..0e3423d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextElement extends InputElement +{ + protected $type = 'text'; +} diff --git a/vendor/ipl/html/src/FormElement/TextareaElement.php b/vendor/ipl/html/src/FormElement/TextareaElement.php new file mode 100644 index 0000000..dc5c42b --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextareaElement.php @@ -0,0 +1,24 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextareaElement extends BaseFormElement +{ + protected $tag = 'textarea'; + + public function setValue($value) + { + parent::setValue($value); + + // A textarea's content actually is the value + $this->setContent($value); + + return $this; + } + + public function getValueAttribute() + { + // textarea elements don't have a value attribute + return null; + } +} diff --git a/vendor/ipl/html/src/FormElement/TimeElement.php b/vendor/ipl/html/src/FormElement/TimeElement.php new file mode 100644 index 0000000..1ee0323 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TimeElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TimeElement extends InputElement +{ + protected $type = 'time'; +} diff --git a/vendor/ipl/html/src/FormattedString.php b/vendor/ipl/html/src/FormattedString.php new file mode 100644 index 0000000..1ef9b5b --- /dev/null +++ b/vendor/ipl/html/src/FormattedString.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\Html; + +use Exception; +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; + +/** + * {@link sprintf()}-like formatted HTML string supporting lazy rendering of {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = new FormattedString( + * 'Follow the %s for more information on %s', + * [ + * new Link('doc/html', 'HTML documentation'), + * Html::tag('strong', 'HTML elements') + * ] + * ); + * ``` + */ +class FormattedString implements ValidHtml +{ + /** @var ValidHtml[] */ + protected $args = []; + + /** @var ValidHtml */ + protected $format; + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param iterable $args + * + * @throws InvalidArgumentException If arguments given but not iterable + */ + public function __construct($format, $args = null) + { + $this->format = Html::wantHtml($format); + + if ($args !== null) { + if (! is_iterable($args)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter two to be iterable, got %s instead', + __METHOD__, + get_php_type($args) + )); + } + + foreach ($args as $key => $val) { + if (! is_scalar($val) || (is_string($val) && ! is_numeric($val))) { + $val = Html::wantHtml($val); + } + + $this->args[$key] = $val; + } + } + } + + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param mixed ...$args + * + * @return static + */ + public static function create($format, ...$args) + { + return new static($format, $args); + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + return vsprintf( + $this->format->render(), + $this->args + ); + } +} diff --git a/vendor/ipl/html/src/Html.php b/vendor/ipl/html/src/Html.php new file mode 100644 index 0000000..afcba39 --- /dev/null +++ b/vendor/ipl/html/src/Html.php @@ -0,0 +1,241 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; +use function ipl\Stdlib\iterable_key_first; + +/** + * Main utility class when working with ipl\Html + */ +abstract class Html +{ + /** + * Create a HTML element from the given tag, attributes and content + * + * This method does not render the HTML element but creates a {@link HtmlElement} + * instance from the given tag, attributes and content + * + * @param string $name The desired HTML tag name + * @param mixed $attributes HTML attributes or content for the element + * @param mixed $content The content of the element if no attributes have been given + * + * @return HtmlElement The created element + */ + public static function tag($name, $attributes = null, $content = null) + { + if ($content !== null) { + // If not null, it's html content, no question + $content = static::wantHtmlList($content); + } elseif ($attributes instanceof ValidHtml || is_scalar($attributes)) { + // Otherwise $attributes may be $content, but only if definitely **NOT** attributes + $content = static::wantHtmlList($attributes); + $attributes = null; + } + + if ($attributes !== null) { + if (! is_iterable($attributes) || ! is_int(iterable_key_first($attributes))) { + // Not an array (e.g. instance of Attributes) or an associative array + $attributes = Attributes::wantAttributes($attributes); + } elseif (is_iterable($attributes)) { + // $attributes may still be $content, in case of a sequenced array + if ($content !== null) { + // But not if there's already $content + throw new InvalidArgumentException('Value of argument $attributes are no attributes'); + } + + $content = static::wantHtmlList($attributes); + $attributes = null; + } + } + + return new HtmlElement($name, $attributes, ...($content ?: [])); + } + + /** + * Convert special characters to HTML5 entities using the UTF-8 character + * set for encoding + * + * This method internally uses {@link htmlspecialchars} with the following + * flags: + * + * * Single quotes are not escaped (ENT_COMPAT) + * * Uses HTML5 entities, disallowing 
 (ENT_HTML5) + * * Invalid characters are replaced with � (ENT_SUBSTITUTE) + * + * Already existing HTML entities will be encoded as well. + * + * @param string $content The content to encode + * + * @return string The encoded content + */ + public static function escape($content) + { + return htmlspecialchars($content, ENT_COMPAT | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Factory for {@link sprintf()}-like formatted HTML strings + * + * This allows to use {@link sprintf()}-like format strings with {@link ValidHtml} element arguments, but with the + * advantage that they'll not be rendered immediately. + * + * # Example Usage + * ``` + * echo Html::sprintf('Hello %s!', Html::tag('strong', $name)); + * ``` + * + * @param string $format + * @param mixed ...$args + * + * @return FormattedString + */ + public static function sprintf($format, ...$args) + { + return new FormattedString($format, $args); + } + + /** + * Wrap each item of then given list + * + * $wrapper is a simple HTML tag per entry if a string is given, + * otherwise the given callable is called with key and value of each list item as parameters. + * + * @param iterable $list + * @param string|callable $wrapper + * + * @return HtmlDocument + */ + public static function wrapEach($list, $wrapper) + { + if (! is_iterable($list)) { + throw new InvalidArgumentException(sprintf( + 'Html::wrapEach() requires a traversable list, got "%s"', + get_php_type($list) + )); + } + $result = new HtmlDocument(); + foreach ($list as $name => $value) { + if (is_string($wrapper)) { + $result->addHtml(Html::tag($wrapper, $value)); + } elseif (is_callable($wrapper)) { + $result->add($wrapper($name, $value)); + } else { + throw new InvalidArgumentException(sprintf( + 'Wrapper must be callable or a string in Html::wrapEach(), got "%s"', + get_php_type($wrapper) + )); + } + } + + return $result; + } + + /** + * Ensure that the given content of mixed type is converted to an instance of {@link ValidHtml} + * + * Returns the very same element in case it's already an instance of {@link ValidHtml}. + * + * @param mixed $any + * + * @return ValidHtml + * + * @throws InvalidArgumentException In case the given content is of an unsupported type + */ + public static function wantHtml($any) + { + if ($any instanceof ValidHtml) { + return $any; + } elseif (static::canBeRenderedAsString($any)) { + return new Text($any); + } elseif (is_iterable($any)) { + $html = new HtmlDocument(); + foreach ($any as $el) { + if ($el !== null) { + $html->addHtml(static::wantHtml($el)); + } + } + + return $html; + } else { + throw new InvalidArgumentException(sprintf( + 'String, Html Element or Array of such expected, got "%s"', + get_php_type($any) + )); + } + } + + /** + * Accept any input and return it as list of ValidHtml + * + * @param mixed $content + * + * @return ValidHtml[] + */ + public static function wantHtmlList($content) + { + $list = []; + + if ($content === null) { + return $list; + } elseif (! is_iterable($content)) { + $list[] = static::wantHtml($content); + } elseif ($content instanceof ValidHtml) { + $list[] = $content; + } else { + foreach ($content as $part) { + $list = array_merge($list, static::wantHtmlList($part)); + } + } + + return $list; + } + + /** + * Get whether the given variable be rendered as a string + * + * @param mixed $any + * + * @return bool + */ + public static function canBeRenderedAsString($any) + { + return is_scalar($any) || is_null($any) || ( + is_object($any) && method_exists($any, '__toString') + ); + } + + /** + * Forward inaccessible static method calls to {@link Html::tag()} with the method's name as tag + * + * @param string $name + * @param array $arguments + * + * @return HtmlElement + */ + public static function __callStatic($name, $arguments) + { + $attributes = array_shift($arguments); + $content = array_shift($arguments); + + return static::tag($name, $attributes, $content); + } + + /** + * @deprecated Use {@link Html::encode()} instead + */ + public static function escapeForHtml($content) + { + return static::escape($content); + } + + /** + * @deprecated Use {@link Error::render()} instead + */ + public static function renderError($error) + { + return Error::render($error); + } +} diff --git a/vendor/ipl/html/src/HtmlDocument.php b/vendor/ipl/html/src/HtmlDocument.php new file mode 100644 index 0000000..e4e977e --- /dev/null +++ b/vendor/ipl/html/src/HtmlDocument.php @@ -0,0 +1,607 @@ +<?php + +namespace ipl\Html; + +use Countable; +use Exception; +use InvalidArgumentException; +use ipl\Html\Contract\Wrappable; +use ipl\Stdlib\Events; +use RuntimeException; + +/** + * HTML document + * + * An HTML document is composed of a tree of HTML nodes, i.e. text nodes and HTML elements. + */ +class HtmlDocument implements Countable, Wrappable +{ + use Events; + + /** @var string Emitted after the content has been assembled */ + public const ON_ASSEMBLED = 'assembled'; + + /** @var string Content separator */ + protected $contentSeparator = ''; + + /** @var bool Whether the document has been assembled */ + protected $hasBeenAssembled = false; + + /** @var Wrappable Wrapper */ + protected $wrapper; + + /** @var Wrappable Wrapped element */ + private $wrapped; + + /** @var HtmlDocument The currently responsible wrapper */ + private $renderedBy; + + /** @var ValidHtml[] Content */ + private $content = []; + + /** @var array */ + private $contentIndex = []; + + /** + * Set the element to wrap + * + * @param Wrappable $element + * + * @return $this + */ + private function setWrapped(Wrappable $element) + { + $this->wrapped = $element; + + return $this; + } + + /** + * Consume the wrapped element + * + * @return Wrappable + */ + private function consumeWrapped() + { + $wrapped = $this->wrapped; + $this->wrapped = null; + + return $wrapped; + } + + /** + * Get the content + * + * return ValidHtml[] + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param mixed $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = []; + $this->setHtmlContent(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Set content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function setHtmlContent(ValidHtml ...$content) + { + $this->content = []; + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Get the content separator + * + * @return string + */ + public function getSeparator() + { + return $this->contentSeparator; + } + + /** + * Set the content separator + * + * @param string $separator + * + * @return $this + */ + public function setSeparator($separator) + { + $this->contentSeparator = $separator; + + return $this; + } + + /** + * Get the first {@link BaseHtmlElement} with the given tag + * + * @param string $tag + * + * @return BaseHtmlElement + * + * @throws InvalidArgumentException If no {@link BaseHtmlElement} with the given tag exists + */ + public function getFirst($tag) + { + foreach ($this->content as $c) { + if ($c instanceof BaseHtmlElement && $c->getTag() === $tag) { + return $c; + } + } + + throw new InvalidArgumentException(sprintf( + 'Trying to get first %s, but there is no such', + $tag + )); + } + + /** + * Insert Html after an existing Html node + * + * @param ValidHtml $newNode + * @param ValidHtml $existingNode + * + * @return $this + */ + public function insertAfter(ValidHtml $newNode, ValidHtml $existingNode): self + { + $index = array_search($existingNode, $this->content, true); + if ($index === false) { + throw new InvalidArgumentException('The content does not contain the $existingNode'); + } + + array_splice($this->content, (int) $index + 1, 0, [$newNode]); + + $this->reIndexContent(); + + return $this; + } + + /** + * Insert Html after an existing Html node + * + * @param ValidHtml $newNode + * @param ValidHtml $existingNode + * + * @return $this + */ + public function insertBefore(ValidHtml $newNode, ValidHtml $existingNode): self + { + $index = array_search($existingNode, $this->content); + if ($index === false) { + throw new InvalidArgumentException('The content does not contain the $existingNode'); + } + + array_splice($this->content, (int) $index, 0, [$newNode]); + + $this->reIndexContent(); + + return $this; + } + + /** + * Add content + * + * @param mixed $content + * + * @return $this + */ + public function add($content) + { + $this->addHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Add content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Add content from the given document + * + * @param HtmlDocument $from + * @param callable $callback Optional callback in order to transform the content to add + * + * @return $this + */ + public function addFrom(HtmlDocument $from, $callback = null) + { + $from->ensureAssembled(); + + $isCallable = is_callable($callback); + foreach ($from->getContent() as $item) { + $this->add($isCallable ? $callback($item) : $item); + } + + return $this; + } + + /** + * Check whether the given element is a direct or indirect child of this document + * + * A direct child is one that is part of this document's content. An indirect child + * is one that is part of a direct child's content (recursively). + * + * @param ValidHtml $element + * + * @return bool + */ + public function contains(ValidHtml $element) + { + $key = spl_object_hash($element); + if (array_key_exists($key, $this->contentIndex)) { + return true; + } + + foreach ($this->content as $child) { + if ($child instanceof self && $child->contains($element)) { + return true; + } + } + + return false; + } + + /** + * Prepend content + * + * @param mixed $content + * + * @return $this + */ + public function prepend($content) + { + $this->prependHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Prepend content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function prependHtml(ValidHtml ...$content) + { + foreach (array_reverse($content) as $html) { + array_unshift($this->content, $html); + $this->incrementIndexKeys(); + $this->addObjectPosition($html, 0); + } + + return $this; + } + + /** + * Remove content + * + * @param ValidHtml $html + * + * @return $this + */ + public function remove(ValidHtml $html) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + foreach ($this->contentIndex[$key] as $pos) { + unset($this->content[$pos]); + } + } + $this->content = array_values($this->content); + + $this->reIndexContent(); + + return $this; + } + + /** + * Ensure that the document has been assembled + * + * @return $this + */ + public function ensureAssembled() + { + if (! $this->hasBeenAssembled) { + $this->hasBeenAssembled = true; + $this->assemble(); + + $this->emit(static::ON_ASSEMBLED, [$this]); + } + + return $this; + } + + /** + * Get whether the document is empty + * + * @return bool + */ + public function isEmpty() + { + $this->ensureAssembled(); + + return empty($this->content); + } + + /** + * Render the content to HTML but ignore any wrapper + * + * @return string + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + $html = []; + + // This **must** be consumed after the document's assembly but before rendering the content. + // If the document consumes it during assembly, nothing happens. If the document is used as + // wrapper for another element, consuming it asap prevents a left-over reference and avoids + // the element from getting rendered multiple times. + $wrapped = $this->consumeWrapped(); + + $content = $this->getContent(); + if ($wrapped !== null && ! $this->contains($wrapped) && ! $this->isIntermediateWrapper($wrapped)) { + $content[] = $wrapped; + } + + foreach ($content as $element) { + if ($element instanceof self) { + $element->renderedBy = $this; + } + + $html[] = $element->render(); + + if ($element instanceof self) { + $element->renderedBy = null; + } + } + + return implode($this->contentSeparator, $html); + } + + public function __clone() + { + foreach ($this->content as $key => $element) { + $this->content[$key] = clone $element; + } + + $this->reIndexContent(); + } + + /** + * Render content to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + /** + * Assemble the document + * + * Override this method in order to provide content in concrete classes. + */ + protected function assemble() + { + } + + /** + * Render the document to HTML respecting the set wrapper + * + * @return string + */ + protected function renderWrapped() + { + $wrapper = $this->wrapper; + + if (isset($this->renderedBy)) { + if ($wrapper === $this->renderedBy || $wrapper->contains($this->renderedBy)) { + // $this might be an intermediate wrapper that's already about to be rendered. + // In case of an element (referencing $this as a wrapper) that is a child of an + // outer wrapper, it is required to ignore $wrapper as otherwise it's a loop. + // ($wrapper then is in the render path of the outer wrapper and sideways "stolen") + return $this->renderUnwrapped(); + } + + $wrapper->renderedBy = $this->renderedBy; + } elseif (isset($wrapper->renderedBy)) { + throw new RuntimeException('Wrapper loop detected'); + } else { + $this->renderedBy = $wrapper; + } + + $html = $wrapper->renderWrappedDocument($this); + + if (isset($this->renderedBy)) { + if ($this->renderedBy === $wrapper) { + $this->renderedBy = null; + } elseif ($wrapper->renderedBy === $this->renderedBy) { + $wrapper->renderedBy = null; + } + } + + return $html; + } + + /** + * Render the given document to HTML by treating this document as the wrapper + * + * @param HtmlDocument $document + * + * @return string + */ + protected function renderWrappedDocument(HtmlDocument $document) + { + return $this->setWrapped($document)->render(); + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->content); + } + + public function getWrapper() + { + return $this->wrapper; + } + + public function setWrapper(Wrappable $wrapper) + { + $this->wrapper = $wrapper; + + return $this; + } + + public function addWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $this->wrapper->addWrapper($wrapper); + } + + return $this; + } + + public function prependWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $wrapper->addWrapper($this->wrapper); + $this->setWrapper($wrapper); + } + + return $this; + } + + /** + * Check whether the given element wraps this document (recursively) + * + * @param ValidHtml $element + * + * @return bool + */ + protected function wrappedBy(ValidHtml $element) + { + if ($this->wrapper === null) { + return false; + } + + if ($this->wrapper === $element || $this->wrapper->wrappedBy($element)) { + return true; + } + + return false; + } + + /** + * Get whether the given element is an intermediate wrapper + * + * @param ValidHtml $element + * + * @return bool + */ + protected function isIntermediateWrapper(ValidHtml $element): bool + { + foreach ($this->content as $child) { + if ($child instanceof self && $child->wrappedBy($element)) { + return true; + } + } + + return false; + } + + public function render() + { + $this->ensureAssembled(); + if ($this->wrapper === null) { + return $this->renderUnwrapped(); + } else { + return $this->renderWrapped(); + } + } + + private function addIndexedContent(ValidHtml $html) + { + $pos = count($this->content); + $this->content[$pos] = $html; + $this->addObjectPosition($html, $pos); + } + + private function addObjectPosition(ValidHtml $html, $pos) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + $this->contentIndex[$key][] = $pos; + } else { + $this->contentIndex[$key] = [$pos]; + } + } + + private function incrementIndexKeys() + { + foreach ($this->contentIndex as & $index) { + foreach ($index as & $pos) { + $pos++; + } + } + } + + private function reIndexContent() + { + $this->contentIndex = []; + foreach ($this->content as $pos => $html) { + $this->addObjectPosition($html, $pos); + } + } +} diff --git a/vendor/ipl/html/src/HtmlElement.php b/vendor/ipl/html/src/HtmlElement.php new file mode 100644 index 0000000..4f5d162 --- /dev/null +++ b/vendor/ipl/html/src/HtmlElement.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Html; + +/** + * The HtmlElement represents any HTML element + * + * A typical HTML element includes a tag, attributes and content. + */ +class HtmlElement extends BaseHtmlElement +{ + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param Attributes $attributes The HTML attributes for the element + * @param ValidHtml ...$content The content of the element + */ + public function __construct($tag, Attributes $attributes = null, ValidHtml ...$content) + { + $this->tag = $tag; + + if ($attributes !== null) { + $this->getAttributes()->merge($attributes); + } + + $this->setHtmlContent(...$content); + } + + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param mixed $attributes The HTML attributes for the element + * @param mixed $content The content of the element + * + * @return static + */ + public static function create($tag, $attributes = null, $content = null) + { + return new static($tag, Attributes::wantAttributes($attributes), ...Html::wantHtmlList($content)); + } +} diff --git a/vendor/ipl/html/src/HtmlString.php b/vendor/ipl/html/src/HtmlString.php new file mode 100644 index 0000000..e62086b --- /dev/null +++ b/vendor/ipl/html/src/HtmlString.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html; + +/** + * HTML string + * + * HTML strings promise to be already escaped and can be anything from simple text to full HTML markup. + */ +class HtmlString extends Text +{ + protected $escaped = true; +} diff --git a/vendor/ipl/html/src/Table.php b/vendor/ipl/html/src/Table.php new file mode 100644 index 0000000..28a6738 --- /dev/null +++ b/vendor/ipl/html/src/Table.php @@ -0,0 +1,226 @@ +<?php + +namespace ipl\Html; + +use RuntimeException; +use stdClass; + +class Table extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + /** @var string */ + protected $tag = 'table'; + + /** @var HtmlElement */ + private $caption; + + /** @var HtmlElement */ + private $header; + + /** @var HtmlElement */ + private $body; + + /** @var HtmlElement */ + private $footer; + + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $html) { + if ($html instanceof BaseHtmlElement) { + switch ($html->getTag()) { + case 'tr': + $this->getBody()->addHtml($html); + + break; + case 'thead': + parent::addHtml($html); + $this->header = $html; + + break; + case 'tbody': + parent::addHtml($html); + $this->body = $html; + + break; + case 'tfoot': + parent::addHtml($html); + $this->footer = $html; + + break; + case 'caption': + if ($this->caption === null) { + $this->prependHtml($html); + $this->caption = $html; + } else { + throw new RuntimeException( + 'Tables allow only one <caption> tag' + ); + } + + break; + default: + $this->getBody()->addHtml(static::row([$html])); + } + } else { + $this->getBody()->addHtml(static::row([$html])); + } + } + + return $this; + } + + /** + * @param mixed $content + * @return $this + */ + public function add($content) + { + if ($content instanceof stdClass) { + $this->getBody()->addHtml(static::row((array) $content)); + } elseif (is_iterable($content)) { + $this->getBody()->addHtml(static::row($content)); + } elseif ($content instanceof ValidHtml) { + $this->addHtml($content); + } else { + $this->getBody()->addHtml(static::row([$content])); + } + + return $this; + } + + /** + * Set the table title + * + * Will be rendered as a "caption" HTML element + * + * @param mixed $caption + * @return $this + */ + public function setCaption($caption) + { + if ($caption instanceof BaseHtmlElement && $caption->getTag() === 'caption') { + $this->caption = $caption; + $this->prependHtml($caption); + } elseif ($this->caption === null) { + $this->caption = new HtmlElement('caption', null, ...Html::wantHtmlList($caption)); + $this->prependHtml($this->caption); + } else { + $this->caption->setContent($caption); + } + + return $this; + } + + /** + * Static helper creating a tr element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function tr($content = null, $attributes = null) + { + return Html::tag('tr', $attributes, $content); + } + + /** + * Static helper creating a th element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function th($content = null, $attributes = null) + { + return Html::tag('th', $attributes, $content); + } + + /** + * Static helper creating a td element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function td($content = null, $attributes = null) + { + return Html::tag('td', $attributes, $content); + } + + /** + * @param $row + * @param null $attributes + * @param string $tag + * @return HtmlElement + */ + public static function row($row, $attributes = null, $tag = 'td') + { + $tr = static::tr(); + foreach ((array) $row as $value) { + $tr->addHtml(Html::tag($tag, null, $value)); + } + + if ($attributes !== null) { + $tr->setAttributes($attributes); + } + + return $tr; + } + + /** + * @return HtmlElement + */ + public function getBody() + { + if ($this->body === null) { + $this->addHtml(Html::tag('tbody')->setSeparator("\n")); + } + + return $this->body; + } + + /** + * @return HtmlElement + */ + public function getHeader() + { + if ($this->header === null) { + $this->addHtml(Html::tag('thead')->setSeparator("\n")); + } + + return $this->header; + } + + /** + * @return HtmlElement + */ + public function getFooter() + { + if ($this->footer === null) { + $this->addHtml(Html::tag('tfoot')->setSeparator("\n")); + } + + return $this->footer; + } + + /** + * @return HtmlElement + */ + public function nextBody() + { + $this->body = null; + + return $this->getBody(); + } + + /** + * @return HtmlElement + */ + public function nextHeader() + { + $this->header = null; + + return $this->getHeader(); + } +} diff --git a/vendor/ipl/html/src/TemplateString.php b/vendor/ipl/html/src/TemplateString.php new file mode 100644 index 0000000..b1e01c0 --- /dev/null +++ b/vendor/ipl/html/src/TemplateString.php @@ -0,0 +1,175 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Render {{#mustache}}Mustache{{/mustache}}-like string from {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = TemplateString::create( + * 'Follow the {{#doc}}HTML documentation{{/doc}} for more information on {{#strong}}HTML elements{{/strong}}', + * [ + * 'doc' => new Link(null, 'doc/html'), + * 'strong' => Html::tag('strong') + * ] + * ); + * ``` + */ +class TemplateString extends FormattedString +{ + /** @var array */ + protected $templateArgs = []; + + /** @var int */ + protected $pos = 0; + + /** @var string */ + protected $string; + + /** @var int */ + protected $length; + + public function __construct($format, $args = null) + { + $parentArgs = []; + foreach ($args ?: [] as $val) { + if (is_array($val) && is_string(key($val))) { + $this->templateArgs += $val; + } else { + $parentArgs[] = $val; + } + } + + parent::__construct($format, $parentArgs); + } + + /** + * Parse template strings + * + * @param ?string $for template name + * @return HtmlDocument + * @throws Exception in case of missing template argument or unbounded open or close templates + */ + protected function parseTemplates($for = null) + { + $buffer = ''; + + while (($char = $this->readChar()) !== false) { + if ($char !== '{') { + $buffer .= $char; + continue; + } + + $nextChar = $this->readChar(); + if ($nextChar !== '{') { + $buffer .= $char . $nextChar; + continue; + } + + $templateHandle = $this->readChar(); + $start = $templateHandle === '#'; + $end = $templateHandle === '/'; + + $templateKey = $this->readUntil('}'); + // if the string following '{{#' is read up to the last character or (length - 1)th character + // then it is not a template + if ($this->pos >= $this->length - 1) { + $buffer .= $char . $nextChar . $templateHandle . $templateKey; + continue; + } + + $this->pos++; + $closeChar = $this->readChar(); + + if ($closeChar !== '}') { + $buffer .= $char . $nextChar . $templateHandle . $templateKey . '}' . $closeChar; + continue; + } + + if ($start) { + if (isset($this->templateArgs[$templateKey])) { + $wrapper = $this->templateArgs[$templateKey]; + + $buffer .= $this->parseTemplates($templateKey)->prependWrapper($wrapper); + } else { + throw new Exception(sprintf( + 'Missing template argument: %s ', + $templateKey + )); + } + } elseif ($for === $templateKey && $end) { + // close the template + $for = null; + break; + } else { + // throw exception for unbounded closing of templates + throw new Exception(sprintf( + 'Unbound closing of template: %s', + $templateKey + )); + } + } + + if ($this->pos === $this->length && $for !== null) { + throw new Exception(sprintf( + 'Unbound opening of template: %s', + $for + )); + } + + return (new HtmlDocument())->addHtml(HtmlString::create($buffer)); + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + public function render() + { + $formattedstring = parent::render(); + if (empty($this->templateArgs)) { + return $formattedstring; + } + + $this->string = $formattedstring; + + $this->length = strlen($formattedstring); + + return $this->parseTemplates()->render(); + } +} diff --git a/vendor/ipl/html/src/Text.php b/vendor/ipl/html/src/Text.php new file mode 100644 index 0000000..710e3d9 --- /dev/null +++ b/vendor/ipl/html/src/Text.php @@ -0,0 +1,116 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * A text node + * + * Primitive element that renders text to HTML while automatically escaping its content. + * If the passed content is already escaped, see {@link setEscaped()} to indicate this. + */ +class Text implements ValidHtml +{ + /** @var string */ + protected $content; + + /** @var bool Whether the content is already escaped */ + protected $escaped = false; + + /** + * Create a new text node + * + * @param string $content + */ + public function __construct($content) + { + $this->setContent($content); + } + + /** + * Create a new text node + * + * @param string $content + * + * @return static + */ + public static function create($content) + { + return new static($content); + } + + /** + * Get the content + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param string $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = (string) $content; + + return $this; + } + + /** + * Get whether the content promises to be already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + if ($this->escaped) { + return $this->content; + } else { + return Html::escape($this->content); + } + } +} diff --git a/vendor/ipl/html/src/ValidHtml.php b/vendor/ipl/html/src/ValidHtml.php new file mode 100644 index 0000000..2b88af4 --- /dev/null +++ b/vendor/ipl/html/src/ValidHtml.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Html; + +/** + * Interface for HTML elements or primitives that promise to render valid UTF-8 encoded HTML5 with special characters + * converted to HTML entities + */ +interface ValidHtml +{ + /** + * Render to HTML + * + * @return string UTF-8 encoded HTML5 with special characters converted to HTML entities + */ + public function render(); +} diff --git a/vendor/ipl/i18n/LICENSE b/vendor/ipl/i18n/LICENSE new file mode 100644 index 0000000..e179593 --- /dev/null +++ b/vendor/ipl/i18n/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/i18n/composer.json b/vendor/ipl/i18n/composer.json new file mode 100644 index 0000000..d79aba2 --- /dev/null +++ b/vendor/ipl/i18n/composer.json @@ -0,0 +1,28 @@ +{ + "name": "ipl/i18n", + "type": "library", + "description": "Icinga PHP Library - Internationalization", + "keywords": ["gettext", "i18n", "internationalization", "localization", "translation"], + "homepage": "https://github.com/Icinga/ipl-i18n", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-intl": "*", + "ext-gettext": "*", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "files": ["src/functions_include.php"], + "psr-4": { + "ipl\\I18n\\": "src" + } + }, + "require-dev": { + "ipl/stdlib": "dev-main" + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\I18n\\": "tests" + } + } +} diff --git a/vendor/ipl/i18n/src/GettextTranslator.php b/vendor/ipl/i18n/src/GettextTranslator.php new file mode 100644 index 0000000..288a489 --- /dev/null +++ b/vendor/ipl/i18n/src/GettextTranslator.php @@ -0,0 +1,353 @@ +<?php + +namespace ipl\I18n; + +use FilesystemIterator; +use ipl\Stdlib\Contract\Translator; +use SplFileInfo; + +/** + * Translator using PHP's native [gettext](https://www.php.net/gettext) extension + * + * # Example Usage + * + * ```php + * $translator = (new GettextTranslator()) + * ->addTranslationDirectory('/path/to/locales') + * ->addTranslationDirectory('/path/to/locales-of-domain', 'special') // Could also be the same directory as above + * ->setLocale('de_DE'); + * + * $translator->translate('user'); + * + * printf( + * $translator->translatePlural('%d user', '%d user', 42), + * 42 + * ); + * + * $translator->translateInDomain('special-domain', 'request'); + * + * printf( + * $translator->translatePluralInDomain('special-domain', '%d request', '%d requests', 42), + * 42 + * ); + * + * // All translation functions also accept a context as last parameter + * $translator->translate('group', 'a-context'); + * ``` + * + */ +class GettextTranslator implements Translator +{ + /** @var string Default gettext domain */ + protected $defaultDomain = 'default'; + + /** @var string Default locale code */ + protected $defaultLocale = 'en_US'; + + /** @var array<string, string> Known translation directories as array[$domain] => $directory */ + protected $translationDirectories = []; + + /** @var array<string, string> Loaded translations as array[$domain] => $directory */ + protected $loadedTranslations = []; + + /** @var string Primary locale code used for translations */ + protected $locale; + + /** + * Get the default domain + * + * @return string + */ + public function getDefaultDomain() + { + return $this->defaultDomain; + } + + /** + * Set the default domain + * + * @param string $defaultDomain + * + * @return $this + */ + public function setDefaultDomain($defaultDomain) + { + $this->defaultDomain = $defaultDomain; + + return $this; + } + + /** + * Get the default locale + * + * @return string + */ + public function getDefaultLocale() + { + return $this->defaultLocale; + } + + /** + * Set the default locale + * + * @param string $defaultLocale + * + * @return $this + */ + public function setDefaultLocale($defaultLocale) + { + $this->defaultLocale = $defaultLocale; + + return $this; + } + + /** + * Get available translations + * + * @return array<string, string> Available translations as array[$domain] => $directory + */ + public function getTranslationDirectories() + { + return $this->translationDirectories; + } + + /** + * Add a translation directory + * + * @param string $directory Path to translation files + * @param string $domain Optional domain of the translation + * + * @return $this + */ + public function addTranslationDirectory($directory, $domain = null) + { + $this->translationDirectories[$domain ?: $this->defaultDomain] = $directory; + + return $this; + } + + /** + * Get loaded translations + * + * @return array<string, string> Loaded translations as array[$domain] => $directory + */ + public function getLoadedTranslations() + { + return $this->loadedTranslations; + } + + /** + * Load a translation so that gettext is able to locate its message catalogs + * + * {@link bindtextdomain()} is called internally for every domain and path + * that has been added with {@link addTranslationDirectory()}. + * + * @return $this + * @throws \Exception If {@link bindtextdomain()} fails for a domain + */ + public function loadTranslations() + { + foreach ($this->translationDirectories as $domain => $directory) { + if ( + isset($this->loadedTranslations[$domain]) + && $this->loadedTranslations[$domain] === $directory + ) { + continue; + } + + if (bindtextdomain($domain, $directory) !== $directory) { + throw new \Exception(sprintf( + "Can't register domain '%s' with path '%s'", + $domain, + $directory + )); + } + + bind_textdomain_codeset($domain, 'UTF-8'); + + $this->loadedTranslations[$domain] = $directory; + } + + return $this; + } + + /** + * Get the primary locale code used for translations + * + * @return string + */ + public function getLocale() + { + return $this->locale; + } + + /** + * Setup the primary locale code to use for translations + * + * Calls {@link loadTranslations()} internally. + * + * @param string $locale Locale code + * + * @return $this + * @throws \Exception If {@link bindtextdomain()} fails for a domain + */ + public function setLocale($locale) + { + putenv("LANGUAGE=$locale.UTF-8"); + setlocale(LC_ALL, $locale . '.UTF-8'); + + $this->loadTranslations(); + + textdomain($this->getDefaultDomain()); + + $this->locale = $locale; + + return $this; + } + + /** + * Encode a message with context to the representation used in .mo files + * + * @param string $message + * @param string $context + * + * @return string The encoded message as context + "\x04" + message + */ + public function encodeMessageWithContext($message, $context) + { + // The encoding of a context and a message in a .mo file is + // context + "\x04" + message (gettext version >= 0.15) + return "{$context}\x04{$message}"; + } + + public function translate($message, $context = null) + { + if ($context !== null) { + $messageForGettext = $this->encodeMessageWithContext($message, $context); + } else { + $messageForGettext = $message; + } + + $translation = gettext($messageForGettext); + + if ($translation === $messageForGettext) { + return $message; + } + + return $translation; + } + + public function translateInDomain($domain, $message, $context = null) + { + if ($context !== null) { + $messageForGettext = $this->encodeMessageWithContext($message, $context); + } else { + $messageForGettext = $message; + } + + $translation = dgettext( + $domain, + $messageForGettext + ); + + if ($translation === $messageForGettext) { + $translation = dgettext( + $this->getDefaultDomain(), + $messageForGettext + ); + } + + if ($translation === $messageForGettext) { + return $message; + } + + return $translation; + } + + public function translatePlural($singular, $plural, $number, $context = null) + { + if ($context !== null) { + $singularForGettext = $this->encodeMessageWithContext($singular, $context); + } else { + $singularForGettext = $singular; + } + + + $translation = ngettext( + $singularForGettext, + $plural, + $number + ); + + if ($translation === $singularForGettext) { + return $number === 1 ? $singular : $plural; + } + + return $translation; + } + + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + if ($context !== null) { + $singularForGettext = $this->encodeMessageWithContext($singular, $context); + } else { + $singularForGettext = $singular; + } + + $translation = dngettext( + $domain, + $singularForGettext, + $plural, + $number + ); + + $isSingular = $number === 1; + + if ($translation === ($isSingular ? $singularForGettext : $plural)) { + $translation = dngettext( + $this->getDefaultDomain(), + $singularForGettext, + $plural, + $number + ); + } + + if ($translation === $singularForGettext) { + return $isSingular ? $singular : $plural; + } + + return $translation; + } + + /** + * List available locales by traversing the translation directories from {@link addTranslationDirectory()} + * + * @return string[] Array of available locale codes + */ + public function listLocales() + { + $locales = []; + + foreach (array_unique($this->getTranslationDirectories()) as $directory) { + $fs = new FilesystemIterator( + $directory, + FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS + ); + + /** @var SplFileInfo $file */ + foreach ($fs as $file) { + if (! $file->isDir()) { + continue; + } + + $locales[] = $file->getBasename(); + } + } + + $locales = array_filter(array_unique($locales)); + + sort($locales); + + return $locales; + } +} diff --git a/vendor/ipl/i18n/src/Locale.php b/vendor/ipl/i18n/src/Locale.php new file mode 100644 index 0000000..48e345f --- /dev/null +++ b/vendor/ipl/i18n/src/Locale.php @@ -0,0 +1,127 @@ +<?php + +namespace ipl\I18n; + +use ipl\Stdlib\Str; +use stdClass; + +class Locale +{ + /** @var string Default locale code */ + protected $defaultLocale = 'en_US'; + + /** + * Get the default locale + * + * @return string + */ + public function getDefaultLocale() + { + return $this->defaultLocale; + } + + /** + * Set the default locale + * + * @param string $defaultLocale + * + * @return $this + */ + public function setDefaultLocale($defaultLocale) + { + $this->defaultLocale = $defaultLocale; + + return $this; + } + + /** + * Return the preferred locale based on the given HTTP header and the available translations + * + * @param string $header The HTTP "Accept-Language" header + * @param array<string> $available Available translations + * + * @return string The browser's preferred locale code + */ + public function getPreferred($header, array $available) + { + $headerValues = Str::trimSplit($header, ','); + for ($i = 0; $i < count($headerValues); $i++) { + // In order to accomplish a stable sort we need to take the original + // index into account as well during element comparison + $headerValues[$i] = [$headerValues[$i], $i]; + } + usort( // Sort DESC but keep equal elements ASC + $headerValues, + function ($a, $b) { + $tagA = Str::trimSplit($a[0], ';', 2); + $tagB = Str::trimSplit($b[0], ';', 2); + $qValA = (float) (strpos($a[0], ';') > 0 ? substr(array_pop($tagA), 2) : 1); + $qValB = (float) (strpos($b[0], ';') > 0 ? substr(array_pop($tagB), 2) : 1); + + return $qValA < $qValB ? 1 : ($qValA > $qValB ? -1 : ($a[1] > $b[1] ? 1 : ($a[1] < $b[1] ? -1 : 0))); + } + ); + for ($i = 0; $i < count($headerValues); $i++) { + // We need to reset the array to its original structure once it's sorted + $headerValues[$i] = $headerValues[$i][0]; + } + $requestedLocales = []; + foreach ($headerValues as $headerValue) { + if (strpos($headerValue, ';') > 0) { + $parts = Str::trimSplit($headerValue, ';', 2); + $headerValue = $parts[0]; + } + $requestedLocales[] = str_replace('-', '_', $headerValue); + } + $requestedLocales = array_combine( + array_map('strtolower', array_values($requestedLocales)), + array_values($requestedLocales) + ); + + $available[] = $this->defaultLocale; + $availableLocales = array_combine( + array_map('strtolower', array_values($available)), + array_values($available) + ); + + $similarMatch = null; + + foreach ($requestedLocales as $requestedLocaleLowered => $requestedLocale) { + $localeObj = $this->parseLocale($requestedLocaleLowered); + + if ( + isset($availableLocales[$requestedLocaleLowered]) + && (! $similarMatch || $this->parseLocale($similarMatch)->language === $localeObj->language) + ) { + // Prefer perfect match only if no similar match has been found yet or the perfect match is more precise + // than the similar match + return $availableLocales[$requestedLocaleLowered]; + } + + if (! $similarMatch) { + foreach ($availableLocales as $availableLocaleLowered => $availableLocale) { + if ($this->parseLocale($availableLocaleLowered)->language === $localeObj->language) { + $similarMatch = $availableLocaleLowered; + break; + } + } + } + } + + return $similarMatch ? $availableLocales[$similarMatch] : $this->defaultLocale; + } + + /** + * Parse a locale into its subtags + * + * Converts to output of {@link \Locale::parseLocale()} to an object and returns it. + * + * @param string $locale + * + * @return stdClass Output of {@link \Locale::parseLocale()} converted to an object + */ + public function parseLocale($locale) + { + return (object) \Locale::parseLocale($locale); + } +} diff --git a/vendor/ipl/i18n/src/NoopTranslator.php b/vendor/ipl/i18n/src/NoopTranslator.php new file mode 100644 index 0000000..1f9aab2 --- /dev/null +++ b/vendor/ipl/i18n/src/NoopTranslator.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\I18n; + +use ipl\Stdlib\Contract\Translator; + +/** + * Translator that just returns the original messages + */ +class NoopTranslator implements Translator +{ + public function translate($message, $context = null) + { + return $message; + } + + public function translateInDomain($domain, $message, $context = null) + { + return $message; + } + + public function translatePlural($singular, $plural, $number, $context = null) + { + return $number === 1 ? $singular : $plural; + } + + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + return $number === 1 ? $singular : $plural; + } +} diff --git a/vendor/ipl/i18n/src/StaticTranslator.php b/vendor/ipl/i18n/src/StaticTranslator.php new file mode 100644 index 0000000..d2869bf --- /dev/null +++ b/vendor/ipl/i18n/src/StaticTranslator.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\I18n; + +use ipl\Stdlib\Contract\Translator; + +/** + * Static entrypoint for a translator instance + */ +class StaticTranslator +{ + /** @var Translator */ + public static $instance; +} diff --git a/vendor/ipl/i18n/src/Translation.php b/vendor/ipl/i18n/src/Translation.php new file mode 100644 index 0000000..eb40287 --- /dev/null +++ b/vendor/ipl/i18n/src/Translation.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\I18n; + +trait Translation +{ + /** + * The domain to use in methods {@see Translation::translate()} and {@see Translation::translatePlural()} + * + * Set this to your desired domain and use both mentioned methods as usual, if you never require the + * default translation domain. (It's still being used as a fallback if your domain doesn't provide a + * particular message.) + * + * @var string + */ + protected $translationDomain; + + /** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translate($message, $context = null) + { + return $this->translationDomain === null + ? StaticTranslator::$instance->translate($message, $context) + : StaticTranslator::$instance->translateInDomain($this->translationDomain, $message, $context); + } + + /** + * Translate a message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * @param string $domain + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translateInDomain($domain, $message, $context = null) + { + return StaticTranslator::$instance->translateInDomain($domain, $message, $context); + } + + /** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param ?int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePlural($singular, $plural, $number, $context = null) + { + return $this->translationDomain === null + ? StaticTranslator::$instance->translatePlural($singular, $plural, $number ?? 0, $context) + : StaticTranslator::$instance->translatePluralInDomain( + $this->translationDomain, + $singular, + $plural, + $number ?? 0, + $context + ); + } + + /** + * Translate a plural message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $domain + * @param string $singular Singular message + * @param string $plural Plural message + * @param ?int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + return StaticTranslator::$instance->translatePluralInDomain( + $domain, + $singular, + $plural, + $number ?? 0, + $context + ); + } +} diff --git a/vendor/ipl/i18n/src/functions.php b/vendor/ipl/i18n/src/functions.php new file mode 100644 index 0000000..74d58df --- /dev/null +++ b/vendor/ipl/i18n/src/functions.php @@ -0,0 +1,34 @@ +<?php + +namespace ipl\I18n; + +/** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ +function t($message, $context = null) +{ + return StaticTranslator::$instance->translate($message, $context); +} + +/** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ +function tp($singular, $plural, $number, $context = null) +{ + return StaticTranslator::$instance->translatePlural($singular, $plural, $number, $context); +} diff --git a/vendor/ipl/i18n/src/functions_include.php b/vendor/ipl/i18n/src/functions_include.php new file mode 100644 index 0000000..68f3806 --- /dev/null +++ b/vendor/ipl/i18n/src/functions_include.php @@ -0,0 +1,6 @@ +<?php + +// Don't redefine the functions if included multiple times +if (! function_exists('ipl\I18n\t')) { + require __DIR__ . '/functions.php'; +} diff --git a/vendor/ipl/orm/LICENSE b/vendor/ipl/orm/LICENSE new file mode 100644 index 0000000..9233b0f --- /dev/null +++ b/vendor/ipl/orm/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2019 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/orm/composer.json b/vendor/ipl/orm/composer.json new file mode 100644 index 0000000..71f17de --- /dev/null +++ b/vendor/ipl/orm/composer.json @@ -0,0 +1,34 @@ +{ + "name": "ipl/orm", + "type": "library", + "description": "Icinga PHP Library - ORM", + "license": "MIT", + "keywords": [ + "sql", + "database", + "orm" + ], + "homepage": "https://github.com/Icinga/ipl-orm", + "require": { + "php": ">=7.2", + "ext-pdo": "*", + "ipl/sql": ">=0.7.0", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "psr-4": { + "ipl\\Orm\\": "src" + } + }, + "require-dev": { + "ext-pdo_sqlite": "*", + "ipl/sql": "dev-main", + "ipl/stdlib": "dev-main" + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Orm\\": "tests", + "ipl\\Tests\\Sql\\": "vendor/ipl/sql/tests" + } + } +} diff --git a/vendor/ipl/orm/src/AliasedExpression.php b/vendor/ipl/orm/src/AliasedExpression.php new file mode 100644 index 0000000..ed733a2 --- /dev/null +++ b/vendor/ipl/orm/src/AliasedExpression.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Expression; + +class AliasedExpression extends Expression +{ + /** @var string */ + protected $alias; + + /** + * Create a new database expression + * + * @param string $alias The alias to use for the expression, this is expected to be quoted and qualified + * @param string $statement The statement of the expression + * @param ?array $columns The columns used by the expression + * @param mixed ...$values The values for the expression + */ + public function __construct(string $alias, string $statement, array $columns = null, ...$values) + { + parent::__construct($statement, $columns, ...$values); + + $this->alias = $alias; + } + + /** + * Get this expression's alias + * + * @return string + */ + public function getAlias(): string + { + return $this->alias; + } +} diff --git a/vendor/ipl/orm/src/Behavior.php b/vendor/ipl/orm/src/Behavior.php new file mode 100644 index 0000000..45b5e87 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm; + +/** + * Interface Behavior + * + * @internal Used for type hinting only. Concrete behaviors are supposed to implement contracts from ipl\Orm\Contract + */ +interface Behavior +{ +} diff --git a/vendor/ipl/orm/src/Behavior/Binary.php b/vendor/ipl/orm/src/Behavior/Binary.php new file mode 100644 index 0000000..c43082a --- /dev/null +++ b/vendor/ipl/orm/src/Behavior/Binary.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\Orm\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Exception\ValueConversionException; +use ipl\Orm\Query; +use ipl\Sql\Adapter\Pgsql; +use ipl\Stdlib\Filter\Condition; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +/** + * Support hex filters for binary columns and PHP resource (in) / bytea hex format (out) transformation for PostgreSQL + */ +class Binary extends PropertyBehavior implements QueryAwareBehavior, RewriteFilterBehavior +{ + /** @var bool Whether the query is using a pgsql adapter */ + protected $isPostgres = true; + + public function fromDb($value, $key, $_) + { + if (! $this->isPostgres) { + return $value; + } + + if ($value !== null) { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + return null; + } + + /** + * @throws ValueConversionException If value is a resource + */ + public function toDb($value, $key, $_) + { + if (! $this->isPostgres) { + return $value; + } + + if (is_resource($value)) { + throw new ValueConversionException(sprintf('Unexpected resource for %s', $key)); + } + + if ($value === '*') { + /** + * Support IS (NOT) NULL filter transformation. + * {@see \ipl\Sql\Compat\FilterProcessor::assemblePredicate()} + */ + return $value; + } + + return sprintf('\\x%s', bin2hex($value)); + } + + public function setQuery(Query $query) + { + $this->isPostgres = $query->getDb()->getAdapter() instanceof Pgsql; + + return $this; + } + + public function rewriteCondition(Condition $condition, $relation = null) + { + /** + * TODO(lippserd): Duplicate code because {@see RewriteFilterBehavior}s come after {@see PropertyBehavior}s. + * {@see \ipl\Orm\Compat\FilterProcessor::requireAndResolveFilterColumns()} + */ + $column = $condition->metaData()->get('columnName'); + if (isset($this->properties[$column])) { + $value = $condition->metaData()->get('originalValue'); + + if ($this->isPostgres && is_resource($value)) { + throw new UnexpectedValueException(sprintf('Unexpected resource for %s', $column)); + } + + // ctype_xdigit expects strings. + $value = (string) $value; + /** + * Although this code path is also affected by the duplicate behavior evaluation stated in {@see toDb()}, + * no further adjustments are needed as ctype_xdigit returns false for binary and bytea hex strings. + */ + if (ctype_xdigit($value)) { + if (! $this->isPostgres) { + $condition->setValue(hex2bin($value)); + } elseif (substr($value, 0, 2) !== '\\x') { + $condition->setValue(sprintf('\\x%s', $value)); + } + } + } + } +} diff --git a/vendor/ipl/orm/src/Behavior/BoolCast.php b/vendor/ipl/orm/src/Behavior/BoolCast.php new file mode 100644 index 0000000..ad1748a --- /dev/null +++ b/vendor/ipl/orm/src/Behavior/BoolCast.php @@ -0,0 +1,147 @@ +<?php + +namespace ipl\Orm\Behavior; + +use InvalidArgumentException; +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Exception\ValueConversionException; + +use function ipl\Stdlib\get_php_type; + +/** + * Convert specific database values from and to boolean + * + * To unify the support of boolean values in different database systems, + * specific database values are converted to and from boolean values, + * e.g. by default `n` is converted to `false` and `y` to `true` and vice versa respectively, + * which could be stored as `ENUM('n', 'y')`. + */ +class BoolCast extends PropertyBehavior +{ + /** @var mixed Database value for boolean `false` */ + protected $falseValue = 'n'; + + /** @var mixed Database value for boolean `true` */ + protected $trueValue = 'y'; + + /** @var bool Whether to throw an exception if the value is not equal to the value for false or true */ + protected $strict = true; + + /** + * Get the database value representing boolean `false` + * + * @return mixed + */ + public function getFalseValue() + { + return $this->falseValue; + } + + /** + * Set the database value representing boolean `false` + * + * @param mixed $falseValue + * + * @return $this + */ + public function setFalseValue($falseValue): self + { + $this->falseValue = $falseValue; + + return $this; + } + + /** + * Get the database value representing boolean `true` + * + * @return mixed + */ + public function getTrueValue() + { + return $this->trueValue; + } + + /** + * Get the database value representing boolean `true` + * + * @param mixed $trueValue + * + * @return $this + */ + public function setTrueValue($trueValue): self + { + $this->trueValue = $trueValue; + + return $this; + } + + /** + * Get whether to throw an exception if the value is not equal to the value for false or true + * + * @return bool + */ + public function isStrict(): bool + { + return $this->strict; + } + + /** + * Set whether to throw an exception if the value is not equal to the value for false or true + * + * @param bool $strict + * + * @return $this + */ + public function setStrict(bool $strict): self + { + $this->strict = $strict; + + return $this; + } + + public function fromDb($value, $key, $_) + { + switch (true) { + case $this->trueValue === $value: + return true; + case $this->falseValue === $value: + return false; + default: + if ($this->isStrict() && $value !== null) { + throw new InvalidArgumentException(sprintf( + 'Expected %s or %s, got %s instead', + $this->trueValue, + $this->falseValue, + $value + )); + } + + return $value; + } + } + + public function toDb($value, $key, $_) + { + if ($value === null) { + return null; + } + + if (! is_bool($value)) { + if ( + $this->isStrict() + && $value !== '*' + && $value !== $this->getFalseValue() + && $value !== $this->getTrueValue() + ) { + throw new ValueConversionException(sprintf( + 'Expected bool, got %s instead', + get_php_type($value) + )); + } + + return $value; + } + + return $value ? $this->trueValue : $this->falseValue; + } +} diff --git a/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php b/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php new file mode 100644 index 0000000..65d8033 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Orm\Behavior; + +use DateTime; +use DateTimeZone; +use Exception; +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Exception\ValueConversionException; + +class MillisecondTimestamp extends PropertyBehavior +{ + public function fromDb($value, $key, $context) + { + if ($value === null) { + return null; + } + + $datetime = DateTime::createFromFormat('U.u', sprintf('%F', $value / 1000.0)); + $datetime->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return $datetime; + } + + public function toDb($value, $key, $context) + { + if (is_numeric($value)) { + return (int) ($value * 1000.0); + } + + if (! $value instanceof DateTime) { + try { + $value = new DateTime($value); + } catch (Exception $err) { + throw new ValueConversionException(sprintf('Invalid date time format provided: %s', $value)); + } + } + + return (int) ($value->format('U.u') * 1000.0); + } +} diff --git a/vendor/ipl/orm/src/Behaviors.php b/vendor/ipl/orm/src/Behaviors.php new file mode 100644 index 0000000..5c54350 --- /dev/null +++ b/vendor/ipl/orm/src/Behaviors.php @@ -0,0 +1,238 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Contract\PersistBehavior; +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\RetrieveBehavior; +use ipl\Orm\Contract\RewriteColumnBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Contract\RewritePathBehavior; +use ipl\Stdlib\Filter; +use IteratorAggregate; +use Traversable; + +class Behaviors implements IteratorAggregate +{ + /** @var array Registered behaviors */ + protected $behaviors = []; + + /** @var RetrieveBehavior[] Registered retrieve behaviors */ + protected $retrieveBehaviors = []; + + /** @var PersistBehavior[] Registered persist behaviors */ + protected $persistBehaviors = []; + + /** @var PropertyBehavior[] Registered property behaviors */ + protected $propertyBehaviors = []; + + /** @var RewriteFilterBehavior[] Registered rewrite filter behaviors */ + protected $rewriteFilterBehaviors = []; + + /** @var RewriteColumnBehavior[] Registered rewrite column behaviors */ + protected $rewriteColumnBehaviors = []; + + /** @var RewritePathBehavior[] Registered rewrite path behaviors */ + protected $rewritePathBehaviors = []; + + /** + * Add a behavior + * + * @param PersistBehavior|PropertyBehavior|RetrieveBehavior|RewriteFilterBehavior $behavior + */ + public function add(Behavior $behavior) + { + $this->behaviors[] = $behavior; + + if ($behavior instanceof PropertyBehavior) { + $this->retrieveBehaviors[] = $behavior; + $this->persistBehaviors[] = $behavior; + $this->propertyBehaviors[] = $behavior; + } else { + if ($behavior instanceof RetrieveBehavior) { + $this->retrieveBehaviors[] = $behavior; + } + + if ($behavior instanceof PersistBehavior) { + $this->persistBehaviors[] = $behavior; + } + } + + if ($behavior instanceof RewriteFilterBehavior) { + $this->rewriteFilterBehaviors[] = $behavior; + } + + if ($behavior instanceof RewriteColumnBehavior) { + $this->rewriteColumnBehaviors[] = $behavior; + } + + if ($behavior instanceof RewritePathBehavior) { + $this->rewritePathBehaviors[] = $behavior; + } + } + + /** + * Iterate registered behaviors + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->behaviors); + } + + /** + * Apply all retrieve behaviors on the given model + * + * @param Model $model + */ + public function retrieve(Model $model) + { + foreach ($this->retrieveBehaviors as $behavior) { + $behavior->retrieve($model); + } + } + + /** + * Apply all persist behaviors on the given model + * + * @param Model $model + */ + public function persist(Model $model) + { + foreach ($this->persistBehaviors as $behavior) { + $behavior->persist($model); + } + } + + /** + * Transform the retrieved key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->retrieveProperty($value, $key); + } + + return $value; + } + + /** + * Transform the to be persisted key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->persistProperty($value, $key); + } + + return $value; + } + + /** + * Rewrite the given filter condition + * + * @param Filter\Condition $condition + * @param string $relation Absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $filter = null; + foreach ($this->rewriteFilterBehaviors as $behavior) { + $replacement = $behavior->rewriteCondition($filter ?: $condition, $relation); + if ($replacement !== null) { + $filter = $replacement; + } + } + + return $filter; + } + + /** + * Rewrite the given relation path + * + * @param string $path + * @param string $relation Absolute path of the model + * + * @return string|null + */ + public function rewritePath($path, $relation = null) + { + $newPath = null; + foreach ($this->rewritePathBehaviors as $behavior) { + $replacement = $behavior->rewritePath($newPath ?: $path, $relation); + if ($replacement !== null) { + $newPath = $replacement; + } + } + + return $newPath; + } + + /** + * Rewrite the given column + * + * @param string $column + * @param string $relation Absolute path of the model + * + * @return mixed + */ + public function rewriteColumn($column, $relation = null) + { + $newColumn = null; + foreach ($this->rewriteColumnBehaviors as $behavior) { + $replacement = $behavior->rewriteColumn($newColumn ?: $column, $relation); + if ($replacement !== null) { + $newColumn = $replacement; + } + } + + return $newColumn; + } + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation Absolute path of the model + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + $behavior->rewriteColumnDefinition($def, $relation); + } + } + + /** + * Get whether the given column is selectable + * + * @param string $column + * + * @return bool + */ + public function isSelectableColumn(string $column): bool + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + if ($behavior->isSelectableColumn($column)) { + return true; + } + } + + return false; + } +} diff --git a/vendor/ipl/orm/src/ColumnDefinition.php b/vendor/ipl/orm/src/ColumnDefinition.php new file mode 100644 index 0000000..ddb8062 --- /dev/null +++ b/vendor/ipl/orm/src/ColumnDefinition.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use LogicException; + +class ColumnDefinition +{ + /** @var string The name of the column */ + protected $name; + + /** @var ?string The label of the column */ + protected $label; + + /** + * Create a new column definition + * + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Get the column name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the column label + * + * @return ?string + */ + public function getLabel(): ?string + { + return $this->label; + } + + /** + * Set the column label + * + * @param ?string $label + * + * @return $this + */ + public function setLabel(?string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Create a new column definition based on the given options + * + * @param array $options + * + * @return self + */ + public static function fromArray(array $options): self + { + if (! isset($options['name'])) { + throw new InvalidArgumentException('$options must provide a name'); + } + + $self = new static($options['name']); + if (isset($options['label'])) { + $self->setLabel($options['label']); + } + + return $self; + } +} diff --git a/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php new file mode 100644 index 0000000..e8d3a84 --- /dev/null +++ b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Orm\Common; + +use Closure; +use Traversable; + +trait PropertiesWithDefaults +{ + use \ipl\Stdlib\Properties { + \ipl\Stdlib\Properties::getProperty as private parentGetProperty; + } + + protected function getProperty($key) + { + if (isset($this->properties[$key]) && $this->properties[$key] instanceof Closure) { + $this->setProperty($key, $this->properties[$key]($this, $key)); + } + + return $this->parentGetProperty($key); + } + + public function getIterator(): Traversable + { + foreach ($this->properties as $key => $value) { + if (! $value instanceof Closure) { + yield $key => $value; + } + } + } +} diff --git a/vendor/ipl/orm/src/Common/SortUtil.php b/vendor/ipl/orm/src/Common/SortUtil.php new file mode 100644 index 0000000..a14ea2b --- /dev/null +++ b/vendor/ipl/orm/src/Common/SortUtil.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Orm\Common; + +use ipl\Stdlib\Str; + +class SortUtil +{ + /** + * Create the sort column(s) and direction(s) from the given sort spec + * + * @param array|string $sort + * + * @return array<int, mixed> Sort column(s) and direction(s) suitable for {@link OrderByInterface::orderBy()} + */ + public static function createOrderBy($sort): array + { + $columnsAndDirections = static::explodeSortSpec($sort); + $orderBy = []; + + foreach ($columnsAndDirections as $columnAndDirection) { + list($column, $direction) = static::splitColumnAndDirection($columnAndDirection); + + $orderBy[] = [$column, $direction]; + } + + return $orderBy; + } + + /** + * Explode the given sort spec into its sort parts + * + * @param array|string $sort + * + * @return array + */ + public static function explodeSortSpec($sort) + { + return Str::trimSplit(implode(',', (array) $sort)); + } + + /** + * Normalize the given sort spec to a sort string + * + * @param array|string $sort + * + * @return string + */ + public static function normalizeSortSpec($sort) + { + return implode(',', static::explodeSortSpec($sort)); + } + + /** + * Explode the given sort part into its sort column and direction + * + * @param string $sort + * + * @return array + */ + public static function splitColumnAndDirection($sort) + { + return Str::symmetricSplit($sort, ' ', 2); + } +} diff --git a/vendor/ipl/orm/src/Compat/FilterProcessor.php b/vendor/ipl/orm/src/Compat/FilterProcessor.php new file mode 100644 index 0000000..7956898 --- /dev/null +++ b/vendor/ipl/orm/src/Compat/FilterProcessor.php @@ -0,0 +1,375 @@ +<?php + +namespace ipl\Orm\Compat; + +use AppendIterator; +use ArrayIterator; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\ValueConversionException; +use ipl\Orm\Query; +use ipl\Orm\Relation; +use ipl\Orm\UnionQuery; +use ipl\Sql\Filter\Exists; +use ipl\Sql\Filter\In; +use ipl\Sql\Filter\NotExists; +use ipl\Sql\Filter\NotIn; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Filter\MetaDataProvider; +use ipl\Stdlib\Filter; + +class FilterProcessor extends \ipl\Sql\Compat\FilterProcessor +{ + protected $baseJoins = []; + + protected $madeJoins = []; + + /** + * Require and resolve the filter rule and apply it on the query + * + * Note that this applies the filter to {@see Query::$selectBase} + * directly and bypasses {@see Query::$filter}. If this is not + * desired, utilize the {@see Filterable} functions of the query. + * + * @param Filter\Rule $filter + * @param Query $query + */ + public static function apply(Filter\Rule $filter, Query $query) + { + if ($query instanceof UnionQuery) { + foreach ($query->getUnions() as $union) { + static::apply($filter, $union); + } + + return; + } + + if (! $filter instanceof Filter\Chain || ! $filter->isEmpty()) { + $filter = clone $filter; + if (! $filter instanceof Filter\Chain) { + $filter = Filter::all($filter); + } + + static::resolveFilter($filter, $query); + + $where = static::assembleFilter($filter); + + if ($where) { + $operator = array_shift($where); + $conditions = array_shift($where); + + $query->getSelectBase()->where($conditions, $operator); + } + } + } + + /** + * Resolve the filter in order to apply it on the query + * + * @param Filter\Chain $filter + * @param Query $query + * + * @return void + */ + public static function resolveFilter(Filter\Chain $filter, Query $query) + { + $processor = new static(); + foreach ($query->getUtilize() as $path => $_) { + $processor->baseJoins[$path] = true; + } + + $processor->requireAndResolveFilterColumns($filter, $query); + } + + protected function requireAndResolveFilterColumns(Filter\Rule $filter, Query $query, $forceOptimization = null) + { + if ($filter instanceof Filter\Condition) { + if ( + $filter instanceof Exists + || $filter instanceof NotExists + || $filter instanceof In + || $filter instanceof NotIn + ) { + return; + } + + $resolver = $query->getResolver(); + $baseTable = $query->getModel()->getTableAlias(); + $column = $resolver->qualifyPath( + $filter->metaData()->get('columnName', $filter->getColumn()), + $baseTable + ); + + $filter->metaData()->set('columnPath', $column); + + list($relationPath, $columnName) = preg_split('/\.(?=[^.]+$)/', $column); + + $subject = null; + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$baseTable => null])); + $relations->append($resolver->resolveRelations($relationPath)); + $behaviorsApplied = $filter->metaData()->get('behaviorsApplied', false); + foreach ($relations as $path => $relation) { + $columnName = substr($column, strlen($path) + 1); + + if ($path === $baseTable) { + $subject = $query->getModel(); + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $subjectBehaviors = $resolver->getBehaviors($subject); + // This is only used within the Binary behavior in rewriteCondition(). + $filter->metaData()->set('originalValue', $filter->getValue()); + + if (! $behaviorsApplied) { + try { + // Prepare filter as if it were final to allow full control for rewrite filter behaviors + $filter->setValue($subjectBehaviors->persistProperty($filter->getValue(), $columnName)); + } catch (ValueConversionException $_) { + // The search bar may submit values with wildcards or whatever the user has entered. + // In this case, we can simply ignore this error instead of rendering a stack trace. + } + } + + $filter->setColumn($resolver->getAlias($subject) . '.' . $columnName); + $filter->metaData()->set('columnName', $columnName); + $filter->metaData()->set('relationPath', $path); + + if (! $behaviorsApplied) { + $rewrittenFilter = $subjectBehaviors->rewriteCondition($filter, $path . '.'); + if ($rewrittenFilter !== null) { + return $this->requireAndResolveFilterColumns($rewrittenFilter, $query, $forceOptimization) + ?: $rewrittenFilter; + } + } + } + + /** + * We have applied all the subject behaviors for this filter condition, so set this metadata to prevent + * the behaviors from being applied for the same filter condition over again later in case of a subquery. + * The behaviors are processed again due to $subQueryFilter being evaluated by this processor as part of + * the subquery. The reason for this is the application of aliases used in said subquery. Since this is + * part of the filter column qualification, and the behaviors are not, this should be separately done. + * There's a similar comment in {@see Query::createSubQuery()} which should be considered when working + * on improving this. + */ + $filter->metaData()->set('behaviorsApplied', true); + + if (! $resolver->hasSelectableColumn($subject, $columnName)) { + throw new InvalidColumnException($columnName, $subject); + } + + if ($relationPath !== $baseTable) { + $query->utilize($relationPath); + $this->madeJoins[$relationPath][] = $filter; + } + } else { + /** @var Filter\Chain $filter */ + + if ($filter->metaData()->has('forceOptimization')) { + // Rules can override the default behavior how it's determined that they need to be + // optimized. If it's done by a chain, it applies to all of its children. + $forceOptimization = $filter->metaData()->get('forceOptimization'); + } + + $subQueryGroups = []; + /** @var Filter\Rule[] $outsourcedRules */ + $outsourcedRules = []; + foreach ($filter as $child) { + /** @var Filter\Rule $child */ + $rewrittenFilter = $this->requireAndResolveFilterColumns($child, $query, $forceOptimization); + if ($rewrittenFilter !== null) { + $filter->replace($child, $rewrittenFilter); + $child = $rewrittenFilter; + } + + $optimizeChild = $forceOptimization; + if ($child instanceof MetaDataProvider && $child->metaData()->has('forceOptimization')) { + $optimizeChild = $child->metaData()->get('forceOptimization'); + } + + // We only optimize rules in a single level, nested chains are ignored + if ($child instanceof Filter\Condition && $child->metaData()->has('relationPath')) { + $relationPath = $child->metaData()->get('relationPath'); + if ( + $relationPath !== $query->getModel()->getTableAlias() // Not the base table + && ( + $optimizeChild !== null && $optimizeChild + || ( + $optimizeChild === null + && ! isset($query->getWith()[$relationPath]) // Not a selected join + && ! $query->getResolver()->isDistinctRelation($relationPath) // Not a to-one relation + ) + ) + ) { + $subQueryGroups[$relationPath][$child->getColumn()][get_class($child)][] = $child; + + // Register all rules that are going to be put into sub queries, for later cleanup + $outsourcedRules[] = $child; + } + } + } + + foreach ($subQueryGroups as $relationPath => $columns) { + $generalRules = []; + foreach ($columns as $column => $comparisons) { + if (isset($comparisons[Filter\Unequal::class]) || isset($comparisons[Filter\Unlike::class])) { + // If there's a unequal (!=) comparison for any column, all other comparisons (for the same + // column) also need to be outsourced to their own sub query. Regardless of their amount of + // occurrence. This is because `$generalRules` apply to all comparisons of such a column and + // need to be applied to all sub queries. + continue; + } + + // Single occurring columns don't need their own sub query + foreach ($comparisons as $conditionClass => $rules) { + if (count($rules) === 1) { + $generalRules[] = $rules[0]; + unset($columns[$column][$conditionClass]); + } + } + + if (empty($columns[$column])) { + unset($columns[$column]); + } + } + + $count = null; + $baseFilters = null; + $subQueryFilters = []; + foreach ($columns as $column => $comparisons) { + foreach ($comparisons as $conditionClass => $rules) { + if ($conditionClass === Filter\Unequal::class || $conditionClass === Filter\Unlike::class) { + // Unequal comparisons are always put into their own sub query + $subQueryFilters[] = [$rules, count($rules), true]; + } elseif (count($rules) > $count) { + // If there are multiple columns used multiple times in the same relation, we have to decide + // which to use as the primary comparison. That is the column that is used most often. + if (! empty($baseFilters)) { + array_push($generalRules, ...$baseFilters); + } + + $count = count($rules); + $baseFilters = $rules; + } else { + array_push($generalRules, ...$rules); + } + } + } + + if (! empty($baseFilters) || ! empty($generalRules)) { + $subQueryFilters[] = [$baseFilters ?: $generalRules, $count, false]; + } + + foreach ($subQueryFilters as list($filters, $count, $negate)) { + $subQueryFilter = null; + if ($count !== null) { + $aggregateFilter = Filter::any(); + foreach ($filters as $condition) { + if ($negate) { + if ($condition instanceof Filter\Unequal) { + $negation = Filter::equal($condition->getColumn(), $condition->getValue()); + } else { // if ($condition instanceof Filter\Unlike) + $negation = Filter::like($condition->getColumn(), $condition->getValue()); + } + + $negation->metaData()->merge($condition->metaData()); + $condition = $negation; + $count = 1; + } + + switch (true) { + case $filter instanceof Filter\All: + $aggregateFilter->add(Filter::all($condition, ...$generalRules)); + break; + case $filter instanceof Filter\Any: + $aggregateFilter->add(Filter::any($condition, ...$generalRules)); + break; + case $filter instanceof Filter\None: + $aggregateFilter->add(Filter::none($condition, ...$generalRules)); + break; + } + } + + $subQueryFilter = $aggregateFilter; + } else { + switch (true) { + case $filter instanceof Filter\All: + $subQueryFilter = Filter::all(...$filters); + break; + case $filter instanceof Filter\Any: + $subQueryFilter = Filter::any(...$filters); + break; + case $filter instanceof Filter\None: + $subQueryFilter = Filter::none(...$filters); + break; + } + } + + $relation = $query->getResolver()->resolveRelation($relationPath); + $subQuery = $query->createSubQuery($relation->getTarget(), $relationPath, null, false); + $subQuery->getResolver()->setAliasPrefix('sub_'); + + $subQuery->filter($subQueryFilter); + + $subQuerySelect = $subQuery->assembleSelect()->resetOrderBy(); + + if ($count !== null && ($negate || $filter instanceof Filter\All)) { + $targetKeys = join( + ',', + array_values( + $subQuery->getResolver()->qualifyColumns( + (array) $subQuery->getModel()->getKeyName(), + $subQuery->getModel() + ) + ) + ); + + $subQuerySelect->having(["COUNT(DISTINCT $targetKeys) >= ?" => $count]); + $subQuerySelect->groupBy(array_values($subQuerySelect->getColumns())); + } + + // TODO: Qualification is only necessary since the `In` and `NotIn` conditions are ignored by + // requireAndResolveFilterColumns(). In case it supports not only single columns but also + // multiple, this might be reduced to: $keyTuple = (array) $query->getModel()->getKeyName() + $keyTuple = array_values( + $query->getResolver()->qualifyColumns( + (array) $query->getModel()->getKeyName(), + $query->getModel() + ) + ); + + if ($negate) { + $filter->add(new NotIn($keyTuple, $subQuerySelect)); + } else { + $filter->add(new In($keyTuple, $subQuerySelect)); + } + } + } + + foreach ($outsourcedRules as $rule) { + // Remove joins solely used for filter conditions + foreach ($this->madeJoins as $joinPath => & $madeBy) { + $madeBy = array_filter( + $madeBy, + function ($relationFilter) use ($rule) { + return $rule !== $relationFilter + && (! $rule instanceof Filter\Chain || ! $rule->has($relationFilter)); + } + ); + + if (empty($madeBy)) { + if (! isset($this->baseJoins[$joinPath])) { + $query->omit($joinPath); + } + + unset($this->madeJoins[$joinPath]); + } + } + + $filter->remove($rule); + } + } + } +} diff --git a/vendor/ipl/orm/src/Contract/PersistBehavior.php b/vendor/ipl/orm/src/Contract/PersistBehavior.php new file mode 100644 index 0000000..a6db05d --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PersistBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface PersistBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is persisted in the database. + * + * @param Model $model + */ + public function persist(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/PropertyBehavior.php b/vendor/ipl/orm/src/Contract/PropertyBehavior.php new file mode 100644 index 0000000..c828458 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PropertyBehavior.php @@ -0,0 +1,102 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Model; +use OutOfBoundsException; + +abstract class PropertyBehavior implements RetrieveBehavior, PersistBehavior +{ + /** @var array Property names of which the value should be processed */ + protected $properties; + + /** + * PropertyBehavior constructor + * + * @param array $properties Property names to process, as values + */ + public function __construct(array $properties) + { + if (is_int(key($properties))) { + $this->properties = array_flip($properties); + } else { + $this->properties = $properties; + } + } + + public function retrieve(Model $model) + { + foreach ($this->properties as $key => $ctx) { + if ($model->hasProperty($key)) { + $model[$key] = $this->fromDb($model[$key], $key, $ctx); + } + } + } + + public function persist(Model $model) + { + foreach ($this->properties as $key => $ctx) { + try { + $model[$key] = $this->toDb($model[$key], $key, $ctx); + } catch (OutOfBoundsException $_) { + // pass + } + } + } + + /** + * Transform the given value, just fetched from the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->fromDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value, about to be persisted to the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->toDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value which has just been fetched from the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function fromDb($value, $key, $context); + + /** + * Transform the given value which is about to be persisted to the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function toDb($value, $key, $context); +} diff --git a/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php new file mode 100644 index 0000000..b67bf51 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Query; + +interface QueryAwareBehavior extends Behavior +{ + /** + * Set the query + * + * @param Query $query + * + * @return $this + */ + public function setQuery(Query $query); +} diff --git a/vendor/ipl/orm/src/Contract/RetrieveBehavior.php b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php new file mode 100644 index 0000000..884d074 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface RetrieveBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is fetched from the database. + * + * @param Model $model + */ + public function retrieve(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php new file mode 100644 index 0000000..b6f545b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\ColumnDefinition; + +interface RewriteColumnBehavior extends RewriteFilterBehavior +{ + /** + * Rewrite the given column + * + * The result must be returned otherwise (NULL is returned) the original column is kept as is. + * + * @param mixed $column + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return mixed + */ + public function rewriteColumn($column, ?string $relation = null); + + /** + * Get whether {@see rewriteColumn} might return an otherwise unknown column or expression + * + * @param string $name + * + * @return bool + */ + public function isSelectableColumn(string $name): bool; + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void; +} diff --git a/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php new file mode 100644 index 0000000..af6de5b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Stdlib\Filter; + +interface RewriteFilterBehavior extends Behavior +{ + /** + * Rewrite the given filter condition + * + * The condition can either be adjusted directly or replaced by an entirely new rule. The result must be + * returned otherwise (NULL is returned) processing continues normally. (Isn't restarted) + * + * If a result is returned, it is required to append the given absolute path of the model to the column. + * Processing of the condition will be restarted, hence the column has to be an absolute path again. + * + * @param Filter\Condition $condition + * @param string $relation The absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null); +} diff --git a/vendor/ipl/orm/src/Contract/RewritePathBehavior.php b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php new file mode 100644 index 0000000..b5b0385 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php @@ -0,0 +1,20 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; + +interface RewritePathBehavior extends Behavior +{ + /** + * Rewrite the given relation path + * + * The result must be returned otherwise (NULL is returned) the original path is kept as is. + * + * @param string $path + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return ?string + */ + public function rewritePath(string $path, ?string $relation = null): ?string; +} diff --git a/vendor/ipl/orm/src/Defaults.php b/vendor/ipl/orm/src/Defaults.php new file mode 100644 index 0000000..aa2d517 --- /dev/null +++ b/vendor/ipl/orm/src/Defaults.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Orm; + +use IteratorAggregate; +use Traversable; + +class Defaults implements IteratorAggregate +{ + /** @var array<string, mixed> Registered defaults */ + protected $defaults = []; + + /** + * Iterate over the defaults + * + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->defaults as $column => $default) { + yield $column => $default; + } + } + + /** + * Add a default for the given property + * + * @param string $property + * @param mixed $default If it's a closure, its interface is assumed to be + * ({@see Model} $subject, string $propertyName) + * + * @return $this + */ + public function add(string $property, $default): self + { + $this->defaults[$property] = $default; + + return $this; + } + + /** + * Get whether a default for the given property exists + * + * @param string $property + * + * @return bool + */ + public function has(string $property): bool + { + return array_key_exists($property, $this->defaults); + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidColumnException.php b/vendor/ipl/orm/src/Exception/InvalidColumnException.php new file mode 100644 index 0000000..cd320c6 --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidColumnException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidColumnException extends Exception +{ + /** @var string The column name */ + protected $column; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidColumnException + * + * @param string $column The column name + * @param Model $model The target model + */ + public function __construct($column, Model $model) + { + $this->column = (string) $column; + $this->model = $model; + + parent::__construct(sprintf( + "Can't require column '%s' in model '%s'. Column not found.", + $column, + get_class($model) + )); + } + + /** + * Get the column name + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidRelationException.php b/vendor/ipl/orm/src/Exception/InvalidRelationException.php new file mode 100644 index 0000000..51e81bb --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidRelationException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidRelationException extends Exception +{ + /** @var string The relation name */ + protected $relation; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidRelationException + * + * @param string $relation The relation name + * @param Model $model The target model + */ + public function __construct($relation, Model $model = null) + { + $this->relation = (string) $relation; + $this->model = $model; + + parent::__construct(sprintf( + 'Cannot join relation "%s"%s. Relation not found.', + $relation, + $model ? ' in model ' . get_class($model) : '' + )); + } + + /** + * Get the relation name + * + * @return string + */ + public function getRelation() + { + return $this->relation; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Exception/ValueConversionException.php b/vendor/ipl/orm/src/Exception/ValueConversionException.php new file mode 100644 index 0000000..499d9f3 --- /dev/null +++ b/vendor/ipl/orm/src/Exception/ValueConversionException.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; + +/** + * Exception thrown if values to be converted don't meet their constraints when reading or writing to the database + */ +class ValueConversionException extends Exception +{ +} diff --git a/vendor/ipl/orm/src/Hydrator.php b/vendor/ipl/orm/src/Hydrator.php new file mode 100644 index 0000000..e3cd23d --- /dev/null +++ b/vendor/ipl/orm/src/Hydrator.php @@ -0,0 +1,197 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use ipl\Orm\Exception\InvalidRelationException; + +/** + * Hydrates raw database rows into concrete model instances. + */ +class Hydrator +{ + /** @var array Additional hydration rules for the model's relations */ + protected $hydrators = []; + + /** @var Query The query the hydration rules are for */ + protected $query; + + /** + * Create a new Hydrator + * + * @param Query $query + */ + public function __construct(Query $query) + { + $this->query = $query; + } + + /** + * Add a hydration rule + * + * @param string $path Model path + * + * @return $this + * + * @throws \InvalidArgumentException If a hydrator for the given path already exists + */ + public function add($path) + { + if (isset($this->hydrators[$path])) { + throw new \InvalidArgumentException("Hydrator for path '$path' already exists"); + } + + $resolver = $this->query->getResolver(); + $target = $this->query->getModel(); + $relation = null; + + if ($path === $target->getTableAlias()) { + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine($selectableColumns, $selectableColumns); + } else { + $relation = $resolver->resolveRelation($path); + $target = $relation->getTarget(); + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine( + array_keys($resolver->qualifyColumnsAndAliases($selectableColumns, $target)), + $selectableColumns + ); + } + + $relationLoader = function (Model $subject, string $relationName) { + return $this->query->derive($relationName, $subject); + }; + + $defaults = $this->query->getResolver()->getDefaults($target); + foreach ($resolver->getRelations($target) as $targetRelation) { + $targetRelationName = $targetRelation->getName(); + if (! $defaults->has($targetRelationName)) { + $defaults->add($targetRelationName, $relationLoader); + } + } + + $this->hydrators[$path] = [$target, $relation, $columnToPropertyMap, $defaults]; + + return $this; + } + + /** + * Hydrate the given raw database rows into the specified model + * + * @param array $data + * @param Model $model + * + * @return Model + */ + public function hydrate(array $data, Model $model) + { + $defaultsToApply = []; + foreach ($this->hydrators as $path => $vars) { + list($target, $relation, $columnToPropertyMap, $defaults) = $vars; + + $subject = $model; + if ($relation !== null) { + /** @var Relation $relation */ + + $steps = explode('.', $path); + $baseTable = array_shift($steps); + $relationName = array_pop($steps); + + $parent = $model; + foreach ($steps as $i => $step) { + if (! isset($parent->$step)) { + $intermediateRelation = $this->query->getResolver()->resolveRelation( + $baseTable . '.' . implode('.', array_slice($steps, 0, $i + 1)), + $model + ); + $parentClass = $intermediateRelation->getTargetClass(); + $parent = $parent->$step = new $parentClass(); + } else { + $parent = $parent->$step; + } + } + + if (isset($parent->$relationName)) { + $subject = $parent->$relationName; + } else { + $subjectClass = $relation->getTargetClass(); + $subject = new $subjectClass(); + $parent->$relationName = $subject; + } + } + + $subject->setProperties($this->extractAndMap($data, $columnToPropertyMap)); + $this->query->getResolver()->getBehaviors($target)->retrieve($subject); + $defaultsToApply[] = [$subject, $defaults]; + } + + // If there are any columns left, propagate them to the targeted relation if possible, to the base otherwise + foreach ($data as $column => $value) { + $columnName = $column; + $steps = explode('_', $column); + $baseTable = array_shift($steps); + + $subject = $model; + $target = $this->query->getModel(); + $stepsTaken = []; + foreach ($steps as $step) { + $stepsTaken[] = $step; + $relationPath = "$baseTable." . implode('.', $stepsTaken); + + try { + $relation = $this->query->getResolver()->resolveRelation($relationPath); + } catch (InvalidArgumentException $_) { + // The base table is missing, which means the alias hasn't been qualified and is custom defined + break; + } catch (InvalidRelationException $_) { + array_pop($stepsTaken); + $columnName = implode('_', array_slice($steps, count($stepsTaken))); + break; + } + + if (! $subject->hasProperty($step)) { + $stepClass = $relation->getTargetClass(); + $subject->$step = new $stepClass(); + } + + $subject = $subject->$step; + $target = $relation->getTarget(); + } + + $subject->$columnName = $this->query + ->getResolver() + ->getBehaviors($target) + ->retrieveProperty($value, $columnName); + } + + // Apply defaults last, otherwise we may evaluate them during hydration + foreach ($defaultsToApply as list($subject, $defaults)) { + foreach ($defaults as $name => $default) { + if (! $subject->hasProperty($name)) { + $subject->$name = $default; + } + } + } + + return $model; + } + + /** + * Extract and map the given data based on the specified column to property resolution map + * + * @param array $data + * @param array $columnToPropertyMap + * + * @return array + */ + protected function extractAndMap(array &$data, array $columnToPropertyMap) + { + $extracted = []; + foreach (array_intersect_key($columnToPropertyMap, $data) as $column => $property) { + $extracted[$property] = $data[$column]; + unset($data[$column]); + } + + return $extracted; + } +} diff --git a/vendor/ipl/orm/src/Model.php b/vendor/ipl/orm/src/Model.php new file mode 100644 index 0000000..44baff7 --- /dev/null +++ b/vendor/ipl/orm/src/Model.php @@ -0,0 +1,143 @@ +<?php + +namespace ipl\Orm; + +use ipl\Orm\Common\PropertiesWithDefaults; +use ipl\Sql\Connection; +use ipl\Sql\ExpressionInterface; + +/** + * Models represent single database tables or parts of it. + * They are also used to interact with the tables, i.e. in order to query for data. + */ +abstract class Model implements \ArrayAccess, \IteratorAggregate +{ + use PropertiesWithDefaults; + + final public function __construct(array $properties = null) + { + if ($this->hasProperties()) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Get the related database table's name + * + * @return string + */ + abstract public function getTableName(); + + /** + * Get the column name(s) of the primary key + * + * @return string|array<string> Array if the primary key is compound, string otherwise + */ + abstract public function getKeyName(); + + /** + * Get the model's queryable columns + * + * @return array<int|string, string|ExpressionInterface> + */ + abstract public function getColumns(); + + /** + * Get the configured table alias. (Default {@see static::getTableName()}) + * + * @return string + */ + public function getTableAlias(): string + { + return $this->getTableName(); + } + + /** + * Get the model's column definitions + * + * The array is indexed by column names, values are either strings (labels) or arrays of this format: + * + * [ + * 'label' => 'A Column', + * 'type' => 'enum(y,n)' + * ] + * + * @return array + */ + public function getColumnDefinitions() + { + return []; + } + + /** + * Get a query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return Query + */ + public static function on(Connection $db) + { + return (new Query()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the model's default sort + * + * @return array|string + */ + public function getDefaultSort() + { + return []; + } + + /** + * Get the model's search columns + * + * @return array + */ + public function getSearchColumns() + { + return []; + } + + /** + * Create the model's behaviors + * + * @param Behaviors $behaviors + */ + public function createBehaviors(Behaviors $behaviors) + { + } + + /** + * Create the model's defaults + * + * @param Defaults $defaults + */ + public function createDefaults(Defaults $defaults) + { + } + + /** + * Create the model's relations + * + * If your model should be associated to other models, override this method and create the model's relations. + */ + public function createRelations(Relations $relations) + { + } + + /** + * Initialize the model + * + * If you want to adjust the model after construction, override this method. + */ + protected function init() + { + } +} diff --git a/vendor/ipl/orm/src/Query.php b/vendor/ipl/orm/src/Query.php new file mode 100644 index 0000000..0e19dd1 --- /dev/null +++ b/vendor/ipl/orm/src/Query.php @@ -0,0 +1,846 @@ +<?php + +namespace ipl\Orm; + +use ArrayObject; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Compat\FilterProcessor; +use ipl\Sql\Connection; +use ipl\Sql\ExpressionInterface; +use ipl\Sql\LimitOffset; +use ipl\Sql\LimitOffsetInterface; +use ipl\Sql\OrderBy; +use ipl\Sql\OrderByInterface; +use ipl\Sql\Select; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filters; +use IteratorAggregate; +use ReflectionClass; +use SplObjectStorage; +use Traversable; + +/** + * Represents a database query which is associated to a model and a database connection. + */ +class Query implements Filterable, LimitOffsetInterface, OrderByInterface, Paginatable, IteratorAggregate +{ + use Events; + use Filters; + use LimitOffset; + use OrderBy; + + /** + * Event raised after assembling a {@link Select} object for the query + * + * **Example usage:** + * + * ``` + * $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) { + * // ... + * }); + * ``` + */ + const ON_SELECT_ASSEMBLED = 'selectAssembled'; + + /** @var int Count cache */ + protected $count; + + /** @var Connection Database connection */ + protected $db; + + /** @var string Class to return results as */ + protected $resultSetClass = ResultSet::class; + + /** @var Model Model to query */ + protected $model; + + /** @var array Columns to select from the model (or its relations). If empty, all columns are selected */ + protected $columns = []; + + /** @var array Additional columns to select from the model (or its relations) */ + protected $withColumns = []; + + /** @var array Columns not to select from the model (or its relations) */ + protected $withoutColumns = []; + + /** @var bool Whether to peek ahead for more results */ + protected $peekAhead = false; + + /** @var Resolver Column and relation resolver */ + protected $resolver; + + /** @var Select Base SELECT query */ + protected $selectBase; + + /** @var Relation[] Relations to eager load */ + protected $with = []; + + /** @var Relation[] Relations to utilize (join) */ + protected $utilize = []; + + /** @var bool Whether to disable the default sorts of the model */ + protected $disableDefaultSort = false; + + /** + * Get the database connection + * + * @return Connection + */ + public function getDb() + { + return $this->db; + } + + /** + * Set the database connection + * + * @param Connection $db + * + * @return $this + */ + public function setDb(Connection $db) + { + $this->db = $db; + + return $this; + } + + /** + * Get the class to return results as + * + * @return string + */ + public function getResultSetClass() + { + return $this->resultSetClass; + } + + /** + * Set the class to return results as + * + * @param string $class + * + * @return $this + * + * @throws InvalidArgumentException If class is not an instance of {@link ResultSet} + */ + public function setResultSetClass($class) + { + if (! is_string($class)) { + throw new InvalidArgumentException('Argument $class must be a string'); + } + + if (! (new ReflectionClass($class))->newInstanceWithoutConstructor() instanceof ResultSet) { + throw new InvalidArgumentException( + $class . ' must be an instance of ' . ResultSet::class + ); + } + + $this->resultSetClass = $class; + + return $this; + } + + /** + * Get the model to query + * + * @return Model + */ + public function getModel() + { + return $this->model; + } + + /** + * Set the model to query + * + * @param $model + * + * @return $this + */ + public function setModel(Model $model) + { + $this->model = $model; + $this->getResolver()->setAlias($model, $model->getTableAlias()); + + return $this; + } + + /** + * Get the columns to select from the model + * + * @return array + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the filter of the query + * + * @param Filter\Chain $filter + * + * @return $this + */ + public function setFilter(Filter\Chain $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Disable default sorts + * + * Prevents the default sort rules of the source model from being used + * + * @param bool $disable + * + * @return $this + */ + public function disableDefaultSort($disable = true) + { + $this->disableDefaultSort = (bool) $disable; + + return $this; + } + + /** + * Get whether to not use the default sort rules of the source model + * + * @return bool + */ + public function defaultSortDisabled() + { + return $this->disableDefaultSort; + } + + + /** + * Set columns to select from the model (or its relations) + * + * By default, i.e. if you do not specify any columns, all columns of the model and + * any relation added via {@see with()} will be selected. + * Multiple calls to this method will overwrite the previously specified columns. + * If you specify columns from the model's relations, the relations are automatically joined upon querying. + * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}. + * Any previously column specified via {@see withColumns()} will not be selected if not part of the given columns. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function columns($columns) + { + $this->columns = (array) $columns; + $this->withColumns = []; + $this->withoutColumns = []; + + return $this; + } + + /** + * Set additional columns to select from the model (or its relations) + * + * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query. + * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function withColumns($columns) + { + $tableName = $this->getModel()->getTableAlias(); + + $qualifiedColumns = []; + foreach ((array) $columns as $alias => $column) { + if (! $column instanceof ExpressionInterface) { + $qualifiedColumns[$alias] = $this->getResolver()->qualifyPath($column, $tableName); + } else { + $qualifiedColumns[$alias] = $column; + } + } + + $this->withColumns = array_merge($this->withColumns, $qualifiedColumns); + $this->withoutColumns = array_diff($this->withoutColumns, array_filter($this->withColumns, 'is_string')); + + return $this; + } + + /** + * Set columns not to select from the model (or its relations) + * + * Multiple calls to this method will not overwrite the previous set columns but append the new ones to the set. + * + * @param string|string[] $columns The column(s) not to select + * + * @return $this + */ + public function withoutColumns($columns): self + { + $tableName = $this->getModel()->getTableAlias(); + + $qualifiedColumns = []; + foreach ((array) $columns as $column) { + if (is_string($column)) { + $qualifiedColumns[] = $this->getResolver()->qualifyPath($column, $tableName); + } + } + + $this->withoutColumns = array_merge($this->withoutColumns, $qualifiedColumns); + + return $this; + } + + /** + * Get the query's resolver + * + * @return Resolver + */ + public function getResolver() + { + if ($this->resolver === null) { + $this->resolver = new Resolver($this); + } + + return $this->resolver; + } + + /** + * Get the SELECT base query + * + * @return Select + */ + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + + $this->selectBase->from([ + $this->getResolver()->getAlias($this->getModel()) => $this->getModel()->getTableName() + ]); + } + + return $this->selectBase; + } + + /** + * Get the relations to eager load + * + * @return Relation[] + */ + public function getWith() + { + return $this->with; + } + + /** + * Add a relation to eager load + * + * @param string|array $relations + * + * @return $this + */ + public function with($relations) + { + $tableName = $this->getModel()->getTableAlias(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + $this->with[$relation] = $this->getResolver()->resolveRelation($relation); + } + + return $this; + } + + /** + * Remove an eager loaded relation + * + * @param string|array $relations + * + * @return $this + */ + public function without($relations) + { + $tableName = $this->getModel()->getTableAlias(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + unset($this->with[$relation]); + } + + return $this; + } + + /** + * Get utilized (joined) relations + * + * @return Relation[] + */ + public function getUtilize() + { + return $this->utilize; + } + + /** + * Add a relation to utilize (join) + * + * @param string $path + * + * @return $this + */ + public function utilize($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableAlias()); + $this->utilize[$path] = $this->getResolver()->resolveRelation($path); + + return $this; + } + + /** + * Remove a utilized (joined) relation + * + * @param string $path + * + * @return $this + */ + public function omit($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableAlias()); + unset($this->utilize[$path]); + + return $this; + } + + /** + * Assemble and return the SELECT query + * + * @return Select + */ + public function assembleSelect() + { + $columns = $this->getColumns(); + $model = $this->getModel(); + $resolver = $this->getResolver(); + $select = clone $this->getSelectBase(); + + if (empty($columns)) { + $columns = $resolver->getSelectColumns($model); + + foreach ($this->getWith() as $path => $relation) { + foreach ($resolver->getSelectColumns($relation->getTarget()) as $alias => $column) { + if (is_int($alias)) { + $columns[] = "$path.$column"; + } else { + $columns[] = "$path.$alias"; + } + } + } + + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($this->withColumns), 'is_string')); + } else { + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($columns), 'is_string')); + } + + $resolved = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($columns)); + $omitted = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($this->withoutColumns)); + foreach ($resolved as $target) { + $targetColumns = $resolved[$target]->getArrayCopy(); + if (isset($omitted[$target])) { + $targetColumns = array_diff($targetColumns, $omitted[$target]->getArrayCopy()); + } + + if (! empty($customAliases)) { + $customColumns = array_intersect_key($targetColumns, $customAliases); + $targetColumns = array_diff_key($targetColumns, $customAliases); + + $select->columns($resolver->qualifyColumns($customColumns, $target)); + } + + $select->columns( + $resolver->qualifyColumnsAndAliases( + $targetColumns, + $target, + $target !== $model + ) + ); + } + + $filter = clone $this->getFilter(); + FilterProcessor::resolveFilter($filter, $this); + $where = FilterProcessor::assembleFilter($filter); + if ($where) { + $select->where(...array_reverse($where)); + } + + $joinedRelations = []; + foreach ($this->getWith() + $this->getUtilize() as $path => $_) { + foreach ($resolver->resolveRelations($path) as $relationPath => $relation) { + if (isset($joinedRelations[$relationPath])) { + continue; + } + + foreach ($relation->resolve() as list($source, $target, $relatedKeys)) { + /** @var Model $source */ + /** @var Model $target */ + + $sourceAlias = $resolver->getAlias($source); + $targetAlias = $resolver->getAlias($target); + + $conditions = []; + foreach ($relatedKeys as $fk => $ck) { + $conditions[] = sprintf( + '%s = %s', + $resolver->qualifyColumn($fk, $targetAlias), + $resolver->qualifyColumn($ck, $sourceAlias) + ); + } + + $table = [$targetAlias => $target->getTableName()]; + + switch ($relation->getJoinType()) { + case 'LEFT': + $select->joinLeft($table, $conditions); + + break; + case 'RIGHT': + $select->joinRight($table, $conditions); + + break; + case 'INNER': + default: + $select->join($table, $conditions); + } + } + + $joinedRelations[$relationPath] = true; + } + } + + if ($this->hasLimit()) { + $limit = $this->getLimit(); + + if ($this->peekAhead) { + ++$limit; + } + + $select->limit($limit); + } + if ($this->hasOffset()) { + $select->offset($this->getOffset()); + } + + $this->order($select); + + $this->emit(static::ON_SELECT_ASSEMBLED, [$select]); + + return $select; + } + + /** + * Create and return the hydrator + * + * @return Hydrator + */ + public function createHydrator() + { + $hydrator = new Hydrator($this); + + $hydrator->add($this->getModel()->getTableAlias()); + foreach ($this->getWith() as $path => $_) { + $hydrator->add($path); + } + + return $hydrator; + } + + /** + * Derive a new query to load the specified relation from a concrete model + * + * @param string $relation + * @param Model $source + * + * @return static + * + * @throws InvalidArgumentException If the relation with the given name does not exist + */ + public function derive($relation, Model $source) + { + // TODO: Think of a way to merge derive() and createSubQuery() + return $this->createSubQuery( + $this->getResolver()->getRelations($source)->get($relation)->getTarget(), + $this->getResolver()->qualifyPath($relation, $source->getTableAlias()), + $source + ); + } + + /** + * Create a sub-query linked to rows of this query + * + * @param Model $target The model to query + * @param string $targetPath The target's absolute relation path + * @param ?Model $from The source model + * @param bool $link Whether the query should be linked to the parent query + * + * @return static + */ + public function createSubQuery(Model $target, $targetPath, Model $from = null, bool $link = true) + { + $subQuery = (new static()) + ->setDb($this->getDb()) + ->setModel($target); + + $sourceParts = array_reverse(explode('.', $targetPath)); + $sourceParts[0] = $target->getTableAlias(); + + $subQueryResolver = $subQuery->getResolver(); + $sourcePath = join('.', $sourceParts); + $subQueryTarget = $subQueryResolver->resolveRelation($sourcePath)->getTarget(); + + $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key + + if (! $link) { + return $subQuery->columns(array_map(function ($keyName) use ($sourcePath) { + return "$sourcePath.$keyName"; + }, (array) $subQueryTarget->getKeyName())); + } + + // TODO: Should be done by the caller. Though, that's not possible until we've got a filter abstraction + // which allows to post-pone filter column qualification. + $subQueryResolver->setAliasPrefix('sub_'); + + $resolver = $this->getResolver(); + $baseAlias = $resolver->getAlias($this->getModel()); + $sourceAlias = $subQueryResolver->getAlias($subQueryTarget); + + $subQueryConditions = []; + foreach ((array) $this->getModel()->getKeyName() as $column) { + $fk = $subQueryResolver->qualifyColumn($column, $sourceAlias); + + if (isset($from->$column)) { + $subQueryConditions["$fk = ?"] = $resolver + ->getBehaviors($from) + ->persistProperty($from->$column, $column); + } else { + $subQueryConditions[] = "$fk = " . $resolver->qualifyColumn($column, $baseAlias); + } + } + + $subQuery->getSelectBase()->where($subQueryConditions); + + return $subQuery; + } + + /** + * Dump the query + * + * @return array + */ + public function dump() + { + return $this->getDb()->getQueryBuilder()->assembleSelect($this->assembleSelect()); + } + + /** + * Execute the query + * + * @return ResultSet + */ + public function execute() + { + $class = $this->getResultSetClass(); + /** @var ResultSet $class Just for type hinting. $class is of course a string */ + + return $class::fromQuery($this); + } + + /** + * Fetch and return the first result + * + * @return Model|null Null in case there's no result + */ + public function first() + { + return $this->execute()->current(); + } + + /** + * Set whether to peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @param bool $peekAhead + * + * @return $this + */ + public function peekAhead($peekAhead = true) + { + $this->peekAhead = (bool) $peekAhead; + + return $this; + } + + /** + * Yield the query's results + * + * @return \Generator + */ + public function yieldResults() + { + $select = $this->assembleSelect(); + $stmt = $this->getDb()->select($select); + $stmt->setFetchMode(\PDO::FETCH_ASSOC); + + $hydrator = $this->createHydrator(); + $modelClass = get_class($this->getModel()); + + foreach ($stmt as $row) { + yield $hydrator->hydrate($row, new $modelClass()); + } + } + + public function count(): int + { + if ($this->count === null) { + $this->count = $this->getDb()->select($this->assembleSelect()->getCountQuery())->fetchColumn(0); + } + + return $this->count; + } + + public function getIterator(): Traversable + { + return $this->execute(); + } + + /** + * Group columns from {@link Resolver::requireAndResolveColumns()} by target models + * + * @param Generator $columns + * + * @return SplObjectStorage + */ + protected function groupColumnsByTarget(Generator $columns) + { + $columnStorage = new SplObjectStorage(); + + foreach ($columns as list($target, $alias, $column)) { + if (! $columnStorage->contains($target)) { + $resolved = new ArrayObject(); + $columnStorage->attach($target, $resolved); + } else { + $resolved = $columnStorage[$target]; + } + + if (is_int($alias)) { + $resolved[] = $column; + } else { + $resolved[$alias] = $column; + } + } + + return $columnStorage; + } + + /** + * Resolve, require and apply ORDER BY columns + * + * @param Select $select + * + * @return $this + */ + protected function order(Select $select) + { + $orderBy = $this->getOrderBy(); + $defaultSort = []; + if (! $this->defaultSortDisabled()) { + $defaultSort = (array) $this->getModel()->getDefaultSort(); + } + + if (empty($orderBy)) { + if (empty($defaultSort)) { + return $this; + } + + $orderBy = SortUtil::createOrderBy($defaultSort); + } + + $columns = []; + $directions = []; + $orderByResolved = []; + $resolver = $this->getResolver(); + $selectedColumns = $select->getColumns(); + + foreach ($orderBy as $part) { + list($column, $direction) = $part; + + if (! $column instanceof ExpressionInterface && isset($selectedColumns[$column])) { + // If it's a custom alias, we have no other way of knowing it, + // unless the caller explicitly uses it in the sort rule. + $orderByResolved[] = $part; + } else { + // Prepare flat ORDER BY column(s) and direction(s) for requireAndResolveColumns() + $columns[] = $column; + $directions[] = $direction; + } + } + + foreach ($resolver->requireAndResolveColumns($columns) as list($model, $alias, $column)) { + $direction = array_shift($directions); + $selectColumns = $resolver->getSelectColumns($model); + $tableName = $resolver->getAlias($model); + + if ($column instanceof ExpressionInterface) { + if (is_int($alias) && $column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif (is_string($alias) && $model !== $this->getModel()) { + $alias = $resolver->qualifyColumnAlias($alias, $tableName); + } elseif ($column instanceof ResolvedExpression) { + // We are doing this in an else if, since a resolved expression can't be an aliased + // expression at the same time and thus doesn't influence the functionality in any way. + $column->setColumns($resolver->qualifyColumns($column->getResolvedColumns())); + } + + if (is_string($alias) && isset($selectedColumns[$alias])) { + // An expression's alias can only be used if the expression is also selected + $column = $alias; + } + } else { + if (isset($selectColumns[$column])) { + $column = $selectColumns[$column]; + } + + if (is_string($column)) { + $column = $resolver->qualifyColumn($column, $tableName); + } + } + + $orderByResolved[] = [$column, $direction]; + } + + $select->orderBy($orderByResolved); + + return $this; + } + + public function __clone() + { + $this->resolver = clone $this->resolver; + + if ($this->filter !== null) { + $this->filter = clone $this->filter; + } + + if ($this->selectBase !== null) { + $this->selectBase = clone $this->selectBase; + } + } +} diff --git a/vendor/ipl/orm/src/Relation.php b/vendor/ipl/orm/src/Relation.php new file mode 100644 index 0000000..1959363 --- /dev/null +++ b/vendor/ipl/orm/src/Relation.php @@ -0,0 +1,336 @@ +<?php + +namespace ipl\Orm; + +use function ipl\Stdlib\get_php_type; + +/** + * Relations represent the connection between models, i.e. the association between rows in one or more tables + * on the basis of matching key columns. The relationships are defined using candidate key-foreign key constructs. + */ +class Relation +{ + /** @var string Name of the relation */ + protected $name; + + /** @var Model Source model */ + protected $source; + + /** @var string|array Column name(s) of the foreign key found in the target table */ + protected $foreignKey; + + /** @var string|array Column name(s) of the candidate key in the source table which references the foreign key */ + protected $candidateKey; + + /** @var string Target model class */ + protected $targetClass; + + /** @var Model Target model */ + protected $target; + + /** @var string Type of the JOIN used in the query */ + protected $joinType = 'INNER'; + + /** @var bool Whether this is the inverse of a relationship */ + protected $inverse; + + /** @var bool Whether this is a to-one relationship */ + protected $isOne = true; + + /** + * Get the default column name(s) in the source table used to match the foreign key + * + * The default candidate key is the primary key column name(s) of the given model. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultCandidateKey(Model $source) + { + return (array) $source->getKeyName(); + } + + /** + * Get the default column name(s) of the foreign key found in the target table + * + * The default foreign key is the given model's primary key column name(s) prefixed with its table name. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultForeignKey(Model $source) + { + $tableName = $source->getTableName(); + + return array_map( + function ($key) use ($tableName) { + return "{$tableName}_{$key}"; + }, + (array) $source->getKeyName() + ); + } + + /** + * Get whether this is a to-one relationship + * + * @return bool + */ + public function isOne() + { + return $this->isOne; + } + + /** + * Get the name of the relation + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the relation + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get the source model of the relation + * + * @return Model + */ + public function getSource() + { + return $this->source; + } + + /** + * Set the source model of the relation + * + * @param Model $source + * + * @return $this + */ + public function setSource(Model $source) + { + $this->source = $source; + + return $this; + } + + /** + * Get the column name(s) of the foreign key found in the target table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getForeignKey() + { + return $this->foreignKey; + } + + /** + * Set the column name(s) of the foreign key found in the target table + * + * @param string|array $foreignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setForeignKey($foreignKey) + { + $this->foreignKey = $foreignKey; + + return $this; + } + + /** + * Get the column name(s) of the candidate key in the source table which references the foreign key + * + * @return string|array Array if the candidate key is compound, string otherwise + */ + public function getCandidateKey() + { + return $this->candidateKey; + } + + /** + * Set the column name(s) of the candidate key in the source table which references the foreign key + * + * @param string|array $candidateKey Array if the candidate key is compound, string otherwise + * + * @return $this + */ + public function setCandidateKey($candidateKey) + { + $this->candidateKey = $candidateKey; + + return $this; + } + + /** + * Get the target model class + * + * @return string + */ + public function getTargetClass() + { + return $this->targetClass; + } + + /** + * Set the target model class + * + * @param string $targetClass + * + * @return $this + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function setTargetClass($targetClass) + { + if (! is_string($targetClass)) { + // Require a class name here instead of a concrete model in oder to prevent circular references when + // constructing relations + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be string, %s given', + __METHOD__, + get_php_type($targetClass) + )); + } + + $this->targetClass = $targetClass; + + return $this; + } + + /** + * Get the target model + * + * Returns the model from {@link setTarget()} or an instance of {@link getTargetClass()}. + * Note that multiple calls to this method always returns the very same model instance. + * + * @return Model + */ + public function getTarget() + { + if ($this->target === null) { + $targetClass = $this->getTargetClass(); + $this->target = new $targetClass(); + } + + return $this->target; + } + + /** + * Set the the target model + * + * @param Model $target + * + * @return $this + */ + public function setTarget(Model $target) + { + $this->target = $target; + + return $this; + } + + /** + * Get the type of the JOIN used in the query + * + * @return string + */ + public function getJoinType() + { + return $this->joinType; + } + + /** + * Set the type of the JOIN used in the query + * + * @param string $joinType + * + * @return Relation + */ + public function setJoinType($joinType) + { + $this->joinType = $joinType; + + return $this; + } + + /** + * Determine the candidate key-foreign key construct of the relation + * + * @param Model $source + * + * @return array Candidate key-foreign key column name pairs + * + * @throws \UnexpectedValueException If there's no candidate key to be found + * or the foreign key count does not match the candidate key count + */ + public function determineKeys(Model $source) + { + $candidateKey = (array) $this->getCandidateKey(); + + if (empty($candidateKey)) { + $candidateKey = $this->inverse + ? static::getDefaultForeignKey($this->getTarget()) + : static::getDefaultCandidateKey($source); + } + + if (empty($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'. No candidate key found.", + $this->getName(), + get_class($source) + )); + } + + $foreignKey = (array) $this->getForeignKey(); + + if (empty($foreignKey)) { + $foreignKey = $this->inverse + ? static::getDefaultCandidateKey($this->getTarget()) + : static::getDefaultForeignKey($source); + } + + if (count($foreignKey) !== count($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'." + . " Foreign key count (%s) does not match candidate key count (%s).", + $this->getName(), + get_class($source), + implode(', ', $foreignKey), + implode(', ', $candidateKey) + )); + } + + return array_combine($foreignKey, $candidateKey); + } + + /** + * Resolve the relation + * + * Yields a three-element array consisting of the source model, target model and the join keys. + * + * @return \Generator + */ + public function resolve() + { + $source = $this->getSource(); + + yield [$source, $this->getTarget(), $this->determineKeys($source)]; + } +} diff --git a/vendor/ipl/orm/src/Relation/BelongsTo.php b/vendor/ipl/orm/src/Relation/BelongsTo.php new file mode 100644 index 0000000..1edcff3 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsTo.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * Inverse of a one-to-one or one-to-many relationship + */ +class BelongsTo extends Relation +{ + protected $inverse = true; +} diff --git a/vendor/ipl/orm/src/Relation/BelongsToMany.php b/vendor/ipl/orm/src/Relation/BelongsToMany.php new file mode 100644 index 0000000..aad4f03 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsToMany.php @@ -0,0 +1,211 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; +use ipl\Orm\Relation; +use ipl\Orm\Relations; +use LogicException; + +use function ipl\Stdlib\get_php_type; + +/** + * Many-to-many relationship + */ +class BelongsToMany extends Relation +{ + /** @var string Relation class */ + protected const RELATION_CLASS = HasMany::class; + + protected $isOne = false; + + /** @var string Name of the join table or junction model class */ + protected $throughClass; + + /** @var Model The junction model */ + protected $through; + + /** @var string|array Column name(s) of the target model's foreign key found in the join table */ + protected $targetForeignKey; + + /** @var string|array Candidate key column name(s) in the target table which references the target foreign key */ + protected $targetCandidateKey; + + /** + * Get the name of the join table or junction model class + * + * @return ?string + */ + public function getThroughClass(): ?string + { + return $this->throughClass; + } + + /** + * Set the join table name or junction model class + * + * @param string $through + * + * @return $this + */ + public function through(string $through): self + { + $this->throughClass = $through; + + return $this; + } + + /** + * Get the junction model + * + * @return Model|Junction + */ + public function getThrough(): Model + { + if ($this->through === null) { + $throughClass = $this->getThroughClass(); + if ($throughClass === null) { + throw new LogicException( + 'You cannot use a many-to-many relation without a through class or a table name for the' + . ' junction model' + ); + } + + if (class_exists($throughClass)) { + $this->through = new $throughClass(); + } else { + $this->through = (new Junction()) + ->setTableName($throughClass); + } + } + + return $this->through; + } + + /** + * Set the junction model + * + * @param Model $through + * + * @return $this + */ + public function setThrough(Model $through): self + { + $this->through = $through; + + return $this; + } + + /** + * Get the column name(s) of the target model's foreign key found in the join table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetForeignKey() + { + return $this->targetForeignKey; + } + + /** + * Set the column name(s) of the target model's foreign key found in the join table + * + * @param string|array $targetForeignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetForeignKey($targetForeignKey): self + { + $this->targetForeignKey = $targetForeignKey; + + return $this; + } + + /** + * Get the candidate key column name(s) in the target table which references the target foreign key + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetCandidateKey() + { + return $this->targetCandidateKey; + } + + /** + * Set the candidate key column name(s) in the target table which references the target foreign key + * + * @param string|array $targetCandidateKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetCandidateKey($targetCandidateKey): self + { + $this->targetCandidateKey = $targetCandidateKey; + + return $this; + } + + public function resolve() + { + $source = $this->getSource(); + + $possibleCandidateKey = [$this->getCandidateKey()]; + $possibleForeignKey = [$this->getForeignKey()]; + + $target = $this->getTarget(); + + $possibleTargetCandidateKey = [$this->getTargetForeignKey() ?: static::getDefaultForeignKey($target)]; + $possibleTargetForeignKey = [$this->getTargetCandidateKey() ?: static::getDefaultCandidateKey($target)]; + + $junction = $this->getThrough(); + + if (! $junction instanceof Junction) { + $relations = new Relations(); + $junction->createRelations($relations); + + if ($relations->has($source->getTableAlias())) { + $sourceRelation = $relations->get($source->getTableAlias()); + + $possibleCandidateKey[] = $sourceRelation->getForeignKey(); + $possibleForeignKey[] = $sourceRelation->getCandidateKey(); + } + + if ($relations->has($target->getTableAlias())) { + $targetRelation = $relations->get($target->getTableAlias()); + + $possibleTargetCandidateKey[] = $targetRelation->getCandidateKey(); + $possibleTargetForeignKey[] = $targetRelation->getForeignKey(); + } + } + + $junctionClass = static::RELATION_CLASS; + $toJunction = (new $junctionClass()) + ->setName($junction->getTableAlias()) + ->setSource($source) + ->setTarget($junction) + ->setCandidateKey($this->extractKey($possibleCandidateKey)) + ->setForeignKey($this->extractKey($possibleForeignKey)); + + $targetClass = static::RELATION_CLASS; + $toTarget = (new $targetClass()) + ->setName($this->getName()) + ->setSource($junction) + ->setTarget($target) + ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) + ->setForeignKey($this->extractKey($possibleTargetForeignKey)); + + foreach ($toJunction->resolve() as $k => $v) { + yield $k => $v; + } + + foreach ($toTarget->resolve() as $k => $v) { + yield $k => $v; + } + } + + protected function extractKey(array $possibleKey) + { + $filtered = array_filter($possibleKey); + + return array_pop($filtered); + } +} diff --git a/vendor/ipl/orm/src/Relation/BelongsToOne.php b/vendor/ipl/orm/src/Relation/BelongsToOne.php new file mode 100644 index 0000000..afdfbec --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsToOne.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +/** + * One-to-one relationship with a junction table + */ +class BelongsToOne extends BelongsToMany +{ + protected const RELATION_CLASS = HasOne::class; + + protected $isOne = true; +} diff --git a/vendor/ipl/orm/src/Relation/HasMany.php b/vendor/ipl/orm/src/Relation/HasMany.php new file mode 100644 index 0000000..3e71e25 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasMany.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-many relationship + */ +class HasMany extends Relation +{ + protected $isOne = false; +} diff --git a/vendor/ipl/orm/src/Relation/HasOne.php b/vendor/ipl/orm/src/Relation/HasOne.php new file mode 100644 index 0000000..8f7a802 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasOne.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-one relationship + */ +class HasOne extends Relation +{ +} diff --git a/vendor/ipl/orm/src/Relation/Junction.php b/vendor/ipl/orm/src/Relation/Junction.php new file mode 100644 index 0000000..1f676c4 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/Junction.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; + +/** + * Junction model for many-to-many relations + */ +class Junction extends Model +{ + /** @var string */ + protected $tableName; + + public function getTableName() + { + return $this->tableName; + } + + /** + * Set the table name + * + * @param string $tableName + * + * @return $this + */ + public function setTableName($tableName) + { + $this->tableName = $tableName; + + return $this; + } + + public function getKeyName() + { + return []; + } + + public function getColumns() + { + return []; + } +} diff --git a/vendor/ipl/orm/src/Relations.php b/vendor/ipl/orm/src/Relations.php new file mode 100644 index 0000000..e19306e --- /dev/null +++ b/vendor/ipl/orm/src/Relations.php @@ -0,0 +1,235 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsTo; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\BelongsToOne; +use ipl\Orm\Relation\HasMany; +use ipl\Orm\Relation\HasOne; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * Collection of a model's relations. + */ +class Relations implements IteratorAggregate +{ + /** @var Relation[] */ + protected $relations = []; + + /** + * Get whether a relation with the given name exists + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return isset($this->relations[$name]); + } + + /** + * Get the relation with the given name + * + * @param string $name + * + * @return Relation + * + * @throws \InvalidArgumentException If the relation with the given name does not exist + */ + public function get($name) + { + $this->assertRelationExists($name); + + return $this->relations[$name]; + } + + /** + * Add the given relation to the collection + * + * @param Relation $relation + * + * @return $this + * + * @throws \InvalidArgumentException If a relation with the given name already exists + */ + public function add(Relation $relation) + { + $name = $relation->getName(); + + $this->assertRelationDoesNotYetExist($name); + + $this->relations[$name] = $relation; + + return $this; + } + + /** + * Create a new relation from the given class, name and target model class + * + * @param string $class Class of the relation to create + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo|BelongsToOne|BelongsToMany|HasMany|HasOne|Relation + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function create($class, $name, $targetClass) + { + $relation = new $class(); + + if (! $relation instanceof Relation) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a subclass of %s, %s given', + __METHOD__, + Relation::class, + get_php_type($relation) + )); + } + + // Test target model + $target = new $targetClass(); + if (! $target instanceof Model) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 3 to be a subclass of %s, %s given', + __METHOD__, + Model::class, + get_php_type($target) + )); + } + + /** @var Relation $relation */ + $relation + ->setName($name) + ->setTarget($target) + ->setTargetClass($targetClass); + + return $relation; + } + + /** + * Define a one-to-one relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasOne + */ + public function hasOne($name, $targetClass) + { + /** @var HasOne $relation */ + $relation = $this->create(HasOne::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasMany + */ + public function hasMany($name, $targetClass) + { + /** @var HasMany $relation */ + $relation = $this->create(HasMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define the inverse of a one-to-one or one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo + */ + public function belongsTo($name, $targetClass) + { + /** @var BelongsTo $relation */ + $relation = $this->create(BelongsTo::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a one-to-one relationship with a junction table + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsToOne + */ + public function belongsToOne(string $name, string $targetClass): BelongsToOne + { + /** @var BelongsToOne $relation */ + $relation = $this->create(BelongsToOne::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a many-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsToMany + */ + public function belongsToMany($name, $targetClass) + { + /** @var BelongsToMany $relation */ + $relation = $this->create(BelongsToMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->relations); + } + + /** + * Throw exception if a relation with the given name already exists + * + * @param string $name + */ + protected function assertRelationDoesNotYetExist($name) + { + if ($this->has($name)) { + throw new \InvalidArgumentException("Relation '$name' already exists"); + } + } + + /** + * Throw exception if a relation with the given name does not exist + * + * @param string $name + */ + protected function assertRelationExists($name) + { + if (! $this->has($name)) { + throw new InvalidRelationException($name); + } + } +} diff --git a/vendor/ipl/orm/src/ResolvedExpression.php b/vendor/ipl/orm/src/ResolvedExpression.php new file mode 100644 index 0000000..86883da --- /dev/null +++ b/vendor/ipl/orm/src/ResolvedExpression.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Orm; + +use Generator; +use ipl\Sql\Expression; +use ipl\Sql\ExpressionInterface; +use RuntimeException; + +class ResolvedExpression extends Expression +{ + /** @var Generator */ + protected $resolvedColumns; + + /** + * Create a resolved database expression + * + * @param ExpressionInterface $expr The original expression + * @param Generator $resolvedColumns The generator as returned by {@see Resolver::requireAndResolveColumns()} + */ + public function __construct(ExpressionInterface $expr, Generator $resolvedColumns) + { + parent::__construct($expr->getStatement(), $expr->getColumns(), ...$expr->getValues()); + + $this->resolvedColumns = $resolvedColumns; + } + + /** + * @throws RuntimeException In case the columns are not qualified yet + */ + public function getColumns() + { + if ($this->resolvedColumns->valid()) { + throw new RuntimeException('Columns are not yet qualified'); + } + + return parent::getColumns(); + } + + /** + * Get the resolved column generator + * + * @return Generator + */ + public function getResolvedColumns() + { + return $this->resolvedColumns; + } +} diff --git a/vendor/ipl/orm/src/Resolver.php b/vendor/ipl/orm/src/Resolver.php new file mode 100644 index 0000000..a3b99b3 --- /dev/null +++ b/vendor/ipl/orm/src/Resolver.php @@ -0,0 +1,803 @@ +<?php + +namespace ipl\Orm; + +use AppendIterator; +use ArrayIterator; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\BelongsToOne; +use ipl\Sql\ExpressionInterface; +use LogicException; +use OutOfBoundsException; +use SplObjectStorage; + +use function ipl\Stdlib\get_php_type; + +/** + * Column and relation resolver. Acts as glue between queries and models + */ +class Resolver +{ + /** @var Query The query to resolve */ + protected $query; + + /** @var SplObjectStorage Model relations */ + protected $relations; + + /** @var SplObjectStorage Model behaviors */ + protected $behaviors; + + /** @var SplObjectStorage Model defaults */ + protected $defaults; + + /** @var SplObjectStorage Model aliases */ + protected $aliases; + + /** @var string The alias prefix to use */ + protected $aliasPrefix; + + /** @var SplObjectStorage Selectable columns from resolved models */ + protected $selectableColumns; + + /** @var SplObjectStorage Select columns from resolved models */ + protected $selectColumns; + + /** @var SplObjectStorage Meta data from models and their direct relations */ + protected $metaData; + + /** @var SplObjectStorage Resolved relations */ + protected $resolvedRelations; + + /** + * Create a new resolver + * + * @param Query $query The query to resolve + */ + public function __construct(Query $query) + { + $this->query = $query; + + $this->relations = new SplObjectStorage(); + $this->behaviors = new SplObjectStorage(); + $this->defaults = new SplObjectStorage(); + $this->aliases = new SplObjectStorage(); + $this->selectableColumns = new SplObjectStorage(); + $this->selectColumns = new SplObjectStorage(); + $this->metaData = new SplObjectStorage(); + $this->resolvedRelations = new SplObjectStorage(); + } + + /** + * Get a model's relations + * + * @param Model $model + * + * @return Relations + */ + public function getRelations(Model $model) + { + if (! $this->relations->contains($model)) { + $relations = new Relations(); + $model->createRelations($relations); + $this->relations->attach($model, $relations); + } + + return $this->relations[$model]; + } + + /** + * Get a model's behaviors + * + * @param Model $model + * + * @return Behaviors + */ + public function getBehaviors(Model $model) + { + if (! $this->behaviors->contains($model)) { + $behaviors = new Behaviors(); + $model->createBehaviors($behaviors); + $this->behaviors->attach($model, $behaviors); + + foreach ($behaviors as $behavior) { + if ($behavior instanceof QueryAwareBehavior) { + $behavior->setQuery($this->query); + } + } + } + + return $this->behaviors[$model]; + } + + /** + * Get a model's defaults + * + * @param Model $model + * + * @return Defaults + */ + public function getDefaults(Model $model): Defaults + { + if (! $this->defaults->contains($model)) { + $defaults = new Defaults(); + $model->createDefaults($defaults); + $this->defaults->attach($model, $defaults); + } + + return $this->defaults[$model]; + } + + /** + * Get a model alias + * + * @param Model $model + * + * @return string + * + * @throws OutOfBoundsException If no alias exists for the given model + */ + public function getAlias(Model $model) + { + if (! $this->aliases->contains($model)) { + throw new OutOfBoundsException(sprintf( + "Can't get alias for model '%s'. Alias does not exist", + get_class($model) + )); + } + + return $this->aliasPrefix . $this->aliases[$model]; + } + + /** + * Set a model alias + * + * @param Model $model + * @param string $alias + * + * @return $this + */ + public function setAlias(Model $model, $alias) + { + $this->aliases[$model] = $alias; + + return $this; + } + + /** + * Get the alias prefix + * + * @return string + */ + public function getAliasPrefix() + { + return $this->aliasPrefix; + } + + /** + * Set the alias prefix + * + * @param string $alias + * + * @return $this + */ + public function setAliasPrefix($alias) + { + $this->aliasPrefix = $alias; + + return $this; + } + + /** + * Get whether the specified model provides the given selectable column + * + * @param Model $subject + * @param string $column + * + * @return bool + */ + public function hasSelectableColumn(Model $subject, $column) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + $columns = $this->selectableColumns[$subject]; + if (! isset($columns[$column])) { + $columns[$column] = $this->getBehaviors($subject)->isSelectableColumn($column); + } + + return $columns[$column]; + } + + /** + * Get all selectable columns from the given model + * + * @param Model $subject + * + * @return array + */ + public function getSelectableColumns(Model $subject) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return array_keys($this->selectableColumns[$subject]); + } + + /** + * Get all select columns from the given model + * + * @param Model $subject + * + * @return array Select columns suitable for {@link \ipl\Sql\Select::columns()} + */ + public function getSelectColumns(Model $subject) + { + if (! $this->selectColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return $this->selectColumns[$subject]; + } + + /** + * Get all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array Column paths as keys (relative to $subject) and their meta data as values + */ + public function getColumnDefinitions(Model $subject) + { + if (! $this->metaData->contains($subject)) { + $this->metaData->attach($subject, $this->collectMetaData($subject)); + } + + return $this->metaData[$subject]; + } + + /** + * Get definition of the given column + * + * @param string $columnPath + * + * @return ColumnDefinition + */ + public function getColumnDefinition(string $columnPath): ColumnDefinition + { + $parts = explode('.', $columnPath); + $model = $this->query->getModel(); + + if ($parts[0] !== $model->getTableAlias()) { + array_unshift($parts, $model->getTableAlias()); + } + + do { + $relationPath[] = array_shift($parts); + $column = implode('.', $parts); + + if (count($relationPath) === 1) { + $subject = $model; + } else { + $subject = $this->resolveRelation(implode('.', $relationPath))->getTarget(); + } + + if ($this->hasSelectableColumn($subject, $column)) { + break; + } + } while ($parts); + + $definition = $this->getColumnDefinitions($subject)[$column] ?? new ColumnDefinition($column); + $this->getBehaviors($subject)->rewriteColumnDefinition($definition, implode('.', $relationPath)); + + return $definition; + } + + /** + * Qualify the given alias by the specified table name + * + * @param string $alias + * @param string $tableName + * + * @return string + */ + public function qualifyColumnAlias($alias, $tableName) + { + return $tableName . '_' . $alias; + } + + /** + * Qualify the given column by the specified table name + * + * @param string $column + * @param string $tableName + * + * @return string + */ + public function qualifyColumn($column, $tableName) + { + return $tableName . '.' . $column; + } + + /** + * Qualify the given columns by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumns($columns, Model $model = null) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given columns and aliases by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * @param bool $autoAlias Set an alias for columns which have none + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumnsAndAliases($columns, Model $model = null, $autoAlias = true) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if (is_int($alias)) { + if ($column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif ($autoAlias && ! $column instanceof ExpressionInterface) { + $alias = $this->qualifyColumnAlias($column, $targetAlias); + } + } elseif ($target !== $this->query->getModel()) { + if (strpos($alias, '.') !== false) { + // This is safe, because custom aliases won't be qualified + $alias = str_replace('.', '_', $alias); + } else { + $alias = $this->qualifyColumnAlias($alias, $targetAlias); + } + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given path by the specified table name + * + * @param string $path + * @param string $tableName + * + * @return string + */ + public function qualifyPath($path, $tableName) + { + $segments = explode('.', $path, 2); + + if ($segments[0] !== $tableName) { + array_unshift($segments, $tableName); + } + + $path = implode('.', $segments); + + return $path; + } + + /** + * Get whether the given relation path points to a distinct entity + * + * @param string $path + * + * @return bool + */ + public function isDistinctRelation($path) + { + foreach ($this->resolveRelations($path) as $relation) { + if (! $relation->isOne()) { + return false; + } + } + + return true; + } + + /** + * Resolve the rightmost relation of the given path + * + * Also resolves all other relations. + * + * @param string $path + * @param Model $subject + * + * @return Relation + */ + public function resolveRelation($path, Model $subject = null) + { + $subject = $subject ?: $this->query->getModel(); + if (! $this->resolvedRelations->contains($subject) || ! isset($this->resolvedRelations[$subject][$path])) { + foreach ($this->resolveRelations($path, $subject) as $_) { + // run and exhaust generator + } + } + + return $this->resolvedRelations[$subject][$path]; + } + + /** + * Resolve all relations of the given path + * + * Traverses the entire path and yields the path travelled so far as key and the relation as value. + * + * @param string $path + * @param Model $subject + * + * @return Generator + * @throws InvalidArgumentException In case $path is not fully qualified + * @throws InvalidRelationException In case a relation is unknown + */ + public function resolveRelations($path, Model $subject = null) + { + $relations = explode('.', $path); + $subject = $subject ?: $this->query->getModel(); + + if ($relations[0] !== $subject->getTableAlias()) { + throw new InvalidArgumentException(sprintf( + 'Cannot resolve relation path "%s". Base table alias/name is missing.', + $path + )); + } + + $resolvedRelations = []; + if ($this->resolvedRelations->contains($subject)) { + $resolvedRelations = $this->resolvedRelations[$subject]; + } + + $target = $subject; + $pathBeingResolved = null; + $relation = null; + $segments = [array_shift($relations)]; + while (! empty($relations)) { + $newPath = $this->getBehaviors($target) + ->rewritePath(join('.', $relations), join('.', $segments)); + if ($newPath !== null) { + $relations = explode('.', $newPath); + $pathBeingResolved = $path; + } + + $relationName = array_shift($relations); + $segments[] = $relationName; + $relationPath = join('.', $segments); + + if (isset($resolvedRelations[$relationPath])) { + $relation = $resolvedRelations[$relationPath]; + } else { + $targetRelations = $this->getRelations($target); + if (! $targetRelations->has($relationName)) { + throw new InvalidRelationException($relationName, $target); + } + + $relation = $targetRelations->get($relationName); + $relation->setSource($target); + + $resolvedRelations[$relationPath] = $relation; + + if ($relation instanceof BelongsToMany) { + $through = $relation->getThrough(); + $this->setAlias($through, join('_', array_merge( + array_slice($segments, 0, -1), + [$through->getTableAlias()] + ))); + } + + $this->setAlias($relation->getTarget(), join('_', $segments)); + } + + yield $relationPath => $relation; + + $target = $relation->getTarget(); + } + + if ($pathBeingResolved !== null) { + $resolvedRelations[$pathBeingResolved] = $relation; + } + + $this->resolvedRelations->attach($subject, $resolvedRelations); + } + + /** + * Require and resolve columns + * + * Related models will be automatically added for eager-loading. + * + * @param array $columns + * @param Model $model + * + * @return Generator + * + * @throws InvalidColumnException If a column does not exist + */ + public function requireAndResolveColumns(array $columns, Model $model = null) + { + $model = $model ?: $this->query->getModel(); + $tableName = $model->getTableAlias(); + + $baseTableColumns = []; + foreach ($columns as $alias => $column) { + $columnPath = &$column; + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $model) + ); + + if (is_int($alias)) { + // Scalar queries and such + yield [$model, $alias, $column]; + + continue; + } + + $columnPath = &$alias; + } elseif ($column === '*') { + yield [$model, $alias, $column]; + + continue; + } + + $dot = strrpos($columnPath, '.'); + + switch (true) { + /** @noinspection PhpMissingBreakStatementInspection */ + case $dot !== false: + $relationPath = null; + $hydrationPath = substr($columnPath, 0, $dot); + $columnPath = substr($columnPath, $dot + 1); // Updates also $column or $alias + + if ($hydrationPath !== $tableName) { + $relation = null; + $hydrationPath = $this->qualifyPath($hydrationPath, $tableName); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$tableName => null])); + $relations->append($this->resolveRelations($hydrationPath)); + foreach ($relations as $relationPath => $relation) { + if ($column instanceof ExpressionInterface) { + continue; + } + + if ($relationPath === $tableName) { + $subject = $model; + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $columnName = $columnPath; + if ($relationPath !== $hydrationPath) { + // It's still an intermediate relation, not the target + $columnName = substr($hydrationPath, strlen($relationPath) + 1) . ".$columnName"; + } + + $newColumn = $this->getBehaviors($subject)->rewriteColumn($columnName, $relationPath); + if ($newColumn !== null) { + if ($newColumn instanceof ExpressionInterface) { + $column = $newColumn; + $target = $subject; + break 2; // Expressions don't need to be *withed* and get no automatic alias either + } + + $column = $newColumn; + break; + } + } + + if (is_int($alias) && $relationPath !== $hydrationPath) { + // If the actual relation is resolved differently, + // ensure the hydration path is not an unexpected one + $alias = "$hydrationPath.$column"; + } + + $this->query->with($hydrationPath); + $target = $relation->getTarget(); + + break; + } + // Move to default + default: + $relationPath = null; + $hydrationPath = null; + $target = $model; + + if (! $column instanceof ExpressionInterface) { + $column = $this->getBehaviors($target)->rewriteColumn($column) ?: $column; + } + + if (is_int($alias) && ! $column instanceof AliasedExpression) { + if (! isset($baseTableColumns[$columnPath])) { + $baseTableColumns[$columnPath] = true; + } else { + // Don't yield base table columns multiple times. + // Duplicate columns without an alias may lead to SQL errors + continue 2; + } + } + } + + if (! $column instanceof ExpressionInterface) { + $targetColumns = $target->getColumns(); + if (isset($targetColumns[$column])) { + // $column is actually an alias + $alias = is_string($alias) ? $alias : ($relationPath ? "$hydrationPath.$column" : $column); + $column = $targetColumns[$column]; + + if ($column instanceof ExpressionInterface) { + $qualifier = $relationPath ? "$hydrationPath." : ''; + + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns(array_map(function ($c) use ($qualifier) { + return $qualifier . $c; + }, $column->getColumns()), $model) + ); + } + } + } + + if (! $column instanceof ExpressionInterface && ! $this->hasSelectableColumn($target, $columnPath)) { + throw new InvalidColumnException($columnPath, $target); + } + + yield [$target, $alias, $column]; + } + } + + /** + * Collect all selectable columns from the given model + * + * @param Model $subject + */ + protected function collectColumns(Model $subject) + { + // Don't fail if Model::getColumns() also contains the primary key columns + $columns = array_merge((array) $subject->getKeyName(), (array) $subject->getColumns()); + + $this->selectColumns->attach($subject, $columns); + + $selectable = []; + + foreach ($columns as $alias => $column) { + if (is_string($alias)) { + $selectable[$alias] = true; + } + + if (is_string($column)) { + $selectable[$column] = true; + } + } + + $this->selectableColumns->attach($subject, $selectable); + } + + /** + * Collect all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array + */ + protected function collectMetaData(Model $subject) + { + $definitions = []; + foreach ($subject->getColumnDefinitions() as $name => $data) { + if ($data instanceof ColumnDefinition) { + $definition = $data; + } else { + if (is_string($data)) { + $data = ['name' => $name, 'label' => $data]; + } elseif (! isset($data[$name])) { + $data['name'] = $name; + } + + $definition = ColumnDefinition::fromArray($data); + } + + if (is_string($name) && $definition->getName() !== $name) { + throw new LogicException(sprintf( + 'Model %s provides a column definition with a different name (%s) than the index (%s)', + get_class($subject), + $definition->getName(), + $name + )); + } + + $definitions[$name] = $definition; + } + + return $definitions; + } +} diff --git a/vendor/ipl/orm/src/ResultSet.php b/vendor/ipl/orm/src/ResultSet.php new file mode 100644 index 0000000..05117a5 --- /dev/null +++ b/vendor/ipl/orm/src/ResultSet.php @@ -0,0 +1,146 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use Iterator; +use Traversable; + +class ResultSet implements Iterator +{ + protected $cache; + + /** @var bool Whether cache is disabled */ + protected $isCacheDisabled = false; + + protected $generator; + + protected $limit; + + protected $position; + + public function __construct(Traversable $traversable, $limit = null) + { + $this->cache = new ArrayIterator(); + $this->generator = $this->yieldTraversable($traversable); + $this->limit = $limit; + } + + /** + * Create a new result set from the given query + * + * @param Query $query + * + * @return static + */ + public static function fromQuery(Query $query) + { + return new static($query->yieldResults(), $query->getLimit()); + } + + /** + * Do not cache query result + * + * ResultSet instance can only be iterated once + * + * @return $this + */ + public function disableCache() + { + $this->isCacheDisabled = true; + + return $this; + } + + public function hasMore() + { + return $this->generator->valid(); + } + + public function hasResult() + { + return $this->generator->valid(); + } + + #[\ReturnTypeWillChange] + public function current() + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->current() : $this->cache->current(); + } + + public function next(): void + { + if (! $this->isCacheDisabled) { + $this->cache->next(); + } + + if ($this->isCacheDisabled || ! $this->cache->valid()) { + $this->generator->next(); + $this->advance(); + } else { + $this->position += 1; + } + } + + public function key(): int + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->key() : $this->cache->key(); + } + + public function valid(): bool + { + if ($this->limit !== null && $this->position === $this->limit) { + return false; + } + + return $this->cache->valid() || $this->generator->valid(); + } + + public function rewind(): void + { + if (! $this->isCacheDisabled) { + $this->cache->rewind(); + } + + if ($this->position === null) { + $this->advance(); + } else { + $this->position = 0; + } + } + + protected function advance() + { + if (! $this->generator->valid()) { + return; + } + + if (! $this->isCacheDisabled) { + $this->cache[$this->generator->key()] = $this->generator->current(); + + // Only required on PHP 5.6, 7+ does it automatically + $this->cache->seek($this->generator->key()); + } + + if ($this->position === null) { + $this->position = 0; + } else { + $this->position += 1; + } + } + + protected function yieldTraversable(Traversable $traversable) + { + foreach ($traversable as $key => $value) { + yield $key => $value; + } + } +} diff --git a/vendor/ipl/orm/src/UnionModel.php b/vendor/ipl/orm/src/UnionModel.php new file mode 100644 index 0000000..5373bb9 --- /dev/null +++ b/vendor/ipl/orm/src/UnionModel.php @@ -0,0 +1,29 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Connection; + +abstract class UnionModel extends Model +{ + /** + * Get a UNION query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return UnionQuery + */ + public static function on(Connection $db) + { + return (new UnionQuery()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the UNION models and columns + * + * @return array + */ + abstract public function getUnions(); +} diff --git a/vendor/ipl/orm/src/UnionQuery.php b/vendor/ipl/orm/src/UnionQuery.php new file mode 100644 index 0000000..6f3823d --- /dev/null +++ b/vendor/ipl/orm/src/UnionQuery.php @@ -0,0 +1,61 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Select; + +class UnionQuery extends Query +{ + /** @var Query[] Underlying queries */ + private $unions; + + /** + * Get the underlying queries + * + * @return Query[] + */ + public function getUnions() + { + if ($this->unions === null) { + $this->unions = []; + + /** @var UnionModel $model */ + $model = $this->getModel(); + foreach ($model->getUnions() as list($target, $relations, $columns)) { + $query = (new Query()) + ->setDb($this->getDb()) + ->setModel(new $target()) + ->columns($columns) + ->disableDefaultSort() + ->with($relations); + + $this->unions[] = $query; + } + } + + return $this->unions; + } + + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + } + + $union = new Select(); + + foreach ($this->getUnions() as $query) { + $select = $query->assembleSelect(); + $columns = $select->getColumns(); + $select->resetColumns(); + ksort($columns); + $select->columns($columns); + + $union->unionAll($select); + } + + $this->selectBase->from([$this->getModel()->getTableName() => $union]); + + return $this->selectBase; + } +} diff --git a/vendor/ipl/scheduler/composer.json b/vendor/ipl/scheduler/composer.json new file mode 100644 index 0000000..5431be8 --- /dev/null +++ b/vendor/ipl/scheduler/composer.json @@ -0,0 +1,39 @@ +{ + "name": "ipl/scheduler", + "type": "library", + "description": "Icinga PHP Library - Tasks scheduler", + "keywords": ["task", "job", "scheduler", "cron"], + "homepage": "https://github.com/Icinga/ipl-scheduler", + "license": "MIT", + "config": { + "sort-packages": true + }, + "require": { + "php": ">=7.2", + "ext-json": "*", + "dragonmantank/cron-expression": "^3", + "psr/log": "^1", + "ramsey/uuid": "^4.2.3", + "react/event-loop": "^1.4", + "react/promise": "^2.10", + "simshaun/recurr": "^5", + "ipl/stdlib": ">=0.12.0" + }, + "require-dev": { + "ipl/stdlib": "dev-main" + }, + "suggest": { + "ext-ev": "Improves performance, efficiency and avoids system limitations. Highly recommended! (See https://www.php.net/manual/en/intro.ev.php for details)" + }, + "autoload": { + "files": ["src/register_cron_aliases.php"], + "psr-4": { + "ipl\\Scheduler\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Scheduler\\": "tests" + } + } +} diff --git a/vendor/ipl/scheduler/src/Common/Promises.php b/vendor/ipl/scheduler/src/Common/Promises.php new file mode 100644 index 0000000..b896627 --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/Promises.php @@ -0,0 +1,108 @@ +<?php + +namespace ipl\Scheduler\Common; + +use ArrayObject; +use InvalidArgumentException; +use Ramsey\Uuid\UuidInterface; +use React\Promise\PromiseInterface; +use SplObjectStorage; + +trait Promises +{ + /** @var SplObjectStorage<UuidInterface, ArrayObject<int, PromiseInterface>> */ + protected $promises; + + /** + * Add the given promise for the specified UUID + * + * **Example Usage:** + * + * ```php + * $promise = work(); + * $promises->addPromise($uuid, $promise); + * ``` + * + * @param UuidInterface $uuid + * @param PromiseInterface $promise + * + * @return $this + */ + protected function addPromise(UuidInterface $uuid, PromiseInterface $promise): self + { + if (! $this->promises->contains($uuid)) { + $this->promises->attach($uuid, new ArrayObject()); + } + + $this->promises[$uuid][] = $promise; + + return $this; + } + + /** + * Remove the given promise for the specified UUID + * + * **Example Usage:** + * + * ```php + * $promise->always(function () use ($uuid, $promise) { + * $promises->removePromise($uuid, $promise); + * }) + * ``` + * + * @param UuidInterface $uuid + * @param PromiseInterface $promise + * + * @return $this + * + * @throws InvalidArgumentException If the given UUID doesn't have any registered promises or when the specified + * UUID promises doesn't contain the provided promise + */ + protected function removePromise(UuidInterface $uuid, PromiseInterface $promise): self + { + if (! $this->promises->contains($uuid)) { + throw new InvalidArgumentException( + sprintf('There are no registered promises for UUID %s', $uuid->toString()) + ); + } + + foreach ($this->promises[$uuid] as $k => $v) { + if ($v === $promise) { + unset($this->promises[$uuid][$k]); + + return $this; + } + } + + throw new InvalidArgumentException( + sprintf('There is no such promise for UUID %s', $uuid->toString()) + ); + } + + /** + * Detach and return promises for the given UUID, if any + * + * **Example Usage:** + * + * ```php + * foreach ($promises->detachPromises($uuid) as $promise) { + * $promise->cancel(); + * } + * ``` + * + * @param UuidInterface $uuid + * + * @return PromiseInterface[] + */ + protected function detachPromises(UuidInterface $uuid): array + { + if (! $this->promises->contains($uuid)) { + return []; + } + + $promises = $this->promises[$uuid]; + $this->promises->detach($uuid); + + return $promises->getArrayCopy(); + } +} diff --git a/vendor/ipl/scheduler/src/Common/TaskProperties.php b/vendor/ipl/scheduler/src/Common/TaskProperties.php new file mode 100644 index 0000000..4ab65e2 --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/TaskProperties.php @@ -0,0 +1,83 @@ +<?php + +namespace ipl\Scheduler\Common; + +use LogicException; +use Ramsey\Uuid\UuidInterface; + +trait TaskProperties +{ + /** @var string */ + protected $description; + + /** @var string Name of this task */ + protected $name; + + /** @var UuidInterface Unique identifier of this task */ + protected $uuid; + + /** + * Set the description of this task + * + * @param ?string $desc + * + * @return $this + */ + public function setDescription(?string $desc): self + { + $this->description = $desc; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getName(): string + { + if (! $this->name) { + throw new LogicException('Task name must not be null'); + } + + return $this->name; + } + + /** + * Set the name of this Task + * + * @param string $name + * + * @return $this + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getUuid(): UuidInterface + { + if (! $this->uuid) { + throw new LogicException('Task UUID must not be null'); + } + + return $this->uuid; + } + + /** + * Set the UUID of this task + * + * @param UuidInterface $uuid + * + * @return $this + */ + public function setUuid(UuidInterface $uuid): self + { + $this->uuid = $uuid; + + return $this; + } +} diff --git a/vendor/ipl/scheduler/src/Common/Timers.php b/vendor/ipl/scheduler/src/Common/Timers.php new file mode 100644 index 0000000..2d0641f --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/Timers.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Scheduler\Common; + +use Ramsey\Uuid\UuidInterface; +use React\EventLoop\TimerInterface; +use SplObjectStorage; + +trait Timers +{ + /** @var SplObjectStorage<UuidInterface, TimerInterface> */ + protected $timers; + + /** + * Set a timer for the given UUID + * + * **Example Usage:** + * + * ```php + * $timers->attachTimer($uuid, Loop::addTimer($interval, $callback)); + * ``` + * + * @param UuidInterface $uuid + * @param TimerInterface $timer + * + * @return $this + */ + protected function attachTimer(UuidInterface $uuid, TimerInterface $timer): self + { + $this->timers->attach($uuid, $timer); + + return $this; + } + + /** + * Detach and return the timer for the given UUID, if any + * + * **Example Usage:** + * + * ```php + * Loop::cancelTimer($timers->detachTimer($uuid)); + * ``` + * + * @param UuidInterface $uuid + * + * @return ?TimerInterface + */ + protected function detachTimer(UuidInterface $uuid): ?TimerInterface + { + if (! $this->timers->contains($uuid)) { + return null; + } + + $timer = $this->timers->offsetGet($uuid); + + $this->timers->detach($uuid); + + return $timer; + } +} diff --git a/vendor/ipl/scheduler/src/Contract/Frequency.php b/vendor/ipl/scheduler/src/Contract/Frequency.php new file mode 100644 index 0000000..2235787 --- /dev/null +++ b/vendor/ipl/scheduler/src/Contract/Frequency.php @@ -0,0 +1,62 @@ +<?php + +namespace ipl\Scheduler\Contract; + +use DateTimeInterface; +use JsonSerializable; + +interface Frequency extends JsonSerializable +{ + /** @var string Format for representing datetimes when serializing the frequency to JSON */ + public const SERIALIZED_DATETIME_FORMAT = 'Y-m-d\TH:i:s.ue'; + + /** + * Get whether the frequency is due at the specified time + * + * @param DateTimeInterface $dateTime + * + * @return bool + */ + public function isDue(DateTimeInterface $dateTime): bool; + + /** + * Get the next due date relative to the given time + * + * @param DateTimeInterface $dateTime + * + * @return DateTimeInterface + */ + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface; + + /** + * Get whether the specified time is beyond the frequency's expiry time + * + * @param DateTimeInterface $dateTime + * + * @return bool + */ + public function isExpired(DateTimeInterface $dateTime): bool; + + /** + * Get the start time of this frequency + * + * @return ?DateTimeInterface + */ + public function getStart(): ?DateTimeInterface; + + /** + * Get the end time of this frequency + * + * @return ?DateTimeInterface + */ + public function getEnd(): ?DateTimeInterface; + + /** + * Create frequency from its stored JSON representation previously encoded with {@see json_encode()} + * + * @param string $json + * + * @return $this + */ + public static function fromJson(string $json): self; +} diff --git a/vendor/ipl/scheduler/src/Contract/Task.php b/vendor/ipl/scheduler/src/Contract/Task.php new file mode 100644 index 0000000..db09ddc --- /dev/null +++ b/vendor/ipl/scheduler/src/Contract/Task.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Scheduler\Contract; + +use Ramsey\Uuid\UuidInterface; +use React\Promise\ExtendedPromiseInterface; + +interface Task +{ + /** + * Get the name of this task + * + * @return string + */ + public function getName(): string; + + /** + * Get unique identifier of this task + * + * @return UuidInterface + */ + public function getUuid(): UuidInterface; + + /** + * Get the description of this task + * + * @return ?string + */ + public function getDescription(): ?string; + + /** + * Run this tasks operations + * + * This commits the actions in a non-blocking fashion to the event loop and yields a deferred promise + * + * @return ExtendedPromiseInterface + */ + public function run(): ExtendedPromiseInterface; +} diff --git a/vendor/ipl/scheduler/src/Cron.php b/vendor/ipl/scheduler/src/Cron.php new file mode 100644 index 0000000..639957b --- /dev/null +++ b/vendor/ipl/scheduler/src/Cron.php @@ -0,0 +1,203 @@ +<?php + +namespace ipl\Scheduler; + +use Cron\CronExpression; +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; + +use function ipl\Stdlib\get_php_type; + +class Cron implements Frequency +{ + public const PART_MINUTE = 0; + public const PART_HOUR = 1; + public const PART_DAY = 2; + public const PART_MONTH = 3; + public const PART_WEEKDAY = 4; + + /** @var CronExpression */ + protected $cron; + + /** @var ?DateTimeInterface Start time of this frequency */ + protected $start; + + /** @var ?DateTimeInterface End time of this frequency */ + protected $end; + + /** @var string String representation of the cron expression */ + protected $expression; + + /** + * Create frequency from the specified cron expression + * + * @param string $expression + * + * @throws InvalidArgumentException If expression is not a valid cron expression + */ + public function __construct(string $expression) + { + $this->cron = new CronExpression($expression); + $this->expression = $expression; + } + + public function isDue(DateTimeInterface $dateTime): bool + { + if ($this->isExpired($dateTime) || $dateTime < $this->start) { + return false; + } + + return $this->cron->isDue($dateTime); + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + if ($this->isExpired($dateTime)) { + return $this->end; + } + + if ($dateTime < $this->start) { + return $this->start; + } + + return $this->cron->getNextRunDate($dateTime); + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + return $this->end !== null && $this->end < $dateTime; + } + + public function getStart(): ?DateTimeInterface + { + return $this->start; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->end; + } + + /** + * Get the configured cron expression + * + * @return string + */ + public function getExpression(): string + { + return $this->expression; + } + + /** + * Set the start time of this frequency + * + * @param DateTimeInterface $start + * + * @return $this + */ + public function startAt(DateTimeInterface $start): self + { + $this->start = clone $start; + $this->start->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return $this; + } + + /** + * Set the end time of this frequency + * + * @param DateTimeInterface $end + * + * @return $this + */ + public function endAt(DateTimeInterface $end): Frequency + { + $this->end = clone $end; + $this->end->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return $this; + } + + /** + * Get the given part of the underlying cron expression + * + * @param int $part One of the classes `PART_*` constants + * + * @return string + * + * @throws InvalidArgumentException If the given part is invalid + */ + public function getPart(int $part): string + { + $value = $this->cron->getExpression($part); + if ($value === null) { + throw new InvalidArgumentException(sprintf('Invalid expression part specified: %d', $part)); + } + + return $value; + } + + /** + * Get the parts of the underlying cron expression as an array + * + * @return string[] + */ + public function getParts(): array + { + return $this->cron->getParts(); + } + + /** + * Get whether the given cron expression is valid + * + * @param string $expression + * + * @return bool + */ + public static function isValid(string $expression): bool + { + return CronExpression::isValidExpression($expression); + } + + public static function fromJson(string $json): Frequency + { + $data = json_decode($json, true); + if (! is_array($data)) { + throw new InvalidArgumentException( + sprintf( + '%s expects json decoded value to be an array, got %s instead', + __METHOD__, + get_php_type($data) + ) + ); + } + + $self = new static($data['expression']); + if (isset($data['start'])) { + $self->startAt(new DateTime($data['start'])); + } + + if (isset($data['end'])) { + $self->endAt(new DateTime($data['end'])); + } + + return $self; + } + + public function jsonSerialize(): array + { + $data = ['expression' => $this->getExpression()]; + if ($this->start) { + $data['start'] = $this->start->format(static::SERIALIZED_DATETIME_FORMAT); + } + + if ($this->end) { + $data['end'] = $this->end->format(static::SERIALIZED_DATETIME_FORMAT); + } + + return $data; + } +} diff --git a/vendor/ipl/scheduler/src/OneOff.php b/vendor/ipl/scheduler/src/OneOff.php new file mode 100644 index 0000000..ebe945d --- /dev/null +++ b/vendor/ipl/scheduler/src/OneOff.php @@ -0,0 +1,69 @@ +<?php + +namespace ipl\Scheduler; + +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; + +use function ipl\Stdlib\get_php_type; + +class OneOff implements Frequency +{ + /** @var DateTimeInterface Start time of this frequency */ + protected $dateTime; + + public function __construct(DateTimeInterface $dateTime) + { + $this->dateTime = clone $dateTime; + $this->dateTime->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + public function isDue(DateTimeInterface $dateTime): bool + { + return ! $this->isExpired($dateTime) && $this->dateTime == $dateTime; + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + return $this->dateTime; + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + return $this->dateTime < $dateTime; + } + + public function getStart(): ?DateTimeInterface + { + return $this->dateTime; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->getStart(); + } + + public static function fromJson(string $json): Frequency + { + $data = json_decode($json, true); + if (! is_string($data)) { + throw new InvalidArgumentException( + sprintf( + '%s expects json decoded value to be string, got %s instead', + __METHOD__, + get_php_type($data) + ) + ); + } + + return new static(new DateTime($data)); + } + + public function jsonSerialize(): string + { + return $this->dateTime->format(static::SERIALIZED_DATETIME_FORMAT); + } +} diff --git a/vendor/ipl/scheduler/src/RRule.php b/vendor/ipl/scheduler/src/RRule.php new file mode 100644 index 0000000..bfad0e5 --- /dev/null +++ b/vendor/ipl/scheduler/src/RRule.php @@ -0,0 +1,328 @@ +<?php + +namespace ipl\Scheduler; + +use BadMethodCallException; +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use Generator; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; +use Recurr\Exception\InvalidRRule; +use Recurr\Rule as RecurrRule; +use Recurr\Transformer\ArrayTransformer; +use Recurr\Transformer\ArrayTransformerConfig; +use Recurr\Transformer\Constraint\AfterConstraint; +use Recurr\Transformer\Constraint\BetweenConstraint; +use stdClass; + +use function ipl\Stdlib\get_php_type; + +/** + * Support scheduling a task based on expressions in iCalendar format + */ +class RRule implements Frequency +{ + /** @var string Run once a year */ + public const YEARLY = 'YEARLY'; + + /** @var string Run every 3 month starting from the given start time */ + public const QUARTERLY = 'QUARTERLY'; + + /** @var string Run once a month */ + public const MONTHLY = 'MONTHLY'; + + /** @var string Run once a week based on the specified start time */ + public const WEEKLY = 'WEEKLY'; + + /** @var string Run once a day at the specified start time */ + public const DAILY = 'DAILY'; + + /** @var string Run once an hour */ + public const HOURLY = 'HOURLY'; + + /** @var string Run once a minute */ + public const MINUTELY = 'MINUTELY'; + + /** @var int Default limit of the recurrences to be generated by the transformer */ + private const DEFAULT_LIMIT = 1; + + /** @var RecurrRule */ + protected $rrule; + + /** @var ArrayTransformer */ + protected $transformer; + + /** @var ArrayTransformerConfig */ + protected $transformerConfig; + + /** @var string */ + protected $frequency; + + /** + * Construct a new rrule instance + * + * @param string|array<string, mixed> $rule + * + * @throws InvalidRRule + */ + public function __construct($rule) + { + $this->rrule = new RecurrRule($rule); + $this->frequency = $this->rrule->getFreqAsText(); + $this->transformerConfig = new ArrayTransformerConfig(); + $this->transformerConfig->setVirtualLimit(self::DEFAULT_LIMIT); + + // If the run day isn't set explicitly, we can enable the last day of month + // fix, so that it doesn't skip some months which doesn't have e.g. 29,30,31 days. + if ( + $this->getFrequency() === static::MONTHLY + && ! $this->rrule->getByDay() + && ! $this->rrule->getByMonthDay() + ) { + $this->transformerConfig->enableLastDayOfMonthFix(); + } + + $this->transformer = new ArrayTransformer($this->transformerConfig); + } + + /** + * Get an RRule instance from the provided frequency + * + * @param string $frequency + * + * @return $this + */ + public static function fromFrequency(string $frequency): self + { + $frequencies = array_flip([ + static::MINUTELY, + static::HOURLY, + static::DAILY, + static::WEEKLY, + static::MONTHLY, + static::QUARTERLY, + static::YEARLY + ]); + + if (! isset($frequencies[$frequency])) { + throw new InvalidArgumentException(sprintf('Unknown frequency provided: %s', $frequency)); + } + + if ($frequency === static::QUARTERLY) { + $repeat = static::MONTHLY; + $rule = "FREQ=$repeat;INTERVAL=3"; + } else { + $rule = "FREQ=$frequency"; + } + + $self = new static($rule); + $self->frequency = $frequency; + + return $self; + } + + public static function fromJson(string $json): Frequency + { + /** @var stdClass $data */ + $data = json_decode($json); + $self = new static($data->rrule); + $self->frequency = $data->frequency; + if (isset($data->start)) { + $start = DateTime::createFromFormat(static::SERIALIZED_DATETIME_FORMAT, $data->start); + if (! $start) { + throw new InvalidArgumentException(sprintf('Cannot deserialize start time: %s', $data->start)); + } + + $self->startAt($start); + } + + return $self; + } + + public function isDue(DateTimeInterface $dateTime): bool + { + if ($dateTime < $this->rrule->getStartDate() || $this->isExpired($dateTime)) { + return false; + } + + $nextDue = $this->getNextRecurrences($dateTime); + if (! $nextDue->valid()) { + return false; + } + + return $nextDue->current() == $dateTime; + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + if ($this->isExpired($dateTime)) { + return $this->getEnd(); + } + + $nextDue = $this->getNextRecurrences($dateTime, 1, false); + if (! $nextDue->valid()) { + return $dateTime; + } + + return $nextDue->current(); + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + if ($this->rrule->repeatsIndefinitely()) { + return false; + } + + return $this->getEnd() !== null && $this->getEnd() < $dateTime; + } + + /** + * Set the start time of this frequency + * + * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second. + * + * @param DateTimeInterface $start + * + * @return $this + */ + public function startAt(DateTimeInterface $start): self + { + $startDate = clone $start; + // When the start time contains microseconds, the first recurrence will always be skipped, as + // the transformer operates only up to seconds level. See also the upstream issue #155 + $startDate->setTime($start->format('H'), $start->format('i'), $start->format('s')); + // In case start time uses a different tz than what the rrule internally does, we force it to use the same + $startDate->setTimezone(new DateTimeZone($this->rrule->getTimezone())); + + $this->rrule->setStartDate($startDate); + + return $this; + } + + public function getStart(): ?DateTimeInterface + { + return $this->rrule->getStartDate(); + } + + /** + * Set the time until this frequency lasts + * + * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second. + * + * @param DateTimeInterface $end + * + * @return $this + */ + public function endAt(DateTimeInterface $end): self + { + $end = clone $end; + $end->setTime($end->format('H'), $end->format('i'), $end->format('s')); + + $this->rrule->setUntil($end); + + return $this; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->rrule->getEndDate() ?? $this->rrule->getUntil(); + } + + /** + * Get the frequency of this rule + * + * @return string + */ + public function getFrequency(): string + { + return $this->frequency; + } + + /** + * Get a set of recurrences relative to the given time + * + * @param DateTimeInterface $dateTime + * @param int $limit Limit the recurrences to be generated to the given value + * @param bool $include Whether to include the passed time in the result set + * + * @return Generator<DateTimeInterface> + */ + public function getNextRecurrences( + DateTimeInterface $dateTime, + int $limit = self::DEFAULT_LIMIT, + bool $include = true + ): Generator { + $resetTransformerConfig = function (int $limit = self::DEFAULT_LIMIT): void { + $this->transformerConfig->setVirtualLimit($limit); + $this->transformer->setConfig($this->transformerConfig); + }; + + if ($limit > self::DEFAULT_LIMIT) { + $resetTransformerConfig($limit); + } + + $constraint = new AfterConstraint($dateTime, $include); + if (! $this->rrule->repeatsIndefinitely()) { + // When accessing this method externally (not by using `getNextDue()`), the transformer may + // generate recurrences beyond the configured end time. + $constraint = new BetweenConstraint($dateTime, $this->getEnd(), $include); + } + + // Setting the start date to a date time smaller than now causes the underlying library + // not to generate any recurrences when using the regular frequencies such as `MINUTELY` etc. + // and the `$countConstraintFailures` is set to true. We need also to tell the transformer + // not to count the recurrences that fail the constraint's test! + $recurrences = $this->transformer->transform($this->rrule, $constraint, false); + foreach ($recurrences as $recurrence) { + yield $recurrence->getStart(); + } + + if ($limit > self::DEFAULT_LIMIT) { + $resetTransformerConfig(); + } + } + + public function jsonSerialize(): array + { + $data = [ + 'rrule' => $this->rrule->getString(RecurrRule::TZ_FIXED), + 'frequency' => $this->frequency + ]; + + $start = $this->getStart(); + if ($start) { + $data['start'] = $start->format(static::SERIALIZED_DATETIME_FORMAT); + } + + return $data; + } + + /** + * Redirect all public method calls to the underlying rrule object + * + * @param string $methodName + * @param array<mixed> $args + * + * @return mixed + * + * @throws BadMethodCallException If the given method doesn't exist or when setter method is called + */ + public function __call(string $methodName, array $args) + { + if (! method_exists($this->rrule, $methodName)) { + throw new BadMethodCallException( + sprintf('Call to undefined method %s::%s()', get_php_type($this->rrule), $methodName) + ); + } + + if (strtolower(substr($methodName, 0, 3)) !== 'get') { + throw new BadMethodCallException( + sprintf('Dynamic method %s is not supported. Only getters (get*) are', $methodName) + ); + } + + return call_user_func_array([$this->rrule, $methodName], $args); + } +} diff --git a/vendor/ipl/scheduler/src/Scheduler.php b/vendor/ipl/scheduler/src/Scheduler.php new file mode 100644 index 0000000..25ad3a1 --- /dev/null +++ b/vendor/ipl/scheduler/src/Scheduler.php @@ -0,0 +1,323 @@ +<?php + +namespace ipl\Scheduler; + +use DateTime; +use InvalidArgumentException; +use ipl\Scheduler\Common\Promises; +use ipl\Scheduler\Common\Timers; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\Contract\Task; +use ipl\Stdlib\Events; +use React\EventLoop\Loop; +use React\Promise; +use React\Promise\ExtendedPromiseInterface; +use SplObjectStorage; +use Throwable; + +class Scheduler +{ + use Events; + use Timers; + use Promises; + + /** + * Event raised when a {@link Task task} is canceled + * + * The task and its pending operations as an array of canceled {@link ExtendedPromiseInterface promise}s + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_CANCEL, function (Task $task, array $_) use ($logger) { + * $logger->info(sprintf('Task %s cancelled', $task->getName())); + * }); + * ``` + */ + public const ON_TASK_CANCEL = 'task-cancel'; + + /** + * Event raised when an operation of a {@link Task task} is done + * + * The task and the operation result are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_DONE, function (Task $task, $result) use ($logger) { + * $logger->info(sprintf('Operation of task %s done: %s', $task->getName(), $result)); + * }); + * ``` + */ + public const ON_TASK_DONE = 'task-done'; + + /** + * Event raised when an operation of a {@link Task task} failed + * + * The task and the {@link Throwable reason} why the operation failed + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_FAILED, function (Task $task, Throwable $e) use ($logger) { + * $logger->error( + * sprintf('Operation of task %s failed: %s', $task->getName(), $e), + * ['exception' => $e] + * ); + * }); + * ``` + */ + public const ON_TASK_FAILED = 'task-failed'; + + /** + * Event raised when a {@link Task task} operation is scheduled + * + * The task and the {@link DateTime time} when it should run + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_SCHEDULED, function (Task $task, DateTime $dateTime) use ($logger) { + * $logger->info(sprintf( + * 'Scheduling task %s to run at %s', + * $task->getName(), + * IntlDateFormatter::formatObject($dateTime) + * )); + * }); + * ``` + */ + public const ON_TASK_SCHEDULED = 'task-scheduled'; + + /** + * Event raised upon operation of a {@link Task task} + * + * The task and the possibly not yet completed result of the operation as a {@link ExtendedPromiseInterface promise} + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_OPERATION, function (Task $task, ExtendedPromiseInterface $_) use ($logger) { + * $logger->info(sprintf('Task %s operating', $task->getName())); + * }); + * ``` + */ + public const ON_TASK_RUN = 'task-run'; + + /** + * Event raised when a {@see Task task} is expired + * + * The task and the {@see DateTime expire time} are passed as parameters to the event callbacks. + * Note that the expiration time is the first time that is considered expired based on the frequency + * of the task and can be later than the specified end time. + * + * **Example usage:** + * + * ```php + * $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Task $task, DateTime $dateTime) use ($logger) { + * $logger->info(sprintf('Removing expired task %s at %s', $task->getName(), $dateTime->format('Y-m-d H:i:s'))); + * }); + * ``` + */ + public const ON_TASK_EXPIRED = 'task-expired'; + + /** @var SplObjectStorage<Task, null> The scheduled tasks of this scheduler */ + protected $tasks; + + public function __construct() + { + $this->tasks = new SplObjectStorage(); + + $this->promises = new SplObjectStorage(); + $this->timers = new SplObjectStorage(); + + $this->init(); + } + + /** + * Initialize this scheduler + */ + protected function init(): void + { + } + + /** + * Remove and cancel the given task + * + * @param Task $task + * + * @return $this + * + * @throws InvalidArgumentException If the given task isn't scheduled + */ + public function remove(Task $task): self + { + if (! $this->hasTask($task)) { + throw new InvalidArgumentException(sprintf('Task %s not scheduled', $task->getName())); + } + + $this->cancelTask($task); + + $this->tasks->detach($task); + + return $this; + } + + /** + * Remove and cancel all tasks + * + * @return $this + */ + public function removeTasks(): self + { + foreach ($this->tasks as $task) { + $this->cancelTask($task); + } + + $this->tasks = new SplObjectStorage(); + + return $this; + } + + /** + * Get whether the specified task is scheduled + * + * @param Task $task + * + * @return bool + */ + public function hasTask(Task $task): bool + { + return $this->tasks->contains($task); + } + + /** + * Schedule the given task based on the specified frequency + * + * @param Task $task + * @param Frequency $frequency + * + * @return $this + */ + public function schedule(Task $task, Frequency $frequency): self + { + $now = new DateTime(); + if ($frequency->isExpired($now)) { + return $this; + } + + if ($frequency->isDue($now)) { + Loop::futureTick(function () use ($task): void { + $promise = $this->runTask($task); + $this->emit(static::ON_TASK_RUN, [$task, $promise]); + }); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $now]); + + if ($frequency instanceof OneOff) { + return $this; + } + } + + $loop = function () use (&$loop, $task, $frequency): void { + $promise = $this->runTask($task); + $this->emit(static::ON_TASK_RUN, [$task, $promise]); + + $now = new DateTime(); + $nextDue = $frequency->getNextDue($now); + if ($frequency instanceof OneOff || $frequency->isExpired($nextDue)) { + $removeTask = function () use ($task, $nextDue): void { + $this->remove($task); + $this->emit(static::ON_TASK_EXPIRED, [$task, $nextDue]); + }; + + if ($this->promises->contains($task->getUuid())) { + $pendingPromises = (array) $this->promises->offsetGet($task->getUuid()); + Promise\all($pendingPromises)->always($removeTask); + } else { + $removeTask(); + } + + return; + } + + $this->attachTimer( + $task->getUuid(), + Loop::addTimer($nextDue->getTimestamp() - $now->getTimestamp(), $loop) + ); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $nextDue]); + }; + + $nextDue = $frequency->getNextDue($now); + $this->attachTimer( + $task->getUuid(), + Loop::addTimer($nextDue->getTimestamp() - $now->getTimestamp(), $loop) + ); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $nextDue]); + + $this->tasks->attach($task); + + return $this; + } + + public function isValidEvent(string $event): bool + { + $events = array_flip([ + static::ON_TASK_CANCEL, + static::ON_TASK_DONE, + static::ON_TASK_EXPIRED, + static::ON_TASK_FAILED, + static::ON_TASK_RUN, + static::ON_TASK_SCHEDULED + ]); + + return isset($events[$event]); + } + + /** + * Cancel the timer of the task and all pending operations + * + * @param Task $task + */ + protected function cancelTask(Task $task): void + { + Loop::cancelTimer($this->detachTimer($task->getUuid())); + + /** @var ExtendedPromiseInterface[] $promises */ + $promises = $this->detachPromises($task->getUuid()); + if (! empty($promises)) { + /** @var Promise\CancellablePromiseInterface $promise */ + foreach ($promises as $promise) { + $promise->cancel(); + } + $this->emit(self::ON_TASK_CANCEL, [$task, $promises]); + } + } + + /** + * Runs the given task immediately and registers handlers for the returned promise + * + * @param Task $task + * + * @return ExtendedPromiseInterface + */ + protected function runTask(Task $task): ExtendedPromiseInterface + { + $promise = $task->run(); + $this->addPromise($task->getUuid(), $promise); + + return $promise->then( + function ($result) use ($task): void { + $this->emit(self::ON_TASK_DONE, [$task, $result]); + }, + function (Throwable $reason) use ($task): void { + $this->emit(self::ON_TASK_FAILED, [$task, $reason]); + } + )->always(function () use ($task, $promise): void { + // Unregister the promise without canceling it as it's already resolved + $this->removePromise($task->getUuid(), $promise); + }); + } +} diff --git a/vendor/ipl/scheduler/src/register_cron_aliases.php b/vendor/ipl/scheduler/src/register_cron_aliases.php new file mode 100644 index 0000000..2987248 --- /dev/null +++ b/vendor/ipl/scheduler/src/register_cron_aliases.php @@ -0,0 +1,11 @@ +<?php + +use Cron\CronExpression; + +if (! CronExpression::supportsAlias('@minutely')) { + CronExpression::registerAlias('@minutely', '* * * * *'); +} + +if (! CronExpression::supportsAlias('@quarterly')) { + CronExpression::registerAlias('@quarterly', '0 0 1 */3 *'); +} diff --git a/vendor/ipl/sql/LICENSE b/vendor/ipl/sql/LICENSE new file mode 100644 index 0000000..e179593 --- /dev/null +++ b/vendor/ipl/sql/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/sql/composer.json b/vendor/ipl/sql/composer.json new file mode 100644 index 0000000..5c662b1 --- /dev/null +++ b/vendor/ipl/sql/composer.json @@ -0,0 +1,29 @@ +{ + "name": "ipl/sql", + "type": "library", + "description": "Icinga PHP Library - SQL abstraction layer", + "keywords": ["sql", "database"], + "homepage": "https://github.com/Icinga/ipl-sql", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-pdo": "*", + "ipl/stdlib": ">=0.12.0" + }, + "require-dev": { + "ipl/stdlib": "dev-main" + }, + "autoload": { + "psr-4": { + "ipl\\Sql\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Sql\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + } +} diff --git a/vendor/ipl/sql/src/Adapter/BaseAdapter.php b/vendor/ipl/sql/src/Adapter/BaseAdapter.php new file mode 100644 index 0000000..f062f63 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/BaseAdapter.php @@ -0,0 +1,120 @@ +<?php + +namespace ipl\Sql\Adapter; + +use DateTime; +use DateTimeZone; +use ipl\Sql\Config; +use ipl\Sql\Connection; +use ipl\Sql\Contract\Adapter; +use ipl\Sql\QueryBuilder; +use ipl\Sql\Select; +use PDO; +use UnexpectedValueException; + +abstract class BaseAdapter implements Adapter +{ + /** + * Quote character to use for quoting identifiers + * + * The default quote character is the double quote (") which is used by databases that behave close to ANSI SQL. + * + * @var array + */ + protected $quoteCharacter = ['"', '"']; + + /** @var string Character to use for escaping quote characters */ + protected $escapeCharacter = '\\"'; + + /** @var array Default PDO connect options */ + protected $options = [ + PDO::ATTR_CASE => PDO::CASE_NATURAL, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false + ]; + + public function getDsn(Config $config) + { + $dsn = "{$config->db}:"; + + $parts = []; + + foreach (['host', 'dbname', 'port'] as $part) { + if (! empty($config->$part)) { + $parts[] = "{$part}={$config->$part}"; + } + } + + return $dsn . implode(';', $parts); + } + + public function getOptions(Config $config) + { + if (is_array($config->options)) { + return $config->options + $this->options; + } + + return $this->options; + } + + public function setClientTimezone(Connection $db) + { + return $this; + } + + public function quoteIdentifier($identifiers) + { + if (is_string($identifiers)) { + $identifiers = explode('.', $identifiers); + } + + foreach ($identifiers as $i => $identifier) { + if ($identifier === '*') { + continue; + } + + $identifiers[$i] = $this->quoteCharacter[0] + . str_replace($this->quoteCharacter[0], $this->escapeCharacter, $identifier) + . $this->quoteCharacter[1]; + } + + return implode('.', $identifiers); + } + + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder) + { + $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select): void { + if ($select->hasOrderBy()) { + foreach ($select->getOrderBy() as list($_, $direction)) { + switch (strtolower($direction ?? '')) { + case '': + case 'asc': + case 'desc': + break; + default: + throw new UnexpectedValueException( + sprintf('Invalid direction "%s" in ORDER BY', $direction) + ); + } + } + } + }); + + return $this; + } + + protected function getTimezoneOffset() + { + $tz = new DateTimeZone(date_default_timezone_get()); + $offset = $tz->getOffset(new DateTime()); + $prefix = $offset >= 0 ? '+' : '-'; + $offset = abs($offset); + + $hours = (int) floor($offset / 3600); + $minutes = (int) floor(($offset % 3600) / 60); + + return sprintf('%s%d:%02d', $prefix, $hours, $minutes); + } +} diff --git a/vendor/ipl/sql/src/Adapter/Mssql.php b/vendor/ipl/sql/src/Adapter/Mssql.php new file mode 100644 index 0000000..c9f11ce --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Mssql.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\QueryBuilder; +use ipl\Sql\Select; +use PDO; +use RuntimeException; + +class Mssql extends BaseAdapter +{ + protected $quoteCharacter = ['[', ']']; + + protected $escapeCharacter = '[[]'; + + public function getDsn(Config $config) + { + $drivers = array_intersect(['sqlsrv', 'dblib', 'mssql', 'sybase'], PDO::getAvailableDrivers()); + + if (empty($drivers)) { + throw new RuntimeException('No PDO driver available for connecting to a Microsoft SQL Server'); + } + + $driver = reset($drivers); // array_intersect preserves keys, so the first may not be indexed at 0 + + $isSqlSrv = $driver === 'sqlsrv'; + if ($isSqlSrv) { + $hostOption = 'Server'; + $dbOption = 'Database'; + } else { + $hostOption = 'host'; + $dbOption = 'dbname'; + } + + $dsn = "{$driver}:{$hostOption}={$config->host}"; + + if (! empty($config->port)) { + if ($isSqlSrv || strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $seperator = ','; + } else { + $seperator = ':'; + } + + $dsn .= "{$seperator}{$config->port}"; + } + + $dsn .= ";{$dbOption}={$config->dbname}"; + + if (! empty($config->charset) && ! $isSqlSrv) { + $dsn .= ";charset={$config->charset}"; + } + + if (isset($config->useSsl) && $isSqlSrv) { + $dsn .= ';Encrypt=' . ($config->useSsl ? 'true' : 'false'); + } + + if (isset($config->sslDoNotVerifyServerCert) && $isSqlSrv) { + $dsn .= ';TrustServerCertificate=' . ($config->sslDoNotVerifyServerCert ? 'true' : 'false'); + } + + return $dsn; + } + + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder) + { + parent::registerQueryBuilderCallbacks($queryBuilder); + + $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) { + if ( + ($select->hasLimit() || $select->hasOffset()) + && ! $select->hasOrderBy() + ) { + $select->orderBy(1); + } + }); + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Mysql.php b/vendor/ipl/sql/src/Adapter/Mysql.php new file mode 100644 index 0000000..2421cae --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Mysql.php @@ -0,0 +1,57 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\Connection; +use PDO; + +class Mysql extends BaseAdapter +{ + protected $quoteCharacter = ['`', '`']; + + protected $escapeCharacter = '``'; + + public function setClientTimezone(Connection $db) + { + $db->exec('SET time_zone = ' . $db->quote($this->getTimezoneOffset())); + + return $this; + } + + public function getOptions(Config $config) + { + $options = parent::getOptions($config); + + if (! empty($config->useSsl)) { + if (! empty($config->sslKey)) { + $options[PDO::MYSQL_ATTR_SSL_KEY] = $config->sslKey; + } + + if (! empty($config->sslCert)) { + $options[PDO::MYSQL_ATTR_SSL_CERT] = $config->sslCert; + } + + if (! empty($config->sslCa)) { + $options[PDO::MYSQL_ATTR_SSL_CA] = $config->sslCa; + } + + if (! empty($config->sslCapath)) { + $options[PDO::MYSQL_ATTR_SSL_CAPATH] = $config->sslCapath; + } + + if (! empty($config->sslCipher)) { + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = $config->sslCipher; + } + + if ( + defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && ! empty($config->sslDoNotVerifyServerCert) + ) { + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + + return $options; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Oracle.php b/vendor/ipl/sql/src/Adapter/Oracle.php new file mode 100644 index 0000000..de0aee5 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Oracle.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\Connection; + +class Oracle extends BaseAdapter +{ + public function getDsn(Config $config) + { + $dsn = 'oci:dbname='; + + if (! empty($config->host)) { + $dsn .= "//{$config->host}"; + + if (! empty($config->port)) { + $dsn .= ":{$config->port}/"; + } + + $dsn .= '/'; + } + + $dsn .= $config->dbname; + + if (! empty($config->charset)) { + $dsn .= ";charset={$config->charset}"; + } + + return $dsn; + } + + public function setClientTimezone(Connection $db) + { + $db->prepexec('ALTER SESSION SET TIME_ZONE = ?', [$this->getTimezoneOffset()]); + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Pgsql.php b/vendor/ipl/sql/src/Adapter/Pgsql.php new file mode 100644 index 0000000..18bf15d --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Pgsql.php @@ -0,0 +1,15 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Connection; + +class Pgsql extends BaseAdapter +{ + public function setClientTimezone(Connection $db) + { + $db->exec(sprintf('SET TIME ZONE INTERVAL %s HOUR TO MINUTE', $db->quote($this->getTimezoneOffset()))); + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Sqlite.php b/vendor/ipl/sql/src/Adapter/Sqlite.php new file mode 100644 index 0000000..9f4e209 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Sqlite.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; + +class Sqlite extends BaseAdapter +{ + public function getDsn(Config $config) + { + return "sqlite:{$config->dbname}"; + } +} diff --git a/vendor/ipl/sql/src/CommonTableExpression.php b/vendor/ipl/sql/src/CommonTableExpression.php new file mode 100644 index 0000000..596ec39 --- /dev/null +++ b/vendor/ipl/sql/src/CommonTableExpression.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link CommonTableExpressionInterface} to allow CTEs via {@link with()} + */ +trait CommonTableExpression +{ + /** + * All CTEs + * + * [ + * [$query, $alias, $recursive], + * ... + * ] + * + * @var array[] + */ + protected $with = []; + + public function getWith() + { + return $this->with; + } + + public function with(Select $query, $alias, $recursive = false) + { + $this->with[] = [$query, $alias, $recursive]; + + return $this; + } + + public function resetWith() + { + $this->with = []; + + return $this; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneCte() + { + foreach ($this->with as &$cte) { + $cte[0] = clone $cte[0]; + } + unset($cte); + } +} diff --git a/vendor/ipl/sql/src/CommonTableExpressionInterface.php b/vendor/ipl/sql/src/CommonTableExpressionInterface.php new file mode 100644 index 0000000..7e93bc8 --- /dev/null +++ b/vendor/ipl/sql/src/CommonTableExpressionInterface.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for CTEs via {@link with()} + */ +interface CommonTableExpressionInterface +{ + /** + * Get all CTEs + * + * [ + * [$query, $alias, $columns, $recursive], + * ... + * ] + * + * @return array[] + */ + public function getWith(); + + /** + * Add a CTE + * + * @param Select $query + * @param string $alias + * @param bool $recursive + * + * @return $this + */ + public function with(Select $query, $alias, $recursive = false); + + /** + * Reset all CTEs + * + * @return $this + */ + public function resetWith(); +} diff --git a/vendor/ipl/sql/src/Compat/FilterProcessor.php b/vendor/ipl/sql/src/Compat/FilterProcessor.php new file mode 100644 index 0000000..6835e25 --- /dev/null +++ b/vendor/ipl/sql/src/Compat/FilterProcessor.php @@ -0,0 +1,127 @@ +<?php + +namespace ipl\Sql\Compat; + +use InvalidArgumentException; +use ipl\Sql\Filter\Exists; +use ipl\Sql\Filter\In; +use ipl\Sql\Filter\NotExists; +use ipl\Sql\Filter\NotIn; +use ipl\Sql\Select; +use ipl\Sql\Sql; +use ipl\Stdlib\Filter; + +class FilterProcessor +{ + public static function assembleFilter(Filter\Rule $filter, $level = 0) + { + $condition = null; + + if ($filter instanceof Filter\Chain) { + if ($filter instanceof Filter\All) { + $operator = Sql::ALL; + } elseif ($filter instanceof Filter\Any) { + $operator = Sql::ANY; + } elseif ($filter instanceof Filter\None) { + $operator = Sql::NOT_ALL; + } + + if (! isset($operator)) { + throw new InvalidArgumentException(sprintf('Cannot render filter: %s', get_class($filter))); + } + + if (! $filter->isEmpty()) { + foreach ($filter as $filterPart) { + $part = static::assembleFilter($filterPart, $level + 1); + if ($part) { + if ($condition === null) { + $condition = [$operator, [$part]]; + } else { + if ($condition[0] === $operator) { + $condition[1][] = $part; + } elseif ($operator === Sql::NOT_ALL) { + $condition = [Sql::ALL, [$condition, [$operator, [$part]]]]; + } elseif ($operator === Sql::NOT_ANY) { + $condition = [Sql::ANY, [$condition, [$operator, [$part]]]]; + } else { + $condition = [$operator, [$condition, $part]]; + } + } + } + } + } else { + // TODO(el): Explicitly return the empty string due to the FilterNot case? + } + } else { + /** @var Filter\Condition $filter */ + $condition = [Sql::ALL, static::assemblePredicate($filter)]; + } + + return $condition; + } + + public static function assemblePredicate(Filter\Condition $filter) + { + $column = $filter->getColumn(); + $expression = $filter->getValue(); + + if (is_array($expression) || $expression instanceof Select) { + $nullVerification = true; + if (is_array($column)) { + if (count($column) === 1) { + $column = $column[0]; + } else { + $nullVerification = false; + $column = '( ' . implode(', ', $column) . ' )'; + } + } + + if ($filter instanceof Filter\Unequal || $filter instanceof NotIn) { + return [sprintf($nullVerification + ? '(%s NOT IN (?) OR %1$s IS NULL)' + : '%s NOT IN (?)', $column) => $expression]; + } elseif ($filter instanceof Filter\Equal || $filter instanceof In) { + return ["$column IN (?)" => $expression]; + } + + throw new InvalidArgumentException( + 'Unable to render array expressions with operators other than equal/in or not equal/not in' + ); + } elseif ( + ($filter instanceof Filter\Like || $filter instanceof Filter\Unlike) + && strpos($expression, '*') !== false + ) { + if ($expression === '*') { + return ["$column IS " . ($filter instanceof Filter\Like ? 'NOT ' : '') . 'NULL']; + } elseif ($filter instanceof Filter\Unlike) { + return [ + "($column NOT LIKE ? OR $column IS NULL)" => str_replace(['%', '*'], ['\\%', '%'], $expression) + ]; + } else { + return ["$column LIKE ?" => str_replace(['%', '*'], ['\\%', '%'], $expression)]; + } + } elseif ($filter instanceof Filter\Unequal || $filter instanceof Filter\Unlike) { + return ["($column != ? OR $column IS NULL)" => $expression]; + } else { + if ($filter instanceof Filter\Like || $filter instanceof Filter\Equal) { + $operator = '='; + } elseif ($filter instanceof Filter\GreaterThan) { + $operator = '>'; + } elseif ($filter instanceof Filter\GreaterThanOrEqual) { + $operator = '>='; + } elseif ($filter instanceof Filter\LessThan) { + $operator = '<'; + } elseif ($filter instanceof Filter\LessThanOrEqual) { + $operator = '<='; + } elseif ($filter instanceof Exists) { + $operator = 'EXISTS'; + } elseif ($filter instanceof NotExists) { + $operator = 'NOT EXISTS'; + } else { + throw new InvalidArgumentException(sprintf('Cannot render filter: %s', get_class($filter))); + } + + return ["$column $operator ?" => $expression]; + } + } +} diff --git a/vendor/ipl/sql/src/Config.php b/vendor/ipl/sql/src/Config.php new file mode 100644 index 0000000..5fa103e --- /dev/null +++ b/vendor/ipl/sql/src/Config.php @@ -0,0 +1,99 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; +use ipl\Stdlib\Str; +use OutOfRangeException; + +use function ipl\Stdlib\get_php_type; + +/** + * SQL connection configuration + */ +class Config +{ + /** @var string Type of the DBMS */ + public $db; + + /** @var string Database host */ + public $host; + + /** @var int Database port */ + public $port; + + /** @var string Database name */ + public $dbname; + + /** @var string Username to use for authentication */ + public $username; + + /** @var string Password to use for authentication */ + public $password; + + /** + * Character set for the connection + * + * If you want to use the default charset as configured by the database, don't set this property. + * + * @var string + */ + public $charset; + + /** + * PDO connect options + * + * Array of key-value pairs that should be set when calling {@link Connection::connect()} in order to establish a DB + * connection. + * + * @var array + */ + public $options; + + /** @var array Extra settings e.g. for SQL SSL connections */ + protected $extraSettings = []; + + /** + * Create a new SQL connection configuration from the given configuration key-value pairs + * + * Keys will be converted to camelCase, e.g. use_ssl → useSsl. + * + * @param iterable $config Configuration key-value pairs + * + * @throws InvalidArgumentException If $config is not iterable + */ + public function __construct($config) + { + if (! is_iterable($config)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter one to be iterable, got %s instead', + __METHOD__, + get_php_type($config) + )); + } + + foreach ($config as $key => $value) { + $key = Str::camel($key); + $this->$key = $value; + } + } + + public function __isset(string $name): bool + { + return isset($this->extraSettings[$name]); + } + + public function __get(string $name) + { + if (array_key_exists($name, $this->extraSettings)) { + return $this->extraSettings[$name]; + } + + throw new OutOfRangeException(sprintf('Property %s does not exist', $name)); + } + + public function __set(string $name, $value): void + { + $this->extraSettings[$name] = $value; + } +} diff --git a/vendor/ipl/sql/src/Connection.php b/vendor/ipl/sql/src/Connection.php new file mode 100644 index 0000000..de84c72 --- /dev/null +++ b/vendor/ipl/sql/src/Connection.php @@ -0,0 +1,554 @@ +<?php + +namespace ipl\Sql; + +use BadMethodCallException; +use Exception; +use InvalidArgumentException; +use ipl\Sql\Contract\Adapter; +use ipl\Sql\Contract\Quoter; +use ipl\Stdlib\Plugins; +use PDO; +use PDOStatement; + +/** + * Connection to a SQL database using the native PDO for database access + */ +class Connection implements Quoter +{ + use Plugins; + + /** @var Config */ + protected $config; + + /** @var ?PDO */ + protected $pdo; + + /** @var QueryBuilder */ + protected $queryBuilder; + + /** @var Adapter */ + protected $adapter; + + /** + * Create a new database connection using the given config for initialising the options for the connection + * + * {@link init()} is called after construction. + * + * @param Config|iterable $config + * + * @throws InvalidArgumentException If there's no adapter for the given database available + */ + public function __construct($config) + { + $config = $config instanceof Config ? $config : new Config($config); + + $this->addPluginLoader('adapter', __NAMESPACE__ . '\\Adapter'); + + $adapter = $this->loadPlugin('adapter', $config->db); + + if (! $adapter) { + throw new InvalidArgumentException("Can't load database adapter for '{$config->db}'."); + } + + $this->adapter = new $adapter(); + $this->config = $config; + + $this->init(); + } + + /** + * Proxy PDO method calls + * + * @param string $name The name of the PDO method to call + * @param array $arguments Arguments for the method to call + * + * @return mixed + * + * @throws BadMethodCallException If the called method does not exist + * + */ + public function __call($name, array $arguments) + { + $this->connect(); + + if (! method_exists($this->pdo, $name)) { + $class = get_class($this); + $message = "Call to undefined method $class::$name"; + + throw new BadMethodCallException($message); + } + + return call_user_func_array([$this->pdo, $name], $arguments); + } + + /** + * Initialise the database connection + * + * If you have to adjust the connection after construction, override this method. + */ + public function init() + { + } + + /** + * Get the database adapter + * + * @return Adapter + */ + public function getAdapter() + { + return $this->adapter; + } + + /** + * Get the connection configuration + * + * @return Config + */ + public function getConfig() + { + return $this->config; + } + + /** + * Get the query builder for the database connection + * + * @return QueryBuilder + */ + public function getQueryBuilder() + { + if ($this->queryBuilder === null) { + $this->queryBuilder = new QueryBuilder($this->adapter); + } + + return $this->queryBuilder; + } + + /** + * Create and return the PDO instance + * + * This method is called via {@link connect()} to establish a database connection. + * If the default PDO needs to be adjusted for a certain DBMS, override this method. + * + * @return PDO + */ + protected function createPdoAdapter() + { + $adapter = $this->getAdapter(); + + $config = $this->getConfig(); + + return new PDO( + $adapter->getDsn($config), + $config->username, + $config->password, + $adapter->getOptions($config) + ); + } + + /** + * Connect to the database, if not already connected + * + * @return $this + */ + public function connect() + { + if ($this->pdo !== null) { + return $this; + } + + $this->pdo = $this->createPdoAdapter(); + + if (! empty($this->config->charset)) { + $this->exec(sprintf('SET NAMES %s', $this->pdo->quote($this->config->charset))); + } + + $this->adapter->setClientTimezone($this); + + return $this; + } + + /** + * Disconnect from the database + * + * @return $this + */ + public function disconnect() + { + $this->pdo = null; + + return $this; + } + + /** + * Check whether the connection to the database is still available + * + * @param bool $reconnect Whether to automatically reconnect + * + * @return bool + */ + public function ping($reconnect = true) + { + try { + $this->query('SELECT 1')->closeCursor(); + } catch (Exception $e) { + if (! $reconnect) { + return false; + } + + $this->disconnect(); + + return $this->ping(false); + } + + return true; + } + + /** + * Fetch and return all result rows as sequential array + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchAll($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(); + } + + /** + * Fetch and return the first column of all result rows as sequential array + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchCol($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch and return the first row of the result rows + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchOne($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetch(); + } + + /** + * Alias of {@link fetchOne()} + */ + public function fetchRow($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetch(); + } + + /** + * Fetch and return all result rows as an array of key-value pairs + * + * First column is the key and the second column is the value. + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchPairs($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Fetch and return the first column of the first result row + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return string + */ + public function fetchScalar($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchColumn(0); + } + + /** + * Yield each result row + * + * `Connection::yieldAll(Select|string $stmt [[, array $values], int $fetchMode [, mixed ...$fetchModeOptions]])` + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param mixed ...$args Values to bind to the statement, fetch mode for the statement, fetch mode options + * + * @return \Generator + */ + public function yieldAll($stmt, ...$args) + { + $values = null; + + if (! empty($args)) { + if (is_array($args[0])) { + $values = array_shift($args); + } + } + + $fetchMode = null; + + if (! empty($args)) { + $fetchMode = array_shift($args); + + switch ($fetchMode) { + case PDO::FETCH_KEY_PAIR: + foreach ($this->yieldPairs($stmt, $values) as $key => $value) { + yield $key => $value; + } + + return; + case PDO::FETCH_COLUMN: + if (empty($args)) { + $args[] = 0; + } + + break; + } + } + + $sth = $this->prepexec($stmt, $values); + + if ($fetchMode !== null) { + $sth->setFetchMode($fetchMode, ...$args); + } + + foreach ($sth as $key => $row) { + yield $key => $row; + } + } + + /** + * Yield the first column of each result row + * + * @param Select|string $stmt The SQL statement to prepare and execute + * @param array $values Values to bind to the statement + * + * @return \Generator + */ + public function yieldCol($stmt, array $values = null) + { + $sth = $this->prepexec($stmt, $values); + + $sth->setFetchMode(PDO::FETCH_COLUMN, 0); + + foreach ($sth as $key => $row) { + yield $key => $row; + } + } + + /** + * Yield key-value pairs with the first column as key and the second column as value for each result row + * + * @param Select|string $stmt The SQL statement to prepare and execute + * @param array $values Values to bind to the statement + * + * @return \Generator + */ + public function yieldPairs($stmt, array $values = null) + { + $sth = $this->prepexec($stmt, $values); + + $sth->setFetchMode(PDO::FETCH_NUM); + + foreach ($sth as $row) { + list($key, $value) = $row; + + yield $key => $value; + } + } + + /** + * Prepare and execute the given statement + * + * @param Delete|Insert|Select|Update|string $stmt The SQL statement to prepare and execute + * @param string|array $values Values to bind to the statement, if any + * + * @return PDOStatement + */ + public function prepexec($stmt, $values = null) + { + if ($values !== null && ! is_array($values)) { + $values = [$values]; + } + + if (is_object($stmt)) { + list($stmt, $values) = $this->getQueryBuilder()->assemble($stmt); + } + + $this->connect(); + + $sth = $this->pdo->prepare($stmt); + $sth->execute($values); + + return $sth; + } + + /** + * Prepare and execute the given Select query + * + * @param Select $select + * + * @return PDOStatement + */ + public function select(Select $select) + { + list($stmt, $values) = $this->getQueryBuilder()->assembleSelect($select); + + return $this->prepexec($stmt, $values); + } + + /** + * Insert a table row with the specified data + * + * @param string $table The table to insert data into. The table specification must be in + * one of the following formats: 'table' or 'schema.table' + * @param iterable $data Row data in terms of column-value pairs + * + * @return PDOStatement + * + * @throws InvalidArgumentException If data type is invalid + */ + public function insert($table, $data) + { + $insert = (new Insert()) + ->into($table) + ->values($data); + + return $this->prepexec($insert); + } + + /** + * Update table rows with the specified data, optionally based on a given condition + * + * @param string|array $table The table to update. The table specification must be in one of + * the following formats: + * 'table', 'table alias', ['alias' => 'table'] + * @param iterable $data The columns to update in terms of column-value pairs + * @param mixed $condition The WHERE condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return PDOStatement + * + * @throws InvalidArgumentException If data type is invalid + */ + public function update($table, $data, $condition = null, $operator = Sql::ALL) + { + $update = (new Update()) + ->table($table) + ->set($data); + + if ($condition !== null) { + $update->where($condition, $operator); + } + + return $this->prepexec($update); + } + + /** + * Delete table rows, optionally based on a given condition + * + * @param string|array $table The table to delete data from. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * @param mixed $condition The WHERE condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return PDOStatement + */ + public function delete($table, $condition = null, $operator = Sql::ALL) + { + $delete = (new Delete()) + ->from($table); + + if ($condition !== null) { + $delete->where($condition, $operator); + } + + return $this->prepexec($delete); + } + + /** + * Begin a transaction + * + * @return bool Whether the transaction was started successfully + */ + public function beginTransaction() + { + $this->connect(); + + return $this->pdo->beginTransaction(); + } + + /** + * Commit a transaction + * + * @return bool Whether the transaction was committed successfully + */ + public function commitTransaction() + { + return $this->pdo->commit(); + } + + /** + * Roll back a transaction + * + * @return bool Whether the transaction was rolled back successfully + */ + public function rollBackTransaction() + { + return $this->pdo->rollBack(); + } + + /** + * Run the given callback in a transaction + * + * @param callable $callback The callback to run in a transaction. + * This connection instance is passed as parameter to the callback + * + * @return mixed The return value of the callback + * + * @throws Exception If an error occurs when running the callback + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + + try { + $result = call_user_func($callback, $this); + $this->commitTransaction(); + } catch (Exception $e) { + $this->rollBackTransaction(); + + throw $e; + } + + return $result; + } + + public function quoteIdentifier($identifier) + { + return $this->getAdapter()->quoteIdentifier($identifier); + } +} diff --git a/vendor/ipl/sql/src/Contract/Adapter.php b/vendor/ipl/sql/src/Contract/Adapter.php new file mode 100644 index 0000000..6142626 --- /dev/null +++ b/vendor/ipl/sql/src/Contract/Adapter.php @@ -0,0 +1,46 @@ +<?php + +namespace ipl\Sql\Contract; + +use ipl\Sql\Config; +use ipl\Sql\Connection; +use ipl\Sql\QueryBuilder; + +interface Adapter extends Quoter +{ + /** + * Get the DSN string from the given connection configuration + * + * @param Config $config + * + * @return string + */ + public function getDsn(Config $config); + + /** + * Get the PDO connect options based on the specified connection configuration + * + * @param Config $config + * + * @return array + */ + public function getOptions(Config $config); + + /** + * Set the client time zone + * + * @param Connection $db + * + * @return $this + */ + public function setClientTimezone(Connection $db); + + /** + * Register callbacks for query builder events + * + * @param QueryBuilder $queryBuilder + * + * @return $this + */ + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder); +} diff --git a/vendor/ipl/sql/src/Contract/Quoter.php b/vendor/ipl/sql/src/Contract/Quoter.php new file mode 100644 index 0000000..79c4c78 --- /dev/null +++ b/vendor/ipl/sql/src/Contract/Quoter.php @@ -0,0 +1,21 @@ +<?php + +namespace ipl\Sql\Contract; + +interface Quoter +{ + /** + * Quote an identifier so that it can be safely used as table or column name, even if it is a reserved name + * + * If a string is passed that contains dots, the parts separated by them are quoted individually. + * (e.g. `myschema.mytable` turns into `"myschema"."mytable"`) If an array is passed, the entries + * are quoted as-is. (e.g. `[myschema.my, table]` turns into `"myschema.my"."table"`) + * + * The quote character depends on the underlying database adapter that is being used. + * + * @param string|string[] $identifiers + * + * @return string + */ + public function quoteIdentifier($identifiers); +} diff --git a/vendor/ipl/sql/src/Cursor.php b/vendor/ipl/sql/src/Cursor.php new file mode 100644 index 0000000..85c5b1c --- /dev/null +++ b/vendor/ipl/sql/src/Cursor.php @@ -0,0 +1,106 @@ +<?php + +namespace ipl\Sql; + +use ipl\Stdlib\Contract\Paginatable; +use IteratorAggregate; +use Traversable; + +/** + * Cursor for ipl SQL queries + */ +class Cursor implements IteratorAggregate, Paginatable +{ + /** @var Connection */ + protected $db; + + /** @var Select */ + protected $select; + + /** @var array */ + protected $fetchModeAndArgs = []; + + /** + * Create a new cursor for the given connection and query + * + * @param Connection $db + * @param Select $select + */ + public function __construct(Connection $db, Select $select) + { + $this->db = $db; + $this->select = $select; + } + + /** + * Get the fetch mode + * + * @return array + */ + public function getFetchMode() + { + return $this->fetchModeAndArgs; + } + + /** + * Set the fetch mode + * + * @param int $fetchMode Fetch mode as one of the PDO fetch mode constants. + * Please see {@link https://www.php.net/manual/en/pdostatement.setfetchmode} for details + * @param mixed ...$args Fetch mode arguments + * + * @return $this + */ + public function setFetchMode($fetchMode, ...$args) + { + array_unshift($args, $fetchMode); + + $this->fetchModeAndArgs = $args; + + return $this; + } + + public function getIterator(): Traversable + { + return $this->db->yieldAll($this->select, ...$this->getFetchMode()); + } + + public function hasLimit() + { + return $this->select->hasLimit(); + } + + public function getLimit() + { + return $this->select->getLimit(); + } + + public function limit($limit) + { + $this->select->limit($limit); + + return $this; + } + + public function hasOffset() + { + return $this->select->hasOffset(); + } + + public function getOffset() + { + return $this->select->getOffset(); + } + + public function offset($offset) + { + $this->select->offset($offset); + + return $this; + } + + public function count(): int + { + return $this->db->select($this->select->getCountQuery())->fetchColumn(0); + } +} diff --git a/vendor/ipl/sql/src/Delete.php b/vendor/ipl/sql/src/Delete.php new file mode 100644 index 0000000..53736b8 --- /dev/null +++ b/vendor/ipl/sql/src/Delete.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Sql; + +/** + * SQL DELETE query + */ +class Delete implements CommonTableExpressionInterface, WhereInterface +{ + use CommonTableExpression; + use Where; + + /** @var array|null The FROM part of the DELETE query */ + protected $from; + + /** + * Get the FROM part of the DELETE query + * + * @return array|null + */ + public function getFrom() + { + return $this->from; + } + + /** + * Set the FROM part of the DELETE query + * + * Note that this method does NOT quote the table you specify for the DELETE FROM. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|array $table The table to delete data from. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * + * @return $this + */ + public function from($table) + { + $this->from = ! is_array($table) ? [$table] : $table; + + return $this; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneWhere(); + } +} diff --git a/vendor/ipl/sql/src/Expression.php b/vendor/ipl/sql/src/Expression.php new file mode 100644 index 0000000..83c10bd --- /dev/null +++ b/vendor/ipl/sql/src/Expression.php @@ -0,0 +1,54 @@ +<?php + +namespace ipl\Sql; + +/** + * A database expression that does need quoting or escaping, e.g. new Expression('NOW()'); + */ +class Expression implements ExpressionInterface +{ + /** @var string The statement of the expression */ + protected $statement; + + /** @var array The columns used by the expression */ + protected $columns; + + /** @var array The values for the expression */ + protected $values; + + /** + * Create a new database expression + * + * @param string $statement The statement of the expression + * @param array $columns The columns used by the expression + * @param mixed ...$values The values for the expression + */ + public function __construct($statement, array $columns = null, ...$values) + { + $this->statement = $statement; + $this->columns = $columns; + $this->values = $values; + } + + public function getStatement() + { + return $this->statement; + } + + public function getColumns() + { + return $this->columns ?: []; + } + + public function setColumns(array $columns) + { + $this->columns = $columns; + + return $this; + } + + public function getValues() + { + return $this->values; + } +} diff --git a/vendor/ipl/sql/src/ExpressionInterface.php b/vendor/ipl/sql/src/ExpressionInterface.php new file mode 100644 index 0000000..9ebe5ee --- /dev/null +++ b/vendor/ipl/sql/src/ExpressionInterface.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for database expressions that do need quoting or escaping, e.g. new Expression('NOW()'); + */ +interface ExpressionInterface +{ + /** + * Get the statement of the expression + * + * @return string + */ + public function getStatement(); + + /** + * Get the columns used by the expression + * + * @return array + */ + public function getColumns(); + + /** + * Set the columns to use by the expression + * + * @param array $columns + * + * @return $this + */ + public function setColumns(array $columns); + + /** + * Get the values for the expression + * + * @return array + */ + public function getValues(); +} diff --git a/vendor/ipl/sql/src/Filter/Exists.php b/vendor/ipl/sql/src/Filter/Exists.php new file mode 100644 index 0000000..e1951d0 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/Exists.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class Exists extends Filter\Condition +{ + public function __construct(Select $select) + { + parent::__construct('', $select); + } +} diff --git a/vendor/ipl/sql/src/Filter/In.php b/vendor/ipl/sql/src/Filter/In.php new file mode 100644 index 0000000..c126af6 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/In.php @@ -0,0 +1,24 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class In extends Filter\Condition +{ + use InAndNotInUtils; + + /** + * Create a new sql IN condition + * + * @param string[]|string $column + * @param Select $select + */ + public function __construct($column, Select $select) + { + $this + ->setColumn($column) + ->setValue($select); + } +} diff --git a/vendor/ipl/sql/src/Filter/InAndNotInUtils.php b/vendor/ipl/sql/src/Filter/InAndNotInUtils.php new file mode 100644 index 0000000..6f26de1 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/InAndNotInUtils.php @@ -0,0 +1,62 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; + +trait InAndNotInUtils +{ + /** @var string[]|string */ + protected $column; + + /** @var Select */ + protected $value; + + /** + * Get the columns of this condition + * + * @return string[]|string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Set the columns of this condition + * + * @param string[]|string $column + * + * @return $this + */ + public function setColumn($column): self + { + $this->column = $column; + + return $this; + } + + /** + * Get the value of this condition + * + * @return Select + */ + public function getValue(): Select + { + return $this->value; + } + + /** + * Set the value of this condition + * + * @param Select $value + * + * @return $this + */ + public function setValue($value): self + { + $this->value = $value; + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Filter/NotExists.php b/vendor/ipl/sql/src/Filter/NotExists.php new file mode 100644 index 0000000..bb8be35 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/NotExists.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class NotExists extends Filter\Condition +{ + public function __construct(Select $select) + { + parent::__construct('', $select); + } +} diff --git a/vendor/ipl/sql/src/Filter/NotIn.php b/vendor/ipl/sql/src/Filter/NotIn.php new file mode 100644 index 0000000..cdf6241 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/NotIn.php @@ -0,0 +1,24 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class NotIn extends Filter\Condition +{ + use InAndNotInUtils; + + /** + * Create a new sql NOT IN condition + * + * @param string[]|string $column + * @param Select $select + */ + public function __construct($column, Select $select) + { + $this + ->setColumn($column) + ->setValue($select); + } +} diff --git a/vendor/ipl/sql/src/Insert.php b/vendor/ipl/sql/src/Insert.php new file mode 100644 index 0000000..738a842 --- /dev/null +++ b/vendor/ipl/sql/src/Insert.php @@ -0,0 +1,172 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; + +use function ipl\Stdlib\arrayval; + +/** + * SQL INSERT query + */ +class Insert implements CommonTableExpressionInterface +{ + use CommonTableExpression; + + /** @var string|null The table for the INSERT INTO query */ + protected $into; + + /** @var array|null The columns for which the query provides values */ + protected $columns; + + /** @var array|null The values to insert */ + protected $values; + + /** @var Select|null The select query for INSERT INTO ... SELECT queries */ + protected $select; + + /** + * Get the table for the INSERT INTo query + * + * @return string|null + */ + public function getInto() + { + return $this->into; + } + + /** + * Set the table for the INSERT INTO query + * + * Note that this method does NOT quote the table you specify for the INSERT INTO. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table name passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string $table The table to insert data into. The table specification must be in one of the following + * formats: 'table' or 'schema.table' + * + * @return $this + */ + public function into($table) + { + $this->into = $table; + + return $this; + } + + /** + * Get the columns for which the statement provides values + * + * @return array + */ + public function getColumns() + { + if (! empty($this->columns)) { + return array_keys($this->columns); + } + + if (! empty($this->values)) { + return array_keys($this->values); + } + + return []; + } + + /** + * Set the columns for which the query provides values + * + * Note that this method does NOT quote the columns you specify for the INSERT INTO. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * If you do not set the columns for which the query provides values using this method, you must pass the values to + * {@link values()} in terms of column-value pairs in order to provide the column names. + * + * @param array $columns + * + * @return $this + */ + public function columns(array $columns) + { + $this->columns = array_flip($columns); + + return $this; + } + + /** + * Get the values to insert + * + * @return array + */ + public function getValues() + { + return array_values($this->values ?: []); + } + + /** + * Set the values to INSERT INTO - either plain values or expressions or scalar subqueries + * + * If you do not set the columns for which the query provides values using {@link columns()}, you must specify + * the values in terms of column-value pairs in order to provide the column names. Please note that the same + * restriction regarding quoting applies here. If you use {@link columns()} to set the columns and specify the + * values in terms of column-value pairs, the columns from {@link columns()} will be used nonetheless. + * + * @param iterable $values List of values or associative set of column-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If values type is invalid + */ + public function values($values) + { + $this->values = arrayval($values); + + return $this; + } + + /** + * Create a INSERT INTO ... SELECT statement + * + * @param Select $select + * + * @return $this + */ + public function select(Select $select) + { + $this->select = $select; + + return $this; + } + + /** + * Get the select query for the INSERT INTO ... SELECT statement + * + * @return Select|null + */ + public function getSelect() + { + return $this->select; + } + + public function __clone() + { + $this->cloneCte(); + + if ($this->values !== null) { + foreach ($this->values as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->select !== null) { + $this->select = clone $this->select; + } + } +} diff --git a/vendor/ipl/sql/src/LimitOffset.php b/vendor/ipl/sql/src/LimitOffset.php new file mode 100644 index 0000000..99c30a2 --- /dev/null +++ b/vendor/ipl/sql/src/LimitOffset.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link LimitOffsetInterface} to allow pagination via {@link limit()} and {@link offset()} + */ +trait LimitOffset +{ + /** + * The maximum number of how many items to return + * + * If unset or lower than 0, no limit will be applied. + * + * @var int|null + */ + protected $limit; + + /** + * Offset from where to start the result set + * + * If unset or lower than 0, the result set will start from the beginning. + * + * @var int|null + */ + protected $offset; + + public function hasLimit() + { + return $this->limit !== null; + } + + public function getLimit() + { + return $this->limit; + } + + public function limit($limit) + { + if ($limit !== null) { + $limit = (int) $limit; + if ($limit < 0) { + $limit = null; + } + } + + $this->limit = $limit; + + return $this; + } + + public function resetLimit() + { + $this->limit = null; + + return $this; + } + + public function hasOffset() + { + return $this->offset !== null; + } + + public function getOffset() + { + return $this->offset; + } + + public function offset($offset) + { + if ($offset !== null) { + $offset = (int) $offset; + if ($offset <= 0) { + $offset = null; + } + } + + $this->offset = $offset; + + return $this; + } + + public function resetOffset() + { + $this->offset = null; + + return $this; + } +} diff --git a/vendor/ipl/sql/src/LimitOffsetInterface.php b/vendor/ipl/sql/src/LimitOffsetInterface.php new file mode 100644 index 0000000..94628c4 --- /dev/null +++ b/vendor/ipl/sql/src/LimitOffsetInterface.php @@ -0,0 +1,71 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for pagination via {@link limit()} and {@link offset()} + */ +interface LimitOffsetInterface +{ + /** + * Get whether a limit is configured + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit + * + * @return int|null + */ + public function getLimit(); + + /** + * Set the limit + * + * @param int|null $limit Maximum number of items to return. + * If you want to disable the limit, use null or a negative value + * + * @return $this + */ + public function limit($limit); + + /** + * Reset the limit + * + * @return $this + */ + public function resetLimit(); + + /** + * Get whether an offset is configured + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset + * + * @return int|null + */ + public function getOffset(); + + /** + * Set the offset + * + * @param int|null $offset Start result set after this many rows. + * If you want to disable the offset, use null, 0, or a negative value + * + * @return $this + */ + public function offset($offset); + + /** + * Reset the offset + * + * @return $this + */ + public function resetOffset(); +} diff --git a/vendor/ipl/sql/src/OrderBy.php b/vendor/ipl/sql/src/OrderBy.php new file mode 100644 index 0000000..a19d7c5 --- /dev/null +++ b/vendor/ipl/sql/src/OrderBy.php @@ -0,0 +1,74 @@ +<?php + +namespace ipl\Sql; + +/** + * Trait for the ORDER BY part of a query + */ +trait OrderBy +{ + /** @var ?array ORDER BY part of the query */ + protected $orderBy; + + public function hasOrderBy() + { + return $this->orderBy !== null; + } + + public function getOrderBy() + { + return $this->orderBy; + } + + public function orderBy($orderBy, $direction = null) + { + if (! is_array($orderBy)) { + $orderBy = [$orderBy]; + } + + foreach ($orderBy as $column => $dir) { + if (is_int($column)) { + $column = $dir; + $dir = $direction; + } + + if (is_array($column) && count($column) === 2) { + list($column, $dir) = $column; + } + + if ($dir === SORT_ASC) { + $dir = 'ASC'; + } elseif ($dir === SORT_DESC) { + $dir = 'DESC'; + } + + $this->orderBy[] = [$column, $dir]; + } + + return $this; + } + + public function resetOrderBy() + { + $this->orderBy = null; + + return $this; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneOrderBy() + { + if ($this->orderBy !== null) { + foreach ($this->orderBy as &$orderBy) { + if ($orderBy[0] instanceof ExpressionInterface || $orderBy[0] instanceof Select) { + $orderBy[0] = clone $orderBy[0]; + } + } + unset($orderBy); + } + } +} diff --git a/vendor/ipl/sql/src/OrderByInterface.php b/vendor/ipl/sql/src/OrderByInterface.php new file mode 100644 index 0000000..0ee0dda --- /dev/null +++ b/vendor/ipl/sql/src/OrderByInterface.php @@ -0,0 +1,51 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for the ORDER BY part of a query + */ +interface OrderByInterface +{ + /** + * Get whether a ORDER BY part is configured + * + * @return bool + */ + public function hasOrderBy(); + + /** + * Get the ORDER BY part of the query + * + * @return array|null + */ + public function getOrderBy(); + + /** + * Set the ORDER BY part of the query - either plain columns or expressions or scalar subqueries + * + * Note that this method does not override an already set ORDER BY part. Instead, each call to this function + * appends the specified ORDER BY part to an already existing one. + * + * This method does NOT quote the columns you specify for the ORDER BY. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|int|array $orderBy The ORDER BY part. The items can be in any format of the following: + * ['column', 'column' => 'DESC', 'column' => SORT_DESC, ['column', 'DESC']] + * @param string|int $direction The default direction. Can be any of the following: + * 'ASC', 'DESC', SORT_ASC, SORT_DESC + * + * @return $this + */ + public function orderBy($orderBy, $direction = null); + + /** + * Reset the ORDER BY part of the query + * + * @return $this + */ + public function resetOrderBy(); +} diff --git a/vendor/ipl/sql/src/QueryBuilder.php b/vendor/ipl/sql/src/QueryBuilder.php new file mode 100644 index 0000000..07b5e3e --- /dev/null +++ b/vendor/ipl/sql/src/QueryBuilder.php @@ -0,0 +1,907 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; +use ipl\Sql\Adapter\Mssql; +use ipl\Sql\Contract\Adapter; +use ipl\Stdlib\Events; + +use function ipl\Stdlib\get_php_type; + +class QueryBuilder +{ + use Events; + + /** + * Event raised when a {@link Select} object is assembled into a SQL statement string + * + * The {@link Select} object is passed as parameter to the event callbacks. + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) { + * // ... + * }); + * ``` + */ + public const ON_ASSEMBLE_SELECT = 'assembleSelect'; + + /** + * Event raised after a {@link Select} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the values to bind to the statement are passed as parameters by reference + * to the event callbacks. + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_SELECT_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + */ + public const ON_SELECT_ASSEMBLED = 'selectAssembled'; + + /** + * Event raised before an {@see Insert} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_INSERT, function (Insert $insert) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_ASSEMBLE_INSERT = 'assembleInsert'; + + /** + * Event raised after an {@see Insert} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_INSERT_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_INSERT_ASSEMBLED = 'insertAssembled'; + + /** + * Event raised before an {@see Update} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_UPDATE, function (Update $update) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_ASSEMBLE_UPDATE = 'assembleUpdate'; + + /** + * Event raised after an {@see Update} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_UPDATE_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_UPDATE_ASSEMBLED = 'updateAssembled'; + + /** + * Event raised before a {@see Delete} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_DELETE, function (Delete $delete) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_ASSEMBLE_DELETE = 'assembleDelete'; + + /** + * Event raised after a {@see Delete} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_DELETE_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + public const ON_DELETE_ASSEMBLED = 'deleteAssembled'; + + /** @var Adapter */ + protected $adapter; + + protected $separator = " "; + + /** + * Create a new query builder for the specified database adapter + * + * @param Adapter $adapter + */ + public function __construct(Adapter $adapter) + { + $adapter->registerQueryBuilderCallbacks($this); + + $this->adapter = $adapter; + } + + /** + * Assemble the given statement + * + * @param Delete|Insert|Select|Update $stmt + * + * @return array + * + * @throw InvalidArgumentException If statement type is invalid + */ + public function assemble($stmt) + { + switch (true) { + case $stmt instanceof Delete: + return $this->assembleDelete($stmt); + case $stmt instanceof Insert: + return $this->assembleInsert($stmt); + case $stmt instanceof Select: + return $this->assembleSelect($stmt); + case $stmt instanceof Update: + return $this->assembleUpdate($stmt); + default: + throw new InvalidArgumentException(sprintf( + __METHOD__ . ' expects instances of Delete, Insert, Select or Update. Got %s instead.', + get_php_type($stmt) + )); + } + } + + /** + * Assemble a DELETE query + * + * @param Delete $delete + * + * @return array + */ + public function assembleDelete(Delete $delete) + { + $values = []; + + $this->emit(self::ON_ASSEMBLE_DELETE, [$delete]); + + $sql = array_filter([ + $this->buildWith($delete->getWith(), $values), + $this->buildDeleteFrom($delete->getFrom()), + $this->buildWhere($delete->getWhere(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_DELETE_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a INSERT statement + * + * @param Insert $insert + * + * @return array + */ + public function assembleInsert(Insert $insert) + { + $values = []; + + $this->emit(static::ON_ASSEMBLE_INSERT, [$insert]); + + $select = $insert->getSelect(); + + $sql = array_filter([ + $this->buildWith($insert->getWith(), $values), + $this->buildInsertInto($insert->getInto()), + $select + ? $this->buildInsertIntoSelect($insert->getColumns(), $select, $values) + : $this->buildInsertColumnsAndValues($insert->getColumns(), $insert->getValues(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_INSERT_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a SELECT query + * + * @param Select $select + * @param array $values + * + * @return array + */ + public function assembleSelect(Select $select, array &$values = []) + { + $select = clone $select; + + $this->emit(static::ON_ASSEMBLE_SELECT, [$select]); + + $sql = array_filter([ + $this->buildWith($select->getWith(), $values), + $this->buildSelect($select->getColumns(), $select->getDistinct(), $values), + $this->buildFrom($select->getFrom(), $values), + $this->buildJoin($select->getJoin(), $values), + $this->buildWhere($select->getWhere(), $values), + $this->buildGroupBy($select->getGroupBy(), $values), + $this->buildHaving($select->getHaving(), $values), + $this->buildOrderBy($select->getOrderBy(), $values), + $this->buildLimitOffset($select->getLimit(), $select->getOffset()) + ]); + + $sql = implode($this->separator, $sql); + + $unions = $this->buildUnions($select->getUnion(), $values); + if ($unions) { + list($unionKeywords, $selects) = $unions; + + if ($sql) { + $sql = "($sql)"; + + $requiresUnionKeyword = true; + } else { + $requiresUnionKeyword = false; + } + + do { + $unionKeyword = array_shift($unionKeywords); + $select = array_shift($selects); + + if ($requiresUnionKeyword) { + $sql .= "{$this->separator}$unionKeyword{$this->separator}"; + } + + $sql .= "($select)"; + + $requiresUnionKeyword = true; + } while (! empty($unionKeywords)); + } + + $this->emit(static::ON_SELECT_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a UPDATE query + * + * @param Update $update + * + * @return array + */ + public function assembleUpdate(Update $update) + { + $values = []; + + $this->emit(self::ON_ASSEMBLE_UPDATE, [$update]); + + $sql = array_filter([ + $this->buildWith($update->getWith(), $values), + $this->buildUpdateTable($update->getTable()), + $this->buildUpdateSet($update->getSet(), $values), + $this->buildWhere($update->getWhere(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_UPDATE_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Build the WITH part of a query + * + * @param array $with + * @param array $values + * + * @return string The WITH part of a query + */ + public function buildWith(array $with, array &$values) + { + if (empty($with)) { + return ''; + } + + $ctes = []; + $hasRecursive = false; + + foreach ($with as $cte) { + list($query, $alias, $recursive) = $cte; + list($cteSql, $cteValues) = $this->assembleSelect($query); + + $ctes[] = "$alias AS ($cteSql)"; + + $values = array_merge($values, $cteValues); + $hasRecursive |= $recursive; + } + + return ($hasRecursive ? 'WITH RECURSIVE ' : 'WITH ') . implode(', ', $ctes); + } + + /** + * Build the DELETE FROM part of a query + * + * @param array $from + * + * @return string The DELETE FROM part of a query + */ + public function buildDeleteFrom(array $from = null) + { + if ($from === null) { + return ''; + } + + $deleteFrom = 'DELETE FROM'; + + reset($from); + $alias = key($from); + $table = current($from); + + if (is_int($alias)) { + $deleteFrom .= " $table"; + } else { + $deleteFrom .= " $table $alias"; + } + + return $deleteFrom; + } + + /** + * Outsourced logic of {@link buildCondition()} + * + * @param string $expression + * @param array $values + * + * @return array + */ + public function unpackCondition($expression, array $values) + { + $placeholders = preg_match_all('/(\?)/', $expression, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + if ($placeholders === 0) { + return [$expression, []]; + } + + if ($placeholders === 1) { + $offset = $matches[0][1][1]; + $expression = substr($expression, 0, $offset) + . implode(', ', array_fill(0, count($values), '?')) + . substr($expression, $offset + 1); + + return [$expression, $values]; + } + + $unpackedExpression = []; + $unpackedValues = []; + $offset = 0; + + foreach ($matches as $match) { + $value = array_shift($values); + $unpackedExpression[] = substr($expression, $offset, $match[1][1] - $offset); + if (is_array($value)) { + $unpackedExpression[] = implode(', ', array_fill(0, count($value), '?')); + $unpackedValues = array_merge($unpackedValues, $value); + } else { + $unpackedExpression[] = '?'; + $unpackedValues[] = $value; + } + $offset = $match[1][1] + 1; // 1 is the length of '?' + } + + $unpackedExpression[] = substr($expression, $offset); + + return [implode('', array_filter($unpackedExpression)), $unpackedValues]; + } + + /** + * Outsourced logic {@link buildWhere()} and {@link buildHaving()} have in common + * + * @param array $condition + * @param array $values + * + * @return string + */ + public function buildCondition(array $condition, array &$values) + { + $sql = []; + + $operator = array_shift($condition); + $conditions = array_shift($condition); + + foreach ($conditions as $expression => $value) { + if (is_array($value)) { + if (is_int($expression)) { + // Operator format + $sql[] = $this->buildCondition($value, $values); + } else { + list($unpackedExpression, $unpackedValues) = $this->unpackCondition($expression, $value); + $sql[] = $unpackedExpression; + $values = array_merge($values, $unpackedValues); + } + } else { + if ($value instanceof ExpressionInterface) { + $sql[] = $this->buildExpression($value, $values); + } elseif ($value instanceof Select) { + $stmt = '(' . $this->assembleSelect($value, $values)[0] . ')'; + if (is_int($expression)) { + $sql[] = $stmt; + } else { + $sql[] = str_replace('?', $stmt, $expression); + } + } elseif (is_int($expression)) { + $sql[] = $value; + } else { + $sql[] = $expression; + $values[] = $value; + } + } + } + + if ($operator === Sql::NOT_ALL || $operator === Sql::NOT_ANY) { + return 'NOT (' . implode(") $operator (", $sql) . ')'; + } + + return count($sql) === 1 ? $sql[0] : '(' . implode(") $operator (", $sql) . ')'; + } + + /** + * Build the WHERE part of a query + * + * @param array $where + * @oaram array $values + * + * @return string The WHERE part of the query + */ + public function buildWhere(array $where = null, array &$values = []) + { + if ($where === null) { + return ''; + } + + return 'WHERE ' . $this->buildCondition($where, $values); + } + + /** + * Build the INSERT INTO part of a INSERT INTO ... statement + * + * @param string|null $into + * + * @return string The INSERT INTO part of a INSERT INTO ... statement + */ + public function buildInsertInto($into) + { + if (empty($into)) { + return ''; + } + + return "INSERT INTO $into"; + } + + /** + * Build the columns and SELECT part of a INSERT INTO ... SELECT statement + * + * @param array $columns + * @param Select $select + * @param array $values + * + * @return string The columns and SELECT part of the INSERT INTO ... SELECT statement + */ + public function buildInsertIntoSelect(array $columns, Select $select, array &$values) + { + $sql = [ + '(' . implode(',', $columns) . ')', + $this->assembleSelect($select, $values)[0] + ]; + + return implode($this->separator, $sql); + } + + /** + * Build the columns and values part of a INSERT INTO ... statement + * + * @param array $columns + * @param array $insertValues + * @param array $values + * + * @return string The columns and values part of a INSERT INTO ... statement + */ + public function buildInsertColumnsAndValues(array $columns, array $insertValues, array &$values) + { + $sql = ['(' . implode(',', $columns) . ')']; + + $preparedValues = []; + + foreach ($insertValues as $value) { + if ($value instanceof ExpressionInterface) { + $preparedValues[] = $this->buildExpression($value, $values); + } elseif ($value instanceof Select) { + $preparedValues[] = "({$this->assembleSelect($value, $values)[0]})"; + } else { + $preparedValues[] = '?'; + $values[] = $value; + } + } + + $sql[] = 'VALUES(' . implode(',', $preparedValues) . ')'; + + return implode($this->separator, $sql); + } + + /** + * Build the SELECT part of a query + * + * @param array $columns + * @param bool $distinct + * @param array $values + * + * @return string The SELECT part of the query + */ + public function buildSelect(array $columns, $distinct, array &$values) + { + if (empty($columns)) { + return ''; + } + + $select = 'SELECT'; + + if ($distinct) { + $select .= ' DISTINCT'; + } + + $sql = []; + + foreach ($columns as $alias => $column) { + if ($column instanceof ExpressionInterface) { + $column = "({$this->buildExpression($column, $values)})"; + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + + if (is_int($alias)) { + $sql[] = $column; + } else { + $sql[] = "$column AS $alias"; + } + } + + return "$select " . implode(', ', $sql); + } + + /** + * Build the FROM part of a query + * + * @param array $from + * @param array $values + * + * @return string The FROM part of the query + */ + public function buildFrom(array $from = null, array &$values = []) + { + if ($from === null) { + return ''; + } + + $sql = []; + + foreach ($from as $alias => $table) { + if ($table instanceof Select) { + $table = "({$this->assembleSelect($table, $values)[0]})"; + } + + if (is_int($alias) || $alias === $table) { + $sql[] = $table; + } else { + $sql[] = "$table $alias"; + } + } + + return 'FROM ' . implode(', ', $sql); + } + + /** + * Build the JOIN part(s) of a query + * + * @param array $joins + * @oaram array $values + * + * @return string The JOIN part(s) of the query + */ + public function buildJoin($joins, array &$values) + { + if ($joins === null) { + return ''; + } + + $sql = []; + foreach ($joins as $join) { + list($joinType, $table, $condition) = $join; + + if (is_array($table)) { + $tableName = null; + foreach ($table as $alias => $tableName) { + break; + } + } else { + $alias = null; + $tableName = $table; + } + + if ($tableName instanceof Select) { + $tableName = "({$this->assembleSelect($tableName, $values)[0]})"; + } + + if (is_array($condition)) { + $condition = $this->buildCondition($condition, $values); + } + + if (empty($alias) || $alias === $tableName) { + $sql[] = "$joinType JOIN $tableName ON $condition"; + } else { + $sql[] = "$joinType JOIN $tableName $alias ON $condition"; + } + } + + return implode($this->separator, $sql); + } + + /** + * Build the GROUP BY part of a query + * + * @param array $groupBy + * @param array $values + * + * @return string The GROUP BY part of the query + */ + public function buildGroupBy(array $groupBy = null, array &$values = []) + { + if ($groupBy === null) { + return ''; + } + + foreach ($groupBy as &$column) { + if ($column instanceof ExpressionInterface) { + $column = $this->buildExpression($column, $values); + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + } + + return 'GROUP BY ' . implode(', ', $groupBy); + } + + /** + * Build the HAVING part of a query + * + * @param array $having + * @param array $values + * + * @return string The HAVING part of the query + */ + public function buildHaving(array $having = null, array &$values = []) + { + if ($having === null) { + return ''; + } + + return 'HAVING ' . $this->buildCondition($having, $values); + } + + /** + * Build the ORDER BY part of a query + * + * @param array $orderBy + * @param array $values + * + * @return string The ORDER BY part of the query + */ + public function buildOrderBy(array $orderBy = null, array &$values = []) + { + if ($orderBy === null) { + return ''; + } + + $sql = []; + + foreach ($orderBy as $column) { + list($column, $direction) = $column; + + if ($column instanceof ExpressionInterface) { + $column = $this->buildExpression($column, $values); + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + + if ($direction !== null) { + $sql[] = "$column $direction"; + } else { + $sql[] = $column; + } + } + + return 'ORDER BY ' . implode(', ', $sql); + } + + /** + * Build the LIMIT and OFFSET part of a query + * + * @param int $limit + * @param int $offset + * + * @return string The LIMIT and OFFSET part of the query + */ + public function buildLimitOffset($limit = null, $offset = null) + { + $sql = []; + + if ($this->adapter instanceof Mssql) { + if ($offset !== null || $limit !== null) { + // If offset is null, sprintf will convert it to 0 + $sql[] = sprintf('OFFSET %d ROWS', $offset); + } + + if ($limit !== null) { + // FETCH FIRST n ROWS ONLY for OFFSET 0 would be an alternative here + $sql[] = "FETCH NEXT $limit ROWS ONLY"; + } + } else { + if ($limit !== null) { + $sql[] = "LIMIT $limit"; + } + + if ($offset !== null) { + $sql[] = "OFFSET $offset"; + } + } + + return implode($this->separator, $sql); + } + + /** + * Build the UNION parts of a query + * + * @param array $unions + * @param array $values + * + * @return array|null The UNION parts of the query + */ + public function buildUnions(array $unions = null, array &$values = []) + { + if ($unions === null) { + return null; + } + + $unionKeywords = []; + $selects = []; + + foreach ($unions as $union) { + list($select, $all) = $union; + + if ($select instanceof Select) { + list($select, $values) = $this->assembleSelect($select, $values); + } + + $unionKeywords[] = ($all ? 'UNION ALL' : 'UNION'); + $selects[] = $select; + } + + return [$unionKeywords, $selects]; + } + + /** + * Build the UPDATE {table} part of a query + * + * @param array $updateTable The table to UPDATE + * + * @return string The UPDATE {table} part of the query + */ + public function buildUpdateTable(array $updateTable = null) + { + if ($updateTable === null) { + return ''; + } + + $update = 'UPDATE'; + + reset($updateTable); + $alias = key($updateTable); + $table = current($updateTable); + + if (is_int($alias)) { + $update .= " $table"; + } else { + $update .= " $table $alias"; + } + + return $update; + } + + /** + * Build the SET part of a UPDATE query + * + * @param array $set + * @param array $values + * + * @return string The SET part of a UPDATE query + */ + public function buildUpdateSet(array $set = null, array &$values = []) + { + if (empty($set)) { + return ''; + } + + $sql = []; + + foreach ($set as $column => $value) { + if ($value instanceof ExpressionInterface) { + $sql[] = "$column = {$this->buildExpression($value, $values)}"; + } elseif ($value instanceof Select) { + $sql[] = "$column = ({$this->assembleSelect($value, $values)[0]})"; + } else { + $sql[] = "$column = ?"; + $values[] = $value; + } + } + + return 'SET ' . implode(', ', $sql); + } + + /** + * Build expression + * + * @param ExpressionInterface $expression + * @param array $values + * + * @return string The expression's statement + */ + public function buildExpression(ExpressionInterface $expression, array &$values = []) + { + $stmt = $expression->getStatement(); + $columns = $expression->getColumns(); + if (! empty($columns)) { + $stmt = vsprintf($stmt, $columns); + } + + $values = array_merge($values, $expression->getValues()); + + return $stmt; + } +} diff --git a/vendor/ipl/sql/src/Select.php b/vendor/ipl/sql/src/Select.php new file mode 100644 index 0000000..f56a131 --- /dev/null +++ b/vendor/ipl/sql/src/Select.php @@ -0,0 +1,562 @@ +<?php + +namespace ipl\Sql; + +/** + * SQL SELECT query + */ +class Select implements CommonTableExpressionInterface, LimitOffsetInterface, OrderByInterface, WhereInterface +{ + use CommonTableExpression; + use LimitOffset; + use OrderBy; + use Where; + + /** @var bool Whether the query is DISTINCT */ + protected $distinct = false; + + /** @var array|null The columns for the SELECT query */ + protected $columns; + + /** @var array|null FROM part of the query, i.e. the table names to select data from */ + protected $from; + + /** + * The tables to JOIN + * + * [ + * [ $joinType, $tableName, $condition ], + * ... + * ] + * + * @var ?array + */ + protected $join; + + /** @var array|null The columns for the GROUP BY part of the query */ + protected $groupBy; + + /** @var array|null Internal representation for the HAVING part of the query */ + protected $having; + + /** + * The queries to UNION + * + * [ + * [ new Select(), (bool) 'UNION ALL' ], + * ... + * ] + * + * @var ?array + */ + protected $union; + + /** + * Get whether to SELECT DISTINCT + * + * @return bool + */ + public function getDistinct() + { + return $this->distinct; + } + + /** + * Set whether to SELECT DISTINCT + * + * @param bool $distinct + * + * @return $this + */ + public function distinct($distinct = true) + { + $this->distinct = $distinct; + + return $this; + } + + /** + * Get the columns for the SELECT query + * + * @return array + */ + public function getColumns() + { + return $this->columns ?: []; + } + + /** + * Add columns to the SELECT query + * + * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query. + * + * Note that this method does NOT quote the columns you specify for the SELECT. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|ExpressionInterface|Select|array $columns The column(s) to add to the SELECT. + * The items can be any mix of the following: 'column', + * 'column as alias', ['alias' => 'column'] + * + * @return $this + */ + public function columns($columns) + { + if (! is_array($columns)) { + $columns = [$columns]; + } + + $this->columns = array_merge($this->columns ?: [], $columns); + + return $this; + } + + /** + * Get the FROM part of the query + * + * @return array|null + */ + public function getFrom() + { + return $this->from; + } + + /** + * Add a FROM part to the query + * + * Multiple calls to this method will not overwrite the previous set FROM part but append the tables to the FROM. + * + * Note that this method does NOT quote the tables you specify for the FROM. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|Select|array $tables The table(s) to add to the FROM part. The items can be any mix of the + * following: ['table', 'table alias', 'alias' => 'table'] + * + * @return $this + */ + public function from($tables) + { + if (! is_array($tables)) { + $tables = [$tables]; + } + + $this->from = array_merge($this->from ?: [], $tables); + + return $this; + } + + /** + * Get the JOIN part(s) of the query + * + * @return array|null + */ + public function getJoin() + { + return $this->join; + } + + /** + * Add a INNER JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function join($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['INNER', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Add a LEFT JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function joinLeft($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['LEFT', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Add a RIGHT JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function joinRight($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['RIGHT', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Get the GROUP BY part of the query + * + * @return array|null + */ + public function getGroupBy() + { + return $this->groupBy; + } + + /** + * Add a GROUP BY part to the query - either plain columns or expressions or scalar subqueries + * + * This method does NOT quote the columns you specify for the GROUP BY. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set GROUP BY part. Instead, multiple calls to this function + * add the specified GROUP BY part. + * + * @param string|ExpressionInterface|Select|array $groupBy + * + * @return $this + */ + public function groupBy($groupBy) + { + $this->groupBy = array_merge( + $this->groupBy === null ? [] : $this->groupBy, + is_array($groupBy) ? $groupBy : [$groupBy] + ); + + return $this; + } + + /** + * Get the HAVING part of the query + * + * @return array|null + */ + public function getHaving() + { + return $this->having; + } + + /** + * Add a HAVING part of the query + * + * This method lets you specify the HAVING part of the query using one of the two following supported formats: + * * String format, e.g. 'id = 1' + * * Array format, e.g. ['id' => 1, ...] + * + * This method does NOT quote the columns you specify for the HAVING. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set HAVING part. Instead, multiple calls to this function add + * the specified HAVING part using the AND operator. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function having($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::ALL); + + return $this; + } + + /** + * Add a OR part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function orHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::ANY); + + return $this; + } + + /** + * Add a AND NOT part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function notHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::NOT_ALL); + + return $this; + } + + /** + * Add a OR NOT part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function orNotHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::NOT_ANY); + + return $this; + } + + /** + * Get the UNION parts of the query + * + * @return array|null + */ + public function getUnion() + { + return $this->union; + } + + /** + * Combine a query with UNION + * + * @param Select|string $query + * + * @return $this + */ + public function union($query) + { + $this->union[] = [$query, false]; + + return $this; + } + + /** + * Combine a query with UNION ALL + * + * @param Select|string $query + * + * @return $this + */ + public function unionAll($query) + { + $this->union[] = [$query, true]; + + return $this; + } + + /** + * Reset the DISTINCT part of the query + * + * @return $this + */ + public function resetDistinct() + { + $this->distinct = false; + + return $this; + } + + /** + * Reset the columns of the query + * + * @return $this + */ + public function resetColumns() + { + $this->columns = null; + + return $this; + } + + /** + * Reset the FROM part of the query + * + * @return $this + */ + public function resetFrom() + { + $this->from = null; + + return $this; + } + + /** + * Reset the JOIN parts of the query + * + * @return $this + */ + public function resetJoin() + { + $this->join = null; + + return $this; + } + + /** + * Reset the GROUP BY part of the query + * + * @return $this + */ + public function resetGroupBy() + { + $this->groupBy = null; + + return $this; + } + + /** + * Reset the HAVING part of the query + * + * @return $this + */ + public function resetHaving() + { + $this->having = null; + + return $this; + } + + /** + * Reset queries combined with UNION and UNION ALL + * + * @return $this + */ + public function resetUnion() + { + $this->union = null; + + return $this; + } + + /** + * Get the count query + * + * @return Select + */ + public function getCountQuery() + { + $countQuery = clone $this; + + $countQuery->orderBy = null; + $countQuery->limit = null; + $countQuery->offset = null; + + if (! empty($countQuery->groupBy) || $countQuery->getDistinct()) { + $countQuery = (new Select())->from(['s' => $countQuery]); + $countQuery->distinct(false); + } + + $countQuery->columns = ['cnt' => 'COUNT(*)']; + + return $countQuery; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneOrderBy(); + $this->cloneWhere(); + + if ($this->columns !== null) { + foreach ($this->columns as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->from !== null) { + foreach ($this->from as &$from) { + if ($from instanceof Select) { + $from = clone $from; + } + } + unset($from); + } + + if ($this->join !== null) { + foreach ($this->join as &$join) { + if (is_array($join[1])) { + foreach ($join[1] as &$table) { + if ($table instanceof Select) { + $table = clone $table; + } + } + unset($table); + } elseif ($join[1] instanceof Select) { + $join[1] = clone $join[1]; + } + + $this->cloneCondition($join[2]); + } + unset($join); + } + + if ($this->groupBy !== null) { + foreach ($this->groupBy as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->having !== null) { + $this->cloneCondition($this->having); + } + + if ($this->union !== null) { + foreach ($this->union as &$union) { + $union[0] = clone $union[0]; + } + unset($union); + } + } +} diff --git a/vendor/ipl/sql/src/Sql.php b/vendor/ipl/sql/src/Sql.php new file mode 100644 index 0000000..000a43a --- /dev/null +++ b/vendor/ipl/sql/src/Sql.php @@ -0,0 +1,70 @@ +<?php + +namespace ipl\Sql; + +/** + * The SQL helper provides a set of static methods for quoting and escaping identifiers to make their use safe in SQL + * queries or fragments + */ +class Sql +{ + /** + * SQL AND operator + */ + public const ALL = 'AND'; + + /** + * SQL OR operator + */ + public const ANY = 'OR'; + + /** + * SQL AND NOT operator + */ + public const NOT_ALL = 'AND NOT'; + + /** + * SQL OR NOT operator + */ + public const NOT_ANY = 'OR NOT'; + + /** + * Create and return a DELETE statement + * + * @return Delete + */ + public static function delete() + { + return new Delete(); + } + + /** + * Create and return a INSERT statement + * + * @return Insert + */ + public static function insert() + { + return new Insert(); + } + + /** + * Create and return a SELECT statement + * + * @return Select + */ + public static function select() + { + return new Select(); + } + + /** + * Create and return a UPDATE statement + * + * @return Update + */ + public static function update() + { + return new Update(); + } +} diff --git a/vendor/ipl/sql/src/Update.php b/vendor/ipl/sql/src/Update.php new file mode 100644 index 0000000..356a610 --- /dev/null +++ b/vendor/ipl/sql/src/Update.php @@ -0,0 +1,100 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; + +use function ipl\Stdlib\arrayval; + +/** + * SQL UPDATE query + */ +class Update implements CommonTableExpressionInterface, WhereInterface +{ + use CommonTableExpression; + use Where; + + /** @var array|null The table for the UPDATE query */ + protected $table; + + /** @var array|null The columns to update in terms of column-value pairs */ + protected $set = []; + + /** + * Get the table for the UPDATE query + * + * @return array|null + */ + public function getTable() + { + return $this->table; + } + + /** + * Set the table for the UPDATE query + * + * Note that this method does NOT quote the table you specify for the UPDATE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|array $table The table to update. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * + * @return $this + */ + public function table($table) + { + $this->table = is_array($table) ? $table : [$table]; + + return $this; + } + + /** + * Get the columns to update in terms of column-value pairs + * + * @return array|null + */ + public function getSet() + { + return $this->set; + } + + /** + * Set the columns to update in terms of column-value pairs + * + * Values may either be plain or expressions or scalar subqueries. + * + * Note that this method does NOT quote the columns you specify for the UPDATE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param iterable $set Associative set of column-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If set type is invalid + */ + public function set($set) + { + $this->set = arrayval($set); + + return $this; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneWhere(); + + foreach ($this->set as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } +} diff --git a/vendor/ipl/sql/src/Where.php b/vendor/ipl/sql/src/Where.php new file mode 100644 index 0000000..f862846 --- /dev/null +++ b/vendor/ipl/sql/src/Where.php @@ -0,0 +1,158 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link WhereInterface} + */ +trait Where +{ + /** @var array|null Internal representation for the WHERE part of the query */ + protected $where; + + public function getWhere() + { + return $this->where; + } + + public function where($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::ALL); + + return $this; + } + + public function orWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::ANY); + + return $this; + } + + public function notWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::NOT_ALL); + + return $this; + } + + public function orNotWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::NOT_ANY); + + return $this; + } + + public function resetWhere() + { + $this->where = null; + + return $this; + } + + /** + * Make $condition an array and build an array like this: [$operator, [$condition]] + * + * If $condition is empty, replace it with a boolean constant depending on the operator. + * + * @param string|array $condition + * @param string $operator + * + * @return array + */ + protected function buildCondition($condition, $operator) + { + if (is_array($condition)) { + if (empty($condition)) { + $condition = [$operator === Sql::ALL ? '1' : '0']; + } elseif (in_array(reset($condition), [Sql::ALL, Sql::ANY, Sql::NOT_ALL, Sql::NOT_ANY], true)) { + return $condition; + } + } else { + $condition = [$condition]; + } + + return [$operator, $condition]; + } + + /** + * Merge the given condition with ours via the given operator + * + * @param mixed $base Our condition + * @param array $condition As returned by {@link buildCondition()} + * @param string $operator + */ + protected function mergeCondition(&$base, array $condition, $operator) + { + if ($base === null) { + $base = [$operator, [$condition]]; + } else { + if ($base[0] === $operator) { + $base[1][] = $condition; + } elseif ($operator === Sql::NOT_ALL) { + $base = [Sql::ALL, [$base, [$operator, [$condition]]]]; + } elseif ($operator === Sql::NOT_ANY) { + $base = [Sql::ANY, [$base, [$operator, [$condition]]]]; + } else { + $base = [$operator, [$base, $condition]]; + } + } + } + + /** + * Prepare condition arguments from the different supported where styles + * + * @param mixed $condition + * @param array $args + * + * @return array + */ + protected function prepareConditionArguments($condition, array $args) + { + // Default operator + $operator = Sql::ALL; + + if (! is_array($condition) && ! empty($args)) { + // Variadic + $condition = [(string) $condition => $args]; + } else { + // Array or string format + $operator = array_shift($args) ?: $operator; + } + + return [$condition, $operator]; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneWhere() + { + if ($this->where !== null) { + $this->cloneCondition($this->where); + } + } + + /** + * Clone a condition in-place + * + * @param array $condition As returned by {@link buildCondition()} + */ + protected function cloneCondition(array &$condition) + { + foreach ($condition as &$subCondition) { + if (is_array($subCondition)) { + $this->cloneCondition($subCondition); + } elseif ($subCondition instanceof ExpressionInterface || $subCondition instanceof Select) { + $subCondition = clone $subCondition; + } + } + unset($subCondition); + } +} diff --git a/vendor/ipl/sql/src/WhereInterface.php b/vendor/ipl/sql/src/WhereInterface.php new file mode 100644 index 0000000..e724465 --- /dev/null +++ b/vendor/ipl/sql/src/WhereInterface.php @@ -0,0 +1,84 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for the WHERE part of a query + */ +interface WhereInterface +{ + /** + * Get the WHERE part of the query + * + * @return array|null + */ + public function getWhere(); + + /** + * Add a WHERE part of the query + * + * This method lets you specify the WHERE part of the query using one of the two following supported formats: + * * String format, e.g. 'id = 1', i.e. `where(string $condition [, mixed ...$args])` + * * Array format, e.g. ['id = ?' => 1, ...], i.e. `where(array $condition [, string $operator])` + * + * This method does NOT quote the columns you specify for the WHERE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set WHERE part. Instead, multiple calls to this function add + * the specified WHERE part using the AND operator. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed $args If condition is a string, parameter values for placeholders in the condition can be passed. + * If condition is an array, the only argument that is allowed is the operator to use to combine + * these conditions. By default, this operator is {@link Sql::ALL} (AND) + * + * @return $this + */ + public function where($condition, ...$args); + + /** + * Add a OR part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function orWhere($condition, ...$args); + + /** + * Add a AND NOT part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function notWhere($condition, ...$args); + + /** + * Add a OR NOT part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function orNotWhere($condition, ...$args); + + /** + * Reset the WHERE part of the query + * + * @return $this + */ + public function resetWhere(); +} diff --git a/vendor/ipl/stdlib/LICENSE b/vendor/ipl/stdlib/LICENSE new file mode 100644 index 0000000..58005ec --- /dev/null +++ b/vendor/ipl/stdlib/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/stdlib/composer.json b/vendor/ipl/stdlib/composer.json new file mode 100644 index 0000000..cc93968 --- /dev/null +++ b/vendor/ipl/stdlib/composer.json @@ -0,0 +1,22 @@ +{ + "name": "ipl/stdlib", + "description": "ipl Standard Library", + "type": "library", + "license": "MIT", + "autoload": { + "files": ["src/functions_include.php"], + "psr-4": { + "ipl\\Stdlib\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Stdlib\\": "tests" + } + }, + "require": { + "php": ">=7.2", + "ext-openssl": "*", + "evenement/evenement": "^3.0.1" + } +} diff --git a/vendor/ipl/stdlib/src/BaseFilter.php b/vendor/ipl/stdlib/src/BaseFilter.php new file mode 100644 index 0000000..267decb --- /dev/null +++ b/vendor/ipl/stdlib/src/BaseFilter.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Stdlib; + +use ipl\Stdlib\Filter\Rule; + +trait BaseFilter +{ + /** @var Rule Base filter */ + private $baseFilter; + + /** + * Get whether a base filter has been set + * + * @return bool + */ + public function hasBaseFilter(): bool + { + return $this->baseFilter !== null; + } + + /** + * Get the base filter + * + * @return ?Rule + */ + public function getBaseFilter() + { + return $this->baseFilter; + } + + /** + * Set the base filter + * + * @param Rule $baseFilter + * + * @return $this + */ + public function setBaseFilter(Rule $baseFilter = null): self + { + $this->baseFilter = $baseFilter; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Contract/Filterable.php b/vendor/ipl/stdlib/src/Contract/Filterable.php new file mode 100644 index 0000000..2a6316a --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Filterable.php @@ -0,0 +1,63 @@ +<?php + +namespace ipl\Stdlib\Contract; + +use ipl\Stdlib\Filter; + +interface Filterable +{ + /** + * Get the filter of the query + * + * @return Filter\Chain + */ + public function getFilter(); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter using a {@see Filter\All} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function filter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter using a {@see Filter\Any} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function orFilter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter wrapped by a {@see Filter\None} chain and using a {@see Filter\All} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function notFilter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter wrapped by a {@see Filter\None} chain and using a {@see Filter\Any} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function orNotFilter(Filter\Rule $filter); +} diff --git a/vendor/ipl/stdlib/src/Contract/Paginatable.php b/vendor/ipl/stdlib/src/Contract/Paginatable.php new file mode 100644 index 0000000..3e2a4ee --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Paginatable.php @@ -0,0 +1,56 @@ +<?php + +namespace ipl\Stdlib\Contract; + +use Countable; + +interface Paginatable extends Countable +{ + /** + * Get whether a limit is set + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit + * + * @return int|null + */ + public function getLimit(); + + /** + * Set the limit + * + * @param int|null $limit Maximum number of items to return. If you want to disable the limit, + * it is best practice to use null or a negative value + * + * @return $this + */ + public function limit($limit); + + /** + * Get whether an offset is set + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset + * + * @return int|null + */ + public function getOffset(); + + /** + * Set the offset + * + * @param int|null $offset Start result set after this many rows. If you want to disable the offset, + * it is best practice to use null or a negative value + * + * @return $this + */ + public function offset($offset); +} diff --git a/vendor/ipl/stdlib/src/Contract/PaginationInterface.php b/vendor/ipl/stdlib/src/Contract/PaginationInterface.php new file mode 100644 index 0000000..b00fa66 --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/PaginationInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** @deprecated Use {@link Paginatable} instead */ +interface PaginationInterface extends Paginatable +{ +} diff --git a/vendor/ipl/stdlib/src/Contract/PluginLoader.php b/vendor/ipl/stdlib/src/Contract/PluginLoader.php new file mode 100644 index 0000000..1be779c --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/PluginLoader.php @@ -0,0 +1,21 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** + * Representation of plugin loaders + * + * Plugin loaders must implement the {@link load()} method in order to provide the fully qualified class name of a + * plugin to load. + */ +interface PluginLoader +{ + /** + * Load the class file for a given plugin name + * + * @param string $name Name of the plugin + * + * @return string|false FQN of the plugin's class if found, false otherwise + */ + public function load($name); +} diff --git a/vendor/ipl/stdlib/src/Contract/Translator.php b/vendor/ipl/stdlib/src/Contract/Translator.php new file mode 100644 index 0000000..85ab515 --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Translator.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** + * Representation of translators + */ +interface Translator +{ + /** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translate($message, $context = null); + + /** + * Translate a message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * @param string $domain + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translateInDomain($domain, $message, $context = null); + + /** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePlural($singular, $plural, $number, $context = null); + + /** + * Translate a plural message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $domain + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null); +} diff --git a/vendor/ipl/stdlib/src/Contract/Validator.php b/vendor/ipl/stdlib/src/Contract/Validator.php new file mode 100644 index 0000000..e43821d --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Validator.php @@ -0,0 +1,22 @@ +<?php + +namespace ipl\Stdlib\Contract; + +interface Validator +{ + /** + * Get whether the given value is valid + * + * @param mixed $value + * + * @return bool + */ + public function isValid($value); + + /** + * Get the validation error messages + * + * @return array<string> + */ + public function getMessages(); +} diff --git a/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php b/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php new file mode 100644 index 0000000..36cf55e --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** @deprecated Use {@link Validator} instead */ +interface ValidatorInterface extends Validator +{ +} diff --git a/vendor/ipl/stdlib/src/Data.php b/vendor/ipl/stdlib/src/Data.php new file mode 100644 index 0000000..b12306c --- /dev/null +++ b/vendor/ipl/stdlib/src/Data.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Stdlib; + +class Data +{ + /** @var array<string, mixed> */ + protected $data = []; + + /** + * Check whether there's any data + * + * @return bool + */ + public function isEmpty() + { + return empty($this->data); + } + + /** + * Check whether the given data exists + * + * @param string $name The name of the data + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->data); + } + + /** + * Get the value of the given data + * + * @param string $name The name of the data + * @param mixed $default The value to return if there's no such data + * + * @return mixed + */ + public function get($name, $default = null) + { + if ($this->has($name)) { + return $this->data[$name]; + } + + return $default; + } + + /** + * Set the value of the given data + * + * @param string $name The name of the data + * @param mixed $value + * + * @return $this + */ + public function set($name, $value) + { + $this->data[$name] = $value; + + return $this; + } + + /** + * Merge the given data + * + * @param Data $with + * + * @return $this + */ + public function merge(self $with) + { + $this->data = array_merge($this->data, $with->data); + + return $this; + } + + /** + * Clear all data + * + * @return $this + */ + public function clear() + { + $this->data = []; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/EventEmitter.php b/vendor/ipl/stdlib/src/EventEmitter.php new file mode 100644 index 0000000..6d189ee --- /dev/null +++ b/vendor/ipl/stdlib/src/EventEmitter.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Stdlib; + +/** @deprecated Use {@link Events} instead */ +trait EventEmitter +{ + use Events; +} diff --git a/vendor/ipl/stdlib/src/Events.php b/vendor/ipl/stdlib/src/Events.php new file mode 100644 index 0000000..3405086 --- /dev/null +++ b/vendor/ipl/stdlib/src/Events.php @@ -0,0 +1,57 @@ +<?php + +namespace ipl\Stdlib; + +use Evenement\EventEmitterTrait; +use InvalidArgumentException; + +trait Events +{ + use EventEmitterTrait { + EventEmitterTrait::on as private evenementUnvalidatedOn; + } + + /** @var array */ + protected $eventsEmittedOnce = []; + + /** + * @param string $event + * @param array $arguments + */ + protected function emitOnce($event, array $arguments = []) + { + if (! isset($this->eventsEmittedOnce[$event])) { + $this->eventsEmittedOnce[$event] = true; + $this->emit($event, $arguments); + } + } + + /** + * @param string $event + * @param callable $listener + * @return $this + */ + public function on($event, callable $listener) + { + $this->assertValidEvent($event); + $this->evenementUnvalidatedOn($event, $listener); + + return $this; + } + + protected function assertValidEvent($event) + { + if (! $this->isValidEvent($event)) { + throw new InvalidArgumentException("$event is not a valid event"); + } + } + + /** + * @param string $event + * @return bool + */ + public function isValidEvent($event) + { + return true; + } +} diff --git a/vendor/ipl/stdlib/src/Filter.php b/vendor/ipl/stdlib/src/Filter.php new file mode 100644 index 0000000..9523f1f --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter.php @@ -0,0 +1,584 @@ +<?php + +namespace ipl\Stdlib; + +use Exception; +use InvalidArgumentException; +use ipl\Stdlib\Filter\All; +use ipl\Stdlib\Filter\Any; +use ipl\Stdlib\Filter\Chain; +use ipl\Stdlib\Filter\Condition; +use ipl\Stdlib\Filter\Equal; +use ipl\Stdlib\Filter\GreaterThan; +use ipl\Stdlib\Filter\GreaterThanOrEqual; +use ipl\Stdlib\Filter\LessThan; +use ipl\Stdlib\Filter\LessThanOrEqual; +use ipl\Stdlib\Filter\Like; +use ipl\Stdlib\Filter\None; +use ipl\Stdlib\Filter\Rule; +use ipl\Stdlib\Filter\Unequal; +use ipl\Stdlib\Filter\Unlike; + +class Filter +{ + /** + * protected - This is only a factory class + */ + protected function __construct() + { + } + + /** + * Return whether the given rule matches the given item + * + * @param Rule $rule + * @param array<mixed>|object $row + * + * @return bool + */ + public static function match(Rule $rule, $row) + { + if (! is_object($row)) { + if (is_array($row)) { + $row = (object) $row; + } else { + throw new InvalidArgumentException(sprintf( + 'Object or array expected, got %s instead', + get_php_type($row) + )); + } + } + + return (new self())->performMatch($rule, $row); + } + + /** + * Create a rule that matches if **all** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function all(Rule ...$rules) + { + return new All(...$rules); + } + + /** + * Return whether the given rules all match the given item + * + * @param All $rules + * @param object $row + * + * @return bool + */ + protected function matchAll(All $rules, $row) + { + foreach ($rules as $rule) { + if (! $this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches if **any** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function any(Rule ...$rules) + { + return new Any(...$rules); + } + + /** + * Return whether any of the given rules match the given item + * + * @param Any $rules + * @param object $row + * + * @return bool + */ + protected function matchAny(Any $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return true; + } + } + + return false; + } + + /** + * Create a rule that matches if **none** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function none(Rule ...$rules) + { + return new None(...$rules); + } + + /** + * Return whether none of the given rules match the given item + * + * @param None $rules + * @param object $row + * + * @return bool + */ + protected function matchNone(None $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches rows with a column that **equals** the given value + * + * @param string $column + * @param array<mixed>|bool|float|int|string $value + * + * @return Condition + */ + public static function equal($column, $value) + { + return new Equal($column, $value); + } + + /** + * Return whether the given rule's value equals the given item's value + * + * @param Equal|Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchEqual($rule, $row) + { + if (! $rule instanceof Equal && ! $rule instanceof Unequal) { + throw new InvalidArgumentException(sprintf( + 'Rule must be of type %s or %s, got %s instead', + Equal::class, + Unequal::class, + get_php_type($rule) + )); + } + + $rowValue = $this->extractValue($rule->getColumn(), $row); + $value = $rule->getValue(); + $this->normalizeTypes($rowValue, $value); + + if (! is_array($rowValue)) { + $rowValue = [$rowValue]; + } + + foreach ($rowValue as $rowVal) { + if ($this->performEqualityMatch($value, $rowVal, $rule->ignoresCase())) { + return true; + } + } + + return false; + } + + /** + * Create a rule that matches rows with a column that is **similar** to the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param string|string[] $value + * + * @return Condition + */ + public static function like($column, $value) + { + return new Like($column, $value); + } + + /** + * Return whether the given rule's value is similar to the given item's value + * + * @param Like|Unlike $rule + * @param object $row + * + * @return bool + */ + protected function matchSimilar($rule, $row) + { + if (! $rule instanceof Like && ! $rule instanceof Unlike) { + throw new InvalidArgumentException(sprintf( + 'Rule must be of type %s or %s, got %s instead', + Like::class, + Unlike::class, + get_php_type($rule) + )); + } + + $rowValue = $this->extractValue($rule->getColumn(), $row); + $value = $rule->getValue(); + $this->normalizeTypes($rowValue, $value); + + if (! is_array($rowValue)) { + $rowValue = [$rowValue]; + } + + foreach ($rowValue as $rowVal) { + if ($this->performSimilarityMatch($value, $rowVal, $rule->ignoresCase())) { + return true; + } + } + + return false; + } + + /** + * Apply equality matching rules on the given row value + * + * @param mixed $value + * @param mixed $rowValue + * @param bool $ignoreCase + * + * @return bool + */ + protected function performEqualityMatch($value, $rowValue, $ignoreCase = false) + { + if ($ignoreCase && is_string($rowValue)) { + $rowValue = strtolower($rowValue); + $value = is_array($value) + ? array_map(function ($val) { + return strtolower((string) $val); + }, $value) + : strtolower((string) $value); + } + + if (is_array($value)) { + return in_array($rowValue, $value, true); + } elseif (! is_string($value)) { + if (is_string($rowValue)) { + $value = (string) $value; + } + } + + return $rowValue === $value; + } + + /** + * Apply similarity matching rules on the given row value + * + * @param string|string[] $value + * @param string $rowValue + * @param bool $ignoreCase + * + * @return bool + */ + protected function performSimilarityMatch($value, $rowValue, $ignoreCase = false) + { + if ($ignoreCase) { + $rowValue = strtolower($rowValue); + $value = is_array($value) + ? array_map('strtolower', $value) + : strtolower($value); + } + + if (is_array($value)) { + return in_array($rowValue, $value, true); + } + + $wildcardSubSegments = preg_split('~\*~', $value); + if (! $wildcardSubSegments) { + $wildcardSubSegments = []; + } + + if (count($wildcardSubSegments) === 1) { + return $rowValue === $value; + } + + $parts = []; + foreach ($wildcardSubSegments as $part) { + $parts[] = preg_quote($part, '~'); + } + + $pattern = '~^' . join('.*', $parts) . '$~'; + + return (bool) preg_match($pattern, $rowValue); + } + + /** + * Create a rule that matches rows with a column that is **unequal** with the given value + * + * @param string $column + * @param array<mixed>|bool|float|int|string $value + * + * @return Condition + */ + public static function unequal($column, $value) + { + return new Unequal($column, $value); + } + + /** + * Return whether the given rule's value does not equal the given item's value + * + * @param Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchUnequal(Unequal $rule, $row) + { + return ! $this->matchEqual($rule, $row); + } + + /** + * Create a rule that matches rows with a column that is **unlike** with the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param string|string[] $value + * + * @return Condition + */ + public static function unlike($column, $value) + { + return new Unlike($column, $value); + } + + /** + * Return whether the given rule's value is unlike the given item's value + * + * @param Unlike $rule + * @param object $row + * + * @return bool + */ + protected function matchUnlike(Unlike $rule, $row) + { + return ! $this->matchSimilar($rule, $row); + } + + /** + * Create a rule that matches rows with a column that is **greater** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThan($column, $value) + { + return new GreaterThan($column, $value); + } + + /** + * Return whether the given rule's value is greater than the given item's value + * + * @param GreaterThan $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThan(GreaterThan $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) > $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThan($column, $value) + { + return new LessThan($column, $value); + } + + /** + * Return whether the given rule's value is less than the given item's value + * + * @param LessThan $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThan(LessThan $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue < $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **greater** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThanOrEqual($column, $value) + { + return new GreaterThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is greater than or equals the given item's value + * + * @param GreaterThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThanOrEqual(GreaterThanOrEqual $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) >= $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThanOrEqual($column, $value) + { + return new LessThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is less than or equals the given item's value + * + * @param LessThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThanOrEqual(LessThanOrEqual $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue <= $rule->getValue(); + } + + /** + * Perform the appropriate match for the given rule on the given item + * + * @param Rule $rule + * @param object $row + * + * @return bool + */ + protected function performMatch(Rule $rule, $row) + { + switch (true) { + case $rule instanceof All: + return $this->matchAll($rule, $row); + case $rule instanceof Any: + return $this->matchAny($rule, $row); + case $rule instanceof Like: + return $this->matchSimilar($rule, $row); + case $rule instanceof Equal: + return $this->matchEqual($rule, $row); + case $rule instanceof GreaterThan: + return $this->matchGreaterThan($rule, $row); + case $rule instanceof GreaterThanOrEqual: + return $this->matchGreaterThanOrEqual($rule, $row); + case $rule instanceof LessThan: + return $this->matchLessThan($rule, $row); + case $rule instanceof LessThanOrEqual: + return $this->matchLessThanOrEqual($rule, $row); + case $rule instanceof None: + return $this->matchNone($rule, $row); + case $rule instanceof Unequal: + return $this->matchUnequal($rule, $row); + case $rule instanceof Unlike: + return $this->matchUnlike($rule, $row); + default: + throw new InvalidArgumentException(sprintf( + 'Unable to match filter. Rule type %s is unknown', + get_class($rule) + )); + } + } + + /** + * Return a value from the given row suitable to work with + * + * @param string $column + * @param object $row + * + * @return mixed + */ + protected function extractValue($column, $row) + { + try { + return $row->{$column}; + } catch (Exception $_) { + return null; + } + } + + /** + * Normalize type of $value to the one of $rowValue + * + * For details on how this works please see the corresponding test + * {@see \ipl\Tests\Stdlib\FilterTest::testConditionsAreValueTypeAgnostic} + * + * @param mixed $rowValue + * @param mixed $value + * + * @return void + */ + protected function normalizeTypes($rowValue, &$value) + { + if ($rowValue === null || $value === null) { + return; + } + + if (is_array($rowValue)) { + if (empty($rowValue)) { + return; + } + + $rowValue = array_shift($rowValue); + } + + if (is_array($value)) { + if (is_bool($rowValue) && ! empty($value) && is_string(array_values($value)[0])) { + return; + } + + $rowValueType = gettype($rowValue); + foreach ($value as &$val) { + settype($val, $rowValueType); + } + } elseif (! is_bool($rowValue) || ! is_string($value)) { + settype($value, gettype($rowValue)); + } + } +} diff --git a/vendor/ipl/stdlib/src/Filter/All.php b/vendor/ipl/stdlib/src/Filter/All.php new file mode 100644 index 0000000..67b47b6 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/All.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class All extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Any.php b/vendor/ipl/stdlib/src/Filter/Any.php new file mode 100644 index 0000000..5d47ebe --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Any.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Any extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Chain.php b/vendor/ipl/stdlib/src/Filter/Chain.php new file mode 100644 index 0000000..9422d3a --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Chain.php @@ -0,0 +1,179 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ArrayIterator; +use Countable; +use IteratorAggregate; +use OutOfBoundsException; +use Traversable; + +abstract class Chain implements Rule, MetaDataProvider, IteratorAggregate, Countable +{ + use MetaData; + + /** @var array<int, Rule> */ + protected $rules = []; + + /** + * Create a new Chain + * + * @param Rule ...$rules + */ + public function __construct(Rule ...$rules) + { + foreach ($rules as $rule) { + $this->add($rule); + } + } + + /** + * Clone this chain's meta data and rules + */ + public function __clone() + { + if ($this->metaData !== null) { + $this->metaData = clone $this->metaData; + } + + foreach ($this->rules as $i => $rule) { + $this->rules[$i] = clone $rule; + } + } + + /** + * Get an iterator this chain's rules + * + * @return ArrayIterator<int, Rule> + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->rules); + } + + /** + * Add a rule to this chain + * + * @param Rule $rule + * + * @return $this + */ + public function add(Rule $rule) + { + $this->rules[] = $rule; + + return $this; + } + + /** + * Prepend a rule to an existing rule in this chain + * + * @param Rule $rule + * @param Rule $before + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function insertBefore(Rule $rule, Rule $before) + { + $ruleAt = array_search($before, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Reference rule not found'); + } + + array_splice($this->rules, $ruleAt, 0, [$rule]); + + return $this; + } + + /** + * Append a rule to an existing rule in this chain + * + * @param Rule $rule + * @param Rule $after + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function insertAfter(Rule $rule, Rule $after) + { + $ruleAt = array_search($after, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Reference rule not found'); + } + + array_splice($this->rules, $ruleAt + 1, 0, [$rule]); + + return $this; + } + + /** + * Get whether this chain contains the given rule + * + * @param Rule $rule + * + * @return bool + */ + public function has(Rule $rule) + { + return array_search($rule, $this->rules, true) !== false; + } + + /** + * Replace a rule with another one in this chain + * + * @param Rule $rule + * @param Rule $replacement + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function replace(Rule $rule, Rule $replacement) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Rule to replace not found'); + } + + array_splice($this->rules, $ruleAt, 1, [$replacement]); + + return $this; + } + + /** + * Remove a rule from this chain + * + * @param Rule $rule + * + * @return $this + */ + public function remove(Rule $rule) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt !== false) { + array_splice($this->rules, $ruleAt, 1, []); + } + + return $this; + } + + /** + * Get whether this chain has any rules + * + * @return bool + */ + public function isEmpty() + { + return empty($this->rules); + } + + /** + * Count this chain's rules + * + * @return int + */ + public function count(): int + { + return count($this->rules); + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Condition.php b/vendor/ipl/stdlib/src/Filter/Condition.php new file mode 100644 index 0000000..cc35610 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Condition.php @@ -0,0 +1,84 @@ +<?php + +namespace ipl\Stdlib\Filter; + +abstract class Condition implements Rule, MetaDataProvider +{ + use MetaData; + + /** @var string */ + protected $column; + + /** @var mixed */ + protected $value; + + /** + * Create a new Condition + * + * @param string $column + * @param mixed $value + */ + public function __construct($column, $value) + { + $this->setColumn($column) + ->setValue($value); + } + + /** + * Clone this condition's meta data + */ + public function __clone() + { + if ($this->metaData !== null) { + $this->metaData = clone $this->metaData; + } + } + + /** + * Set this condition's column + * + * @param string $column + * + * @return $this + */ + public function setColumn($column) + { + $this->column = $column; + + return $this; + } + + /** + * Get this condition's column + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Set this condition's value + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Get this condition's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Equal.php b/vendor/ipl/stdlib/src/Filter/Equal.php new file mode 100644 index 0000000..71da490 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Equal.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Equal extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/GreaterThan.php b/vendor/ipl/stdlib/src/Filter/GreaterThan.php new file mode 100644 index 0000000..fd8190c --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/GreaterThan.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class GreaterThan extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php b/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php new file mode 100644 index 0000000..4cd4a73 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class GreaterThanOrEqual extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/LessThan.php b/vendor/ipl/stdlib/src/Filter/LessThan.php new file mode 100644 index 0000000..297493f --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/LessThan.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class LessThan extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php b/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php new file mode 100644 index 0000000..ef35974 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class LessThanOrEqual extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Like.php b/vendor/ipl/stdlib/src/Filter/Like.php new file mode 100644 index 0000000..7a06279 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Like.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Like extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/MetaData.php b/vendor/ipl/stdlib/src/Filter/MetaData.php new file mode 100644 index 0000000..6fe2523 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/MetaData.php @@ -0,0 +1,20 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ipl\Stdlib\Data; + +trait MetaData +{ + /** @var Data */ + protected $metaData; + + public function metaData() + { + if ($this->metaData === null) { + $this->metaData = new Data(); + } + + return $this->metaData; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php b/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php new file mode 100644 index 0000000..ef9557e --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php @@ -0,0 +1,15 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ipl\Stdlib\Data; + +interface MetaDataProvider +{ + /** + * Get this rule's meta data + * + * @return Data + */ + public function metaData(); +} diff --git a/vendor/ipl/stdlib/src/Filter/None.php b/vendor/ipl/stdlib/src/Filter/None.php new file mode 100644 index 0000000..a1b14f7 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/None.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class None extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Rule.php b/vendor/ipl/stdlib/src/Filter/Rule.php new file mode 100644 index 0000000..dc83c80 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Rule.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +interface Rule +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Unequal.php b/vendor/ipl/stdlib/src/Filter/Unequal.php new file mode 100644 index 0000000..5e37cbd --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Unequal.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Unequal extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Unlike.php b/vendor/ipl/stdlib/src/Filter/Unlike.php new file mode 100644 index 0000000..16b9fb3 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Unlike.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Unlike extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filters.php b/vendor/ipl/stdlib/src/Filters.php new file mode 100644 index 0000000..defff43 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filters.php @@ -0,0 +1,58 @@ +<?php + +namespace ipl\Stdlib; + +trait Filters +{ + /** @var Filter\Chain */ + protected $filter; + + public function getFilter() + { + return $this->filter ?: Filter::all(); + } + + public function filter(Filter\Rule $filter) + { + $currentFilter = $this->getFilter(); + if ($currentFilter instanceof Filter\All) { + $this->filter = $currentFilter->add($filter); + } else { + $this->filter = Filter::all($filter); + if (! $currentFilter->isEmpty()) { + $this->filter->insertBefore($currentFilter, $filter); + } + } + + return $this; + } + + public function orFilter(Filter\Rule $filter) + { + $currentFilter = $this->getFilter(); + if ($currentFilter instanceof Filter\Any) { + $this->filter = $currentFilter->add($filter); + } else { + $this->filter = Filter::any($filter); + if (! $currentFilter->isEmpty()) { + $this->filter->insertBefore($currentFilter, $filter); + } + } + + return $this; + } + + public function notFilter(Filter\Rule $filter) + { + $this->filter(Filter::none($filter)); + + return $this; + } + + public function orNotFilter(Filter\Rule $filter) + { + $this->orFilter(Filter::none($filter)); + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php b/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php new file mode 100644 index 0000000..ba195c6 --- /dev/null +++ b/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Stdlib\Loader; + +use ipl\Stdlib\Contract\PluginLoader; + +/** + * Plugin loader that makes use of registered PHP autoloaders + */ +class AutoloadingPluginLoader implements PluginLoader +{ + /** @var string Namespace of the plugins */ + protected $namespace; + + /** @var string Class name postfix */ + protected $postfix; + + /** + * Create a new autoloading plugin loader + * + * @param string $namespace Namespace of the plugins + * @param string $postfix Class name postfix + */ + public function __construct($namespace, $postfix = '') + { + $this->namespace = $namespace; + $this->postfix = $postfix; + } + + /** + * Get the FQN of a plugin + * + * @param string $name Name of the plugin + * + * @return string + */ + protected function getFqn($name) + { + return $this->namespace . '\\' . ucfirst($name) . $this->postfix; + } + + public function load($name) + { + $class = $this->getFqn($name); + + if (! class_exists($class)) { + return false; + } + + return $class; + } +} diff --git a/vendor/ipl/stdlib/src/MessageContainer.php b/vendor/ipl/stdlib/src/MessageContainer.php new file mode 100644 index 0000000..3b383b1 --- /dev/null +++ b/vendor/ipl/stdlib/src/MessageContainer.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Stdlib; + +/** @deprecated Use {@link Messages} instead */ +trait MessageContainer +{ + use Messages; +} diff --git a/vendor/ipl/stdlib/src/Messages.php b/vendor/ipl/stdlib/src/Messages.php new file mode 100644 index 0000000..b601c1d --- /dev/null +++ b/vendor/ipl/stdlib/src/Messages.php @@ -0,0 +1,92 @@ +<?php + +namespace ipl\Stdlib; + +trait Messages +{ + /** @var array */ + protected $messages = []; + + /** + * Get whether there are any messages + * + * @return bool + */ + public function hasMessages() + { + return ! empty($this->messages); + } + + /** + * Get all messages + * + * @return array + */ + public function getMessages() + { + return $this->messages; + } + + /** + * Set the given messages overriding existing ones + * + * @param string[] $messages + * + * @return $this + */ + public function setMessages(array $messages) + { + $this->clearMessages(); + + foreach ($messages as $message) { + $this->addMessage($message); + } + + return $this; + } + + /** + * Add a single message + * + * @param string $message + * @param mixed ...$args Optional args for sprintf-style messages + * + * @return $this + */ + public function addMessage($message, ...$args) + { + if (empty($args)) { + $this->messages[] = $message; + } else { + $this->messages[] = vsprintf($message, $args); + } + + return $this; + } + + /** + * Add the given messages + * + * @param array $messages + * + * @return $this + */ + public function addMessages(array $messages) + { + $this->messages = array_merge($this->messages, $messages); + + return $this; + } + + /** + * Drop any existing message + * + * @return $this + */ + public function clearMessages() + { + $this->messages = []; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Plugins.php b/vendor/ipl/stdlib/src/Plugins.php new file mode 100644 index 0000000..a5dbb77 --- /dev/null +++ b/vendor/ipl/stdlib/src/Plugins.php @@ -0,0 +1,95 @@ +<?php + +namespace ipl\Stdlib; + +use ipl\Stdlib\Contract\PluginLoader; +use ipl\Stdlib\Loader\AutoloadingPluginLoader; + +trait Plugins +{ + /** @var array Registered plugin loaders by type */ + protected $pluginLoaders = []; + + /** + * Factory for plugin loaders + * + * @param PluginLoader|string $loaderOrNamespace + * @param string $postfix + * + * @return PluginLoader + */ + public static function wantPluginLoader($loaderOrNamespace, $postfix = '') + { + if ($loaderOrNamespace instanceof PluginLoader) { + $loader = $loaderOrNamespace; + } else { + $loader = new AutoloadingPluginLoader($loaderOrNamespace, $postfix); + } + + return $loader; + } + + /** + * Get whether a plugin loader for the given type exists + * + * @param string $type + * + * @return bool + */ + public function hasPluginLoader($type) + { + return isset($this->pluginLoaders[$type]); + } + + /** + * Add a plugin loader for the given type + * + * @param string $type + * @param PluginLoader|string $loaderOrNamespace + * @param string $postfix + * + * @return $this + */ + public function addPluginLoader($type, $loaderOrNamespace, $postfix = '') + { + $loader = static::wantPluginLoader($loaderOrNamespace, $postfix); + + if (! isset($this->pluginLoaders[$type])) { + $this->pluginLoaders[$type] = []; + } + + array_unshift($this->pluginLoaders[$type], $loader); + + return $this; + } + + /** + * Load the class file of the given plugin + * + * @param string $type + * @param string $name + * + * @return string|false + */ + public function loadPlugin($type, $name) + { + if ($this->hasPluginLoader($type)) { + /** @var PluginLoader $loader */ + foreach ($this->pluginLoaders[$type] as $loader) { + $class = $loader->load($name); + if ($class) { + return $class; + } + } + } + + return false; + } + + protected function addDefaultPluginLoader($type, $loaderOrNamespace, $postfix) + { + $this->pluginLoaders[$type][] = static::wantPluginLoader($loaderOrNamespace, $postfix); + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/PriorityQueue.php b/vendor/ipl/stdlib/src/PriorityQueue.php new file mode 100644 index 0000000..9047af4 --- /dev/null +++ b/vendor/ipl/stdlib/src/PriorityQueue.php @@ -0,0 +1,42 @@ +<?php + +namespace ipl\Stdlib; + +use Generator; +use SplPriorityQueue; + +/** + * Stable priority queue that also maintains insertion order for items with the same priority + */ +class PriorityQueue extends SplPriorityQueue +{ + /** @var int */ + protected $serial = PHP_INT_MAX; + + /** + * @inheritDoc + * + * Maintains insertion order for items with the same priority. + */ + public function insert($value, $priority): bool + { + return parent::insert($value, [$priority, $this->serial--]); + } + + /** + * Yield all items as priority-value pairs + * + * @return Generator + */ + public function yieldAll() + { + // Clone queue because the SplPriorityQueue acts as a heap and thus items are removed upon iteration + $queue = clone $this; + + $queue->setExtractFlags(static::EXTR_BOTH); + + foreach ($queue as $item) { + yield $item['priority'][0] => $item['data']; + } + } +} diff --git a/vendor/ipl/stdlib/src/Properties.php b/vendor/ipl/stdlib/src/Properties.php new file mode 100644 index 0000000..5726af3 --- /dev/null +++ b/vendor/ipl/stdlib/src/Properties.php @@ -0,0 +1,205 @@ +<?php + +namespace ipl\Stdlib; + +use OutOfBoundsException; +use Traversable; + +/** + * Trait for property access, mutation and array access. + */ +trait Properties +{ + /** @var array */ + private $properties = []; + + /** + * Get whether this class has any properties + * + * @return bool + */ + public function hasProperties() + { + return ! empty($this->properties); + } + + /** + * Get whether a property with the given key exists + * + * @param string $key + * + * @return bool + */ + public function hasProperty($key) + { + return array_key_exists($key, $this->properties); + } + + /** + * Set the given properties + * + * @param array $properties + * + * @return $this + */ + public function setProperties(array $properties) + { + foreach ($properties as $key => $value) { + $this->setProperty($key, $value); + } + + return $this; + } + + /** + * Get the property by the given key + * + * @param string $key + * + * @return mixed + * + * @throws OutOfBoundsException If the property by the given key does not exist + */ + protected function getProperty($key) + { + if (array_key_exists($key, $this->properties)) { + return $this->properties[$key]; + } + + throw new OutOfBoundsException("Can't access property '$key'. Property does not exist"); + } + + /** + * Set a property with the given key and value + * + * @param string $key + * @param mixed $value + * + * @return $this + */ + protected function setProperty($key, $value) + { + $this->properties[$key] = $value; + + return $this; + } + + /** + * Iterate over all existing properties + * + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->properties as $key => $value) { + yield $key => $value; + } + } + + /** + * Check whether an offset exists + * + * @param mixed $offset + * + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->properties[$offset]); + } + + /** + * Get the value for an offset + * + * @param mixed $offset + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getProperty($offset); + } + + /** + * Set the value for an offset + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->setProperty($offset, $value); + } + + /** + * Unset the value for an offset + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + unset($this->properties[$offset]); + } + + /** + * Get the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `$value = $object->property;`. + * Do not call this method directly. + * + * @param mixed $key + * + * @return mixed + */ + public function __get($key) + { + return $this->getProperty($key); + } + + /** + * Set the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `$object->property = $value;`. + * Do not call this method directly. + * + * @param string $key + * @param mixed $value + */ + public function __set($key, $value) + { + $this->setProperty($key, $value); + } + + /** + * Check whether a non-public property is defined and not null + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `isset($object->property);`. + * Do not call this method directly. + * + * @param string $key + * + * @return bool + */ + public function __isset($key) + { + return $this->offsetExists($key); + } + + /** + * Unset the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `unset($object->property);`. This method does nothing if the property does not exist. + * Do not call this method directly. + * + * @param string $key + */ + public function __unset($key) + { + $this->offsetUnset($key); + } +} diff --git a/vendor/ipl/stdlib/src/Seq.php b/vendor/ipl/stdlib/src/Seq.php new file mode 100644 index 0000000..02a3bd0 --- /dev/null +++ b/vendor/ipl/stdlib/src/Seq.php @@ -0,0 +1,111 @@ +<?php + +namespace ipl\Stdlib; + +use Closure; + +/** + * Collection of utilities for traversables + */ +class Seq +{ + /** + * Check if the traversable contains the given needle + * + * @param array<mixed>|iterable<mixed> $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return bool + */ + public static function contains($traversable, $needle, $caseSensitive = true) + { + return self::find($traversable, $needle, $caseSensitive)[0] !== null; + } + + /** + * Search in the traversable for the given needle and return its key and value + * + * @param array<mixed>|iterable<mixed> $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return array<mixed> An array with two entries, the first is the key, then the value. + * Both are null if nothing is found. + */ + public static function find($traversable, $needle, $caseSensitive = true) + { + $usesCallback = $needle instanceof Closure; + if (! $usesCallback && $caseSensitive && is_array($traversable)) { + return [array_search($needle, $traversable, true), $needle]; + } + + if (! $caseSensitive && is_string($needle) && ! $usesCallback) { + $needle = strtolower($needle); + } + + foreach ($traversable as $key => $item) { + $originalItem = $item; + if (! $caseSensitive && is_string($item)) { + $item = strtolower($item); + } + + if ($usesCallback && $needle($item)) { + return [$key, $originalItem]; + } elseif ($item === $needle) { + return [$key, $originalItem]; + } + } + + return [null, null]; + } + + /** + * Search in the traversable for the given needle and return its key + * + * @param array<mixed>|iterable<mixed> $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return mixed|null Null if nothing is found + */ + public static function findKey($traversable, $needle, $caseSensitive = true) + { + return self::find($traversable, $needle, $caseSensitive)[0]; + } + + /** + * Search in the traversable for the given needle and return its value + * + * @param array<mixed>|iterable<mixed> $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return mixed|null Null if nothing is found + */ + public static function findValue($traversable, $needle, $caseSensitive = true) + { + $usesCallback = $needle instanceof Closure; + if (! $usesCallback && $caseSensitive && is_array($traversable)) { + return isset($traversable[$needle]) ? $traversable[$needle] : null; + } + + if (! $caseSensitive && is_string($needle) && ! $usesCallback) { + $needle = strtolower($needle); + } + + foreach ($traversable as $key => $item) { + if (! $caseSensitive && is_string($key)) { + $key = strtolower($key); + } + + if ($usesCallback && $needle($key)) { + return $item; + } elseif ($key === $needle) { + return $item; + } + } + + return null; + } +} diff --git a/vendor/ipl/stdlib/src/Str.php b/vendor/ipl/stdlib/src/Str.php new file mode 100644 index 0000000..9cf1cae --- /dev/null +++ b/vendor/ipl/stdlib/src/Str.php @@ -0,0 +1,93 @@ +<?php + +namespace ipl\Stdlib; + +/** + * Collection of string manipulation functions + */ +class Str +{ + /** + * Convert the given string to camel case + * + * The given string may be delimited by the following characters: '_' (underscore), '-' (dash), ' ' (space). + * + * @param ?string $subject + * + * @return string + */ + public static function camel(?string $subject) + { + if ($subject === null) { + return ''; + } + + $normalized = str_replace(['-', '_'], ' ', $subject); + + return lcfirst(str_replace(' ', '', ucwords(strtolower($normalized)))); + } + + /** + * Check if the given string starts with the specified substring + * + * @param ?string $subject + * @param string $start + * @param bool $caseSensitive + * + * @return bool + */ + public static function startsWith(?string $subject, string $start, bool $caseSensitive = true) + { + $subject = $subject ?? ''; + if (! $caseSensitive) { + return strncasecmp($subject, $start, strlen($start)) === 0; + } + + return substr($subject, 0, strlen($start)) === $start; + } + + /** + * Split string into an array padded to the size specified by limit + * + * This method is a perfect fit if you need default values for symmetric array destructuring. + * + * @param ?string $subject + * @param string $delimiter + * @param int $limit + * @param mixed $default + * + * @return array<int, mixed> + */ + public static function symmetricSplit(?string $subject, string $delimiter, int $limit, $default = null) + { + if ($subject === null) { + return array_pad([], $limit, $default); + } + + return array_pad(explode($delimiter, $subject, $limit), $limit, $default); + } + + /** + * Split string into an array and trim spaces + * + * @param ?string $subject + * @param string $delimiter + * @param ?int $limit + * + * @return array<string> + */ + public static function trimSplit(?string $subject, string $delimiter = ',', int $limit = null) + { + if ($subject === null) { + return []; + } + + if ($limit !== null) { + $exploded = explode($delimiter, $subject, $limit); + } else { + $exploded = explode($delimiter, $subject); + } + + return array_map('trim', $exploded); + } +} diff --git a/vendor/ipl/stdlib/src/functions.php b/vendor/ipl/stdlib/src/functions.php new file mode 100644 index 0000000..e7f9be0 --- /dev/null +++ b/vendor/ipl/stdlib/src/functions.php @@ -0,0 +1,128 @@ +<?php + +namespace ipl\Stdlib; + +use Generator; +use InvalidArgumentException; +use IteratorIterator; +use Traversable; +use stdClass; + +/** + * Detect and return the PHP type of the given subject + * + * If subject is an object, the name of the object's class is returned, otherwise the subject's type. + * + * @param mixed $subject + * + * @return string + */ +function get_php_type($subject) +{ + if (is_object($subject)) { + return get_class($subject); + } else { + return gettype($subject); + } +} + +/** + * Get the array value of the given subject + * + * @param array<mixed>|object|Traversable $subject + * + * @return array<mixed> + * + * @throws InvalidArgumentException If subject type is invalid + */ +function arrayval($subject) +{ + if (is_array($subject)) { + return $subject; + } + + if ($subject instanceof stdClass) { + return (array) $subject; + } + + if ($subject instanceof Traversable) { + // Works for generators too + return iterator_to_array($subject); + } + + throw new InvalidArgumentException(sprintf( + 'arrayval expects arrays, objects or instances of Traversable. Got %s instead.', + get_php_type($subject) + )); +} + +/** + * Get the first key of an iterable + * + * @param iterable<mixed> $iterable + * + * @return mixed The first key of the iterable if it is not empty, null otherwise + */ +function iterable_key_first($iterable) +{ + foreach ($iterable as $key => $_) { + return $key; + } + + return null; +} + +/** + * Get the first value of an iterable + * + * @param iterable<mixed> $iterable + * + * @return ?mixed + */ +function iterable_value_first($iterable) +{ + foreach ($iterable as $_ => $value) { + return $value; + } + + return null; +} + +/** + * Yield sets of items from a sorted traversable grouped by a specific criterion gathered from a callback + * + * The traversable must be sorted by the criterion. The callback must return at least the criterion, + * but can also return value and key in addition. + * + * @param Traversable<mixed, mixed> $traversable + * @param callable(mixed $value, mixed $key): array{0: mixed, 1?: mixed, 2?: mixed} $groupBy + * + * @return Generator + */ +function yield_groups(Traversable $traversable, callable $groupBy): Generator +{ + $iterator = new IteratorIterator($traversable); + $iterator->rewind(); + + if (! $iterator->valid()) { + return; + } + + list($criterion, $v, $k) = array_pad((array) $groupBy($iterator->current(), $iterator->key()), 3, null); + $group = [$k ?? $iterator->key() => $v ?? $iterator->current()]; + + $iterator->next(); + for (; $iterator->valid(); $iterator->next()) { + list($c, $v, $k) = array_pad((array) $groupBy($iterator->current(), $iterator->key()), 3, null); + if ($c !== $criterion) { + yield $criterion => $group; + + $group = []; + $criterion = $c; + } + + $group[$k ?? $iterator->key()] = $v ?? $iterator->current(); + } + + yield $criterion => $group; +} diff --git a/vendor/ipl/stdlib/src/functions_include.php b/vendor/ipl/stdlib/src/functions_include.php new file mode 100644 index 0000000..9a2dc6f --- /dev/null +++ b/vendor/ipl/stdlib/src/functions_include.php @@ -0,0 +1,6 @@ +<?php + +// Don't redefine the functions if included multiple times +if (! function_exists('ipl\Stdlib\get_php_type')) { + require __DIR__ . '/functions.php'; +} diff --git a/vendor/ipl/validator/LICENSE b/vendor/ipl/validator/LICENSE new file mode 100644 index 0000000..b247ccf --- /dev/null +++ b/vendor/ipl/validator/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2020 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/validator/composer.json b/vendor/ipl/validator/composer.json new file mode 100644 index 0000000..51ba68d --- /dev/null +++ b/vendor/ipl/validator/composer.json @@ -0,0 +1,28 @@ +{ + "name": "ipl/validator", + "type": "library", + "description": "Icinga PHP Library - Common validators and validator chaining", + "homepage": "https://github.com/Icinga/ipl-validator", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-mbstring": "*", + "ext-openssl": "*", + "ipl/stdlib": ">=0.12.0", + "ipl/i18n": ">=0.2.0", + "psr/http-message": "~1.0" + }, + "autoload": { + "psr-4": { + "ipl\\Validator\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Validator\\": "tests" + } + }, + "require-dev": { + "guzzlehttp/psr7": "^1" + } +} diff --git a/vendor/ipl/validator/src/BaseValidator.php b/vendor/ipl/validator/src/BaseValidator.php new file mode 100644 index 0000000..8faa79b --- /dev/null +++ b/vendor/ipl/validator/src/BaseValidator.php @@ -0,0 +1,11 @@ +<?php + +namespace ipl\Validator; + +use ipl\Stdlib\Contract\Validator; +use ipl\Stdlib\Messages; + +abstract class BaseValidator implements Validator +{ + use Messages; +} diff --git a/vendor/ipl/validator/src/BetweenValidator.php b/vendor/ipl/validator/src/BetweenValidator.php new file mode 100644 index 0000000..3d7faaf --- /dev/null +++ b/vendor/ipl/validator/src/BetweenValidator.php @@ -0,0 +1,159 @@ +<?php + +namespace ipl\Validator; + +use Exception; +use ipl\I18n\Translation; + +/** + * Validates whether value is between the given min and max + */ +class BetweenValidator extends BaseValidator +{ + use Translation; + + /** @var mixed Min value */ + protected $min; + + /** @var mixed Max value */ + protected $max; + + /** + * Whether to do inclusive comparisons, allowing equivalence to min and/or max + * + * If false, then strict comparisons are done, and the value may equal neither + * the min nor max options + * + * @var boolean + */ + protected $inclusive; + + /** + * Create a new BetweenValidator + * + * Required options: + * + * - min: (scalar) Minimum border + * - max: (scalar) Maximum border + * + * Optional options: + * + * - inclusive: (bool) Whether inclusive border values, default true + * + * @param array $options + * + * @throws Exception When required option is missing + */ + public function __construct(array $options) + { + if (! isset($options['min'], $options['max'])) { + throw new Exception("Missing option. 'min' and 'max' has to be given"); + } + + $this->setMin($options['min']) + ->setMax($options['max']) + ->setInclusive($options['inclusive'] ?? true); + } + + /** + * Return the min option + * + * @return mixed + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the min option + * + * @param mixed $min + * + * @return $this + */ + public function setMin($min): self + { + $this->min = $min; + + return $this; + } + + /** + * Return the max option + * + * @return mixed + */ + public function getMax() + { + return $this->max; + } + + /** + * Set the max option + * + * @param mixed $max + * + * @return $this + */ + public function setMax($max): self + { + $this->max = $max; + + return $this; + } + + /** + * Return the inclusive option + * + * @return bool + */ + public function getInclusive(): bool + { + return $this->inclusive; + } + + /** + * Set the inclusive option + * + * @param bool $inclusive + * + * @return $this + */ + public function setInclusive($inclusive = true): self + { + $this->inclusive = (bool) $inclusive; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if ($this->getInclusive()) { + if ($this->getMin() > $value || $value > $this->getMax()) { + $this->addMessage(sprintf( + $this->translate("'%s' is not between '%s' and '%s', inclusively"), + $value, + $this->getMin(), + $this->getMax() + )); + + return false; + } + } elseif ($this->getMin() >= $value || $value >= $this->getMax()) { + $this->addMessage(sprintf( + $this->translate("'%s' is not between '%s' and '%s'"), + $value, + $this->getMin(), + $this->getMax() + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/CallbackValidator.php b/vendor/ipl/validator/src/CallbackValidator.php new file mode 100644 index 0000000..611a45e --- /dev/null +++ b/vendor/ipl/validator/src/CallbackValidator.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Validator; + +/** + * Validator that uses a callback for the actual validation + * + * # Example Usage + * ``` + * $dedup = new CallbackValidator(function ($value, CallbackValidator $validator) { + * if (already_exists_in_database($value)) { + * $validator->addMessage('Record already exists in database'); + * + * return false; + * } + * + * return true; + * }); + * + * $dedup->isValid($id); + * ``` + */ +class CallbackValidator extends BaseValidator +{ + /** @var callable Validation callback */ + protected $callback; + + /** + * Create a new callback validator + * + * @param callable $callback Validation callback + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + return call_user_func($this->callback, $value, $this); + } +} diff --git a/vendor/ipl/validator/src/CidrValidator.php b/vendor/ipl/validator/src/CidrValidator.php new file mode 100644 index 0000000..32c1162 --- /dev/null +++ b/vendor/ipl/validator/src/CidrValidator.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; +use ipl\Stdlib\Str; + +/** + * Validate a classless inter-domain routing (CIDR) + */ +class CidrValidator extends BaseValidator +{ + use Translation; + + public function isValid($value): bool + { + $this->clearMessages(); + + $pieces = Str::trimSplit($value, '/'); + if (count($pieces) !== 2) { + $this->addMessage(sprintf( + $this->translate('CIDR "%s" does not conform to the required format $address/$prefix'), + $value + )); + + return false; + } + + list($address, $prefix) = $pieces; + $inaddr = @inet_pton($address); + if ($inaddr === false) { + $this->addMessage(sprintf($this->translate('CIDR "%s" contains an invalid address'), $value)); + + return false; + } + + if (! is_numeric($prefix)) { + $this->addMessage(sprintf($this->translate('Prefix of CIDR "%s" must be a number'), $value)); + + return false; + } + + $isIPv6 = isset($inaddr[4]); + $prefix = (int) $prefix; + $maxPrefixLength = $isIPv6 ? 128 : 32; + + if ($prefix < 0 || $prefix > $maxPrefixLength) { + $this->addMessage(sprintf( + $this->translate('Prefix length of CIDR "%s" must be between 0 and %d for IPv%d addresses'), + $value, + $maxPrefixLength, + $isIPv6 ? 6 : 4 + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/DateTimeValidator.php b/vendor/ipl/validator/src/DateTimeValidator.php new file mode 100644 index 0000000..1e35d61 --- /dev/null +++ b/vendor/ipl/validator/src/DateTimeValidator.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Validator; + +use DateTime; +use ipl\I18n\Translation; + +/** + * Validator for date-and-time input controls + */ +class DateTimeValidator extends BaseValidator +{ + use Translation; + + /** @var string Default date time format */ + const FORMAT = 'Y-m-d\TH:i:s'; + + /** @var bool Whether to use the default date time format */ + protected $local; + + /** + * Create a new date-and-time input control validator + * + * @param bool $local + */ + public function __construct($local = true) + { + $this->local = (bool) $local; + } + + /** + * Check whether the given date time is valid + * + * @param string|DateTime $value + * + * @return bool + */ + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (! $value instanceof DateTime && ! is_string($value)) { + $this->addMessage($this->translate('Invalid date/time given.')); + + return false; + } + + if (! $value instanceof DateTime) { + $format = $this->local === true ? static::FORMAT : DateTime::RFC3339; + $dateTime = DateTime::createFromFormat($format, $value); + + if ($dateTime === false || $dateTime->format($format) !== $value) { + $this->addMessage(sprintf( + $this->translate("Date/time string not in the expected format: %s"), + $format + )); + + return false; + } + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/DeferredInArrayValidator.php b/vendor/ipl/validator/src/DeferredInArrayValidator.php new file mode 100644 index 0000000..55b9b83 --- /dev/null +++ b/vendor/ipl/validator/src/DeferredInArrayValidator.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Validator; + +/** + * Validates whether the value exists in the haystack created by the callback + */ +class DeferredInArrayValidator extends InArrayValidator +{ + /** @var callable Callback to create the haystack array */ + protected $callback; + + /** + * Create a new deferredInArray validator + * + * **Required parameter:** + * + * - `callback`: (`callable`) The callback to create haystack + * + * **Optional parameter:** + * + * *options: (`array`) Following option can be defined:* + * + * * `strict`: (`bool`) Whether the types of the needle in the haystack should also match, default `false` + * + * @param callable $callback Validation callback + * @param array $options + */ + public function __construct(callable $callback, array $options = []) + { + $this->callback = $callback; + + parent::__construct($options); + } + + public function getHaystack(): array + { + return $this->haystack ?? call_user_func($this->callback); + } + + /** + * Set the callback + * + * @param callable $callback + * + * @return $this + */ + public function setCallback(callable $callback): self + { + $this->haystack = null; + $this->callback = $callback; + + return $this; + } +} diff --git a/vendor/ipl/validator/src/EmailAddressValidator.php b/vendor/ipl/validator/src/EmailAddressValidator.php new file mode 100644 index 0000000..52c3697 --- /dev/null +++ b/vendor/ipl/validator/src/EmailAddressValidator.php @@ -0,0 +1,341 @@ +<?php + +namespace ipl\Validator; + +use Exception; +use ipl\I18n\Translation; + +/** + * Validates an email address + * + * Email Address syntax: (<local part>@<domain-literal part>) + * + * We currently do not support dot-atom syntax (refer RFC 2822 [https://www.ietf.org/rfc/rfc2822.txt] + * documentation for more details) for domain-literal part of an email address + * + */ +class EmailAddressValidator extends BaseValidator +{ + use Translation; + + /** + * If MX check should be enabled + * + * @var bool + */ + protected $mx = false; + + /** + * If a deep MX check should be enabled + * + * @var bool + */ + protected $deep = false; + + /** + * Create a new E-mail address validator with optional options + * + * Optional options: + * + * 'mx' => If an MX check should be enabled, boolean + * 'deep' => If a deep MX check should be enabled, boolean + * + * @param array $options + * + * @throws Exception + */ + public function __construct(array $options = []) + { + if (array_key_exists('mx', $options)) { + $this->setEnableMxCheck($options['mx']); + } + + if (array_key_exists('deep', $options)) { + $this->setEnableDeepMxCheck($options['deep']); + } + } + + /** + * Set MX check + * + * To validate if the hostname is a DNS mail exchange (MX) record set it to true + * + * @param bool $mx if MX check should be enabled + * + * @return $this + */ + public function setEnableMxCheck(bool $mx = true): self + { + $this->mx = $mx; + + return $this; + } + + /** + * Set Deep MX check + * + * To validate if the hostname is a DNS mail exchange (MX) record, and it points to an A record (for IPv4) or + * an AAAA / A6 record (for IPv6) set it to true + * + * @param bool $deep if deep MX check should be enabled + * + * @return $this + * + * @throws Exception in case MX check has not been enabled + */ + public function setEnableDeepMxCheck(bool $deep = true): self + { + if (! $this->mx) { + throw new Exception("MX record check has to be enabled to enable deep MX record check"); + } + + $this->deep = $deep; + + return $this; + } + + /** + * Validate the local part (username / the part before '@') of the email address + * + * @param string $localPart + * @param string $email + * + * @return bool + */ + private function validateLocalPart(string $localPart, string $email): bool + { + // First try to match the local part on the common dot-atom format + $result = false; + + // Dot-atom characters are: 1*atext *("." 1*atext) + // atext: ALPHA / DIGIT / and "!", "#", "$", "%", "&", "'", "*", + // "+", "-", "/", "=", "?", "^", "_", "`", "{", "|", "}", "~" + $atext = 'a-zA-Z0-9\x21\x23\x24\x25\x26\x27\x2a\x2b\x2d\x2f\x3d\x3f\x5e\x5f\x60\x7b\x7c\x7d\x7e'; + if (preg_match('/^[' . $atext . ']+(\x2e+[' . $atext . ']+)*$/', $localPart)) { + $result = true; + } else { + // Try quoted string format (RFC 5321 Chapter 4.1.2) + + // Quoted-string characters are: DQUOTE *(qtext/quoted-pair) DQUOTE + $qtext = '\x20-\x21\x23-\x5b\x5d-\x7e'; // %d32-33 / %d35-91 / %d93-126 + $quotedPair = '\x20-\x7e'; // %d92 %d32-126 + if (preg_match('/^"([' . $qtext . ']|\x5c[' . $quotedPair . '])*"$/', $localPart)) { + $result = true; + } else { + $this->addMessage(sprintf( + $this->translate( + "'%s' can not be matched against dot-atom format or quoted-string format" + ), + $localPart + )); + $this->addMessage(sprintf( + $this->translate("Hence '%s' is not a valid local part for email address '%s'"), + $localPart, + $email + )); + } + } + + return $result; + } + + /** + * Validate the hostname part of the email address + * + * @param string $hostname + * @param string $email + * + * @return bool + */ + private function validateHostnamePart(string $hostname, string $email): bool + { + $hostValidator = new HostnameValidator(); + + if ($this->validateIp($hostname)) { + return true; + } + + if (preg_match('/^\[([^\]]*)\]$/i', $hostname, $matches)) { + $validHostname = $matches[1]; + if (! $this->validateIp($validHostname)) { + $this->addMessage(sprintf( + $this->translate("host name %s is a domain literal and is invalid"), + $hostname + )); + + return false; + } + + return true; + } + + if (! $hostValidator->isValid($hostname)) { + $this->addMessage(sprintf( + $this->translate('%s is not a valid domain name for email address %s.'), + $hostname, + $email + )); + + return false; + } elseif ($this->mx) { + // MX check on hostname + return $this->validateMXRecords($hostname, $email); + } + + return true; + } + + /** + * Check if the given IP address is valid + * + * @param string $value + * + * @return bool + */ + private function validateIp(string $value): bool + { + if (! filter_var($value, FILTER_VALIDATE_IP)) { + return false; + } + + return true; + } + + /** + * Returns true if and only if $value is a valid email address + * according to RFC2822 + * + * @param string $value + * + * @return bool + */ + public function isValid($value): bool + { + $this->clearMessages(); + + $matches = []; + $length = true; + + // Split email address up and disallow '..' + if ( + (strpos($value, '..') !== false) + || (! preg_match('/^(.+)@([^@]+)$/', $value, $matches)) + ) { + $this->addMessage(sprintf( + $this->translate("'%s' is not a valid email address in the basic format local-part@hostname"), + $value + )); + return false; + } + + $localPart = $matches[1]; + $hostname = $matches[2]; + + if ((strlen($localPart) > 64) || (strlen($hostname) > 255)) { + $length = false; + $this->addMessage(sprintf( + $this->translate("'%s' exceeds the allowed length"), + $value + )); + } + + $local = $this->validateLocalPart($localPart, $value); + + // If both parts valid, return true + if (($local && $this->validateHostnamePart($hostname, $value)) && $length) { + return true; + } + + return false; + } + + /** + * Perform deep MX record validation + * + * Check if the hostname is a valid DNS mail exchange (MX) record in case deep MX record check is enabled, + * also checks if the corresponding MX record points to an A record (for IPv4) or an AAAA / A6 record (for IPv6) + * + * @param string $hostname + * @param string $email + * + * @return bool + */ + private function validateMXRecords(string $hostname, string $email): bool + { + $mxHosts = []; + //decode IDN domain name + $decodedHostname = idn_to_ascii($hostname, 0, INTL_IDNA_VARIANT_UTS46); + + $result = getmxrr($decodedHostname, $mxHosts); + if (! $result) { + $this->addMessage(sprintf( + $this->translate("'%s' does not appear to have a valid MX record for the email address '%s'"), + $hostname, + $email + )); + } elseif ($this->deep) { + $validAddress = false; + $reserved = true; + foreach ($mxHosts as $decodedHostname) { + $res = $this->isReserved($decodedHostname); + if (! $res) { + $reserved = false; + } + + if ( + ! $res + && ( + checkdnsrr($decodedHostname, "A") + || checkdnsrr($decodedHostname, "AAAA") + || checkdnsrr($decodedHostname, "A6") + ) + ) { + $validAddress = true; + break; + } + } + + if (! $validAddress) { + $result = false; + if ($reserved) { + $this->addMessage(sprintf( + $this->translate( + "'%s' is not in a routable network segment." . + " The email address '%s' should not be resolved from public network" + ), + $hostname, + $email + )); + } else { + $this->addMessage(sprintf( + $this->translate("'%s' does not appear to have a valid MX record for the email address '%s'"), + $hostname, + $email + )); + } + } + } + + return $result; + } + + /** + * Validate whether the given host is reserved + * + * @param string $host host name or ip address + * + * @return bool + */ + private function isReserved(string $host): bool + { + if (! preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $host)) { + $host = gethostbyname($host); + } + + if (! filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_NO_PRIV_RANGE)) { + return true; + } + + return false; + } +} diff --git a/vendor/ipl/validator/src/FileValidator.php b/vendor/ipl/validator/src/FileValidator.php new file mode 100644 index 0000000..8c5b90e --- /dev/null +++ b/vendor/ipl/validator/src/FileValidator.php @@ -0,0 +1,248 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; +use ipl\Stdlib\Str; +use LogicException; +use Psr\Http\Message\UploadedFileInterface; + +/** + * Validates an uploaded file + */ +class FileValidator extends BaseValidator +{ + use Translation; + + /** @var int Minimum allowed file size */ + protected $minSize; + + /** @var ?int Maximum allowed file size */ + protected $maxSize; + + /** @var ?string[] Allowed mime types */ + protected $allowedMimeTypes; + + /** @var ?int Maximum allowed file name length */ + protected $maxFileNameLength; + + /** + * Create a new FileValidator + * + * Optional options: + * - minSize: (int) Minimum allowed file size, by default 0 + * - maxSize: (int) Maximum allowed file size, by default no limit + * - maxFileNameLength: (int) Maximum allowed file name length, by default no limit + * - mimeType: (array) Allowed mime types, by default no restriction + */ + public function __construct(array $options = []) + { + $this + ->setMinSize($options['minSize'] ?? 0) + ->setMaxSize($options['maxSize'] ?? null) + ->setMaxFileNameLength($options['maxFileNameLength'] ?? null) + ->setAllowedMimeTypes($options['mimeType'] ?? null); + } + + /** + * Get the minimum allowed file size + * + * @return int + */ + public function getMinSize(): int + { + return $this->minSize; + } + + /** + * Set the minimum allowed file size + * + * @param int $minSize + * + * @return $this + */ + public function setMinSize(int $minSize): self + { + if (($max = $this->getMaxSize()) !== null && $minSize > $max) { + throw new LogicException( + sprintf( + 'The minSize must be less than or equal to the maxSize, but minSize: %d and maxSize: %d given.', + $minSize, + $max + ) + ); + } + + $this->minSize = $minSize; + + return $this; + } + + /** + * Get the maximum allowed file size + * + * @return ?int + */ + public function getMaxSize(): ?int + { + return $this->maxSize; + } + + /** + * Set the maximum allowed file size + * + * @param ?int $maxSize + * + * @return $this + */ + public function setMaxSize(?int $maxSize): self + { + if ($maxSize !== null && ($min = $this->getMinSize()) !== null && $maxSize < $min) { + throw new LogicException( + sprintf( + 'The minSize must be less than or equal to the maxSize, but minSize: %d and maxSize: %d given.', + $min, + $maxSize + ) + ); + } + + $this->maxSize = $maxSize; + + return $this; + } + + /** + * Get the allowed file mime types + * + * @return ?string[] + */ + public function getAllowedMimeTypes(): ?array + { + return $this->allowedMimeTypes; + } + + /** + * Set the allowed file mime types + * + * @param ?string[] $allowedMimeTypes + * + * @return $this + */ + public function setAllowedMimeTypes(?array $allowedMimeTypes): self + { + $this->allowedMimeTypes = $allowedMimeTypes; + + return $this; + } + + /** + * Get maximum allowed file name length + * + * @return ?int + */ + public function getMaxFileNameLength(): ?int + { + return $this->maxFileNameLength; + } + + /** + * Set maximum allowed file name length + * + * @param ?int $maxFileNameLength + * + * @return $this + */ + public function setMaxFileNameLength(?int $maxFileNameLength): self + { + $this->maxFileNameLength = $maxFileNameLength; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (is_array($value)) { + foreach ($value as $file) { + if (! $this->validateFile($file)) { + return false; + } + } + + return true; + } + + return $this->validateFile($value); + } + + + private function validateFile(UploadedFileInterface $file): bool + { + $isValid = true; + if ($this->getMaxSize() && $file->getSize() > $this->getMaxSize()) { + $this->addMessage(sprintf( + $this->translate('File %s is bigger than the allowed maximum size of %d'), + $file->getClientFileName(), + $this->getMaxSize() + )); + + $isValid = false; + } + + if ($this->getMinSize() && $file->getSize() < $this->getMinSize()) { + $this->addMessage(sprintf( + $this->translate('File %s is smaller than the minimum required size of %d'), + $file->getClientFileName(), + $this->getMinSize() + )); + + $isValid = false; + } + + if ($this->getMaxFileNameLength()) { + $strValidator = new StringLengthValidator(['max' => $this->getMaxFileNameLength()]); + + if (! $strValidator->isValid($file->getClientFilename())) { + $this->addMessage(sprintf( + $this->translate('File name is longer than the allowed length of %d characters.'), + $this->maxFileNameLength + )); + + $isValid = false; + } + } + + if (! empty($this->getAllowedMimeTypes())) { + $hasAllowedMimeType = false; + foreach ($this->getAllowedMimeTypes() as $type) { + $fileMimetype = $file->getClientMediaType(); + if (($pos = strpos($type, '/*')) !== false) { // image/* + $typePrefix = substr($type, 0, $pos); + if (Str::startsWith($fileMimetype, $typePrefix)) { + $hasAllowedMimeType = true; + break; + } + } elseif ($fileMimetype === $type) { // image/png + $hasAllowedMimeType = true; + break; + } + } + + if (! $hasAllowedMimeType) { + $this->addMessage(sprintf( + $this->translate('File %s is of type %s. Only %s allowed.'), + $file->getClientFileName(), + $file->getClientMediaType(), + implode(', ', $this->allowedMimeTypes) + )); + + $isValid = false; + } + } + + return $isValid; + } +} diff --git a/vendor/ipl/validator/src/GreaterThanValidator.php b/vendor/ipl/validator/src/GreaterThanValidator.php new file mode 100644 index 0000000..e5de3d0 --- /dev/null +++ b/vendor/ipl/validator/src/GreaterThanValidator.php @@ -0,0 +1,69 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates whether the value is greater than the given min + */ +class GreaterThanValidator extends BaseValidator +{ + use Translation; + + /** @var mixed Comparison value for greater than */ + protected $min; + + /** + * Create a new GreaterThanValidator + * + * Optional options: + * - min: (scalar) Comparison value for greater than, default 0 + */ + public function __construct(array $options = []) + { + $this->setMin($options['min'] ?? 0); + } + + /** + * Get the min option + * + * @return mixed + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the min option + * + * @param mixed $min + * + * @return $this + */ + public function setMin($min): self + { + $this->min = $min; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if ($this->getMin() >= $value) { + $this->addMessage(sprintf( + $this->translate("'%s' is not greater than '%s'"), + $value, + $this->min + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/HexColorValidator.php b/vendor/ipl/validator/src/HexColorValidator.php new file mode 100644 index 0000000..e2da39c --- /dev/null +++ b/vendor/ipl/validator/src/HexColorValidator.php @@ -0,0 +1,37 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validator for color input controls + */ +class HexColorValidator extends BaseValidator +{ + use Translation; + + /** + * Check whether the given color is valid + * + * @param string $value + * + * @return bool + */ + public function isValid($value): bool + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (! preg_match('/\A#[0-9a-f]{6}\z/i', $value)) { + $this->addMessage(sprintf( + $this->translate('Color string not in the expected format %s'), + '#rrggbb' + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/HostnameValidator.php b/vendor/ipl/validator/src/HostnameValidator.php new file mode 100644 index 0000000..3bb9b66 --- /dev/null +++ b/vendor/ipl/validator/src/HostnameValidator.php @@ -0,0 +1,37 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates Host name + */ +class HostnameValidator extends BaseValidator +{ + use Translation; + + /** + * Validates host names against RFC 1034, RFC 1035, RFC 952, RFC 1123, RFC 2732, RFC 2181, and RFC 1123 + * + * @param string $value + * + * @return boolean + */ + public function isValid($value) + { + $this->clearMessages(); + + $asciiHostname = idn_to_ascii($value, 0, INTL_IDNA_VARIANT_UTS46); + if (filter_var($asciiHostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) { + $this->addMessage(sprintf( + $this->translate("%s is not a valid host name."), + $value ?? '' + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/InArrayValidator.php b/vendor/ipl/validator/src/InArrayValidator.php new file mode 100644 index 0000000..f8c18ef --- /dev/null +++ b/vendor/ipl/validator/src/InArrayValidator.php @@ -0,0 +1,128 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validate if specific single or multiple values exist in an array + */ +class InArrayValidator extends BaseValidator +{ + use Translation; + + /** @var array The array */ + protected $haystack; + + /** @var bool Whether the types of the needle in the haystack should also match */ + protected $strict = false; + + /** + * Create a new InArray validator + * + * **Optional options:** + * + * * `haystack`: (`array`) The array + * * `strict`: (`bool`) Whether the types of the needle in the haystack should also match, default `false` + * + * @param array $options + */ + public function __construct(array $options = []) + { + if (isset($options['haystack'])) { + $this->setHaystack($options['haystack']); + } + + $this->setStrict($options['strict'] ?? false); + } + + /** + * Get the haystack + * + * @return array + */ + public function getHaystack(): array + { + return $this->haystack ?? []; + } + + /** + * Set the haystack + * + * @param array $haystack + * + * @return $this + */ + public function setHaystack(array $haystack): self + { + $this->haystack = $haystack; + + return $this; + } + + /** + * Get whether the types of the needle in the haystack should also match + * + * @return bool + */ + public function isStrict(): bool + { + return $this->strict; + } + + /** + * Set whether the types of the needle in the haystack should also match + * + * @param bool $strict + * + * @return $this + */ + public function setStrict(bool $strict = true): self + { + $this->strict = $strict; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + $notInArray = $this->findInvalid((array) $value); + + if (empty($notInArray)) { + return true; + } + + $this->addMessage(sprintf( + $this->translatePlural( + "%s was not found in the haystack", + "%s were not found in the haystack", + count($notInArray) + ), + implode(', ', $notInArray) + )); + + return false; + } + + /** + * Get the values from the specified array that are not present in the haystack + * + * @param array $values + * + * @return array Values not found in the haystack + */ + protected function findInvalid(array $values = []): array + { + $notInArray = []; + foreach ($values as $val) { + if (! in_array($val, $this->getHaystack(), $this->isStrict())) { + $notInArray[] = $val; + } + } + + return $notInArray; + } +} diff --git a/vendor/ipl/validator/src/LessThanValidator.php b/vendor/ipl/validator/src/LessThanValidator.php new file mode 100644 index 0000000..68e3daf --- /dev/null +++ b/vendor/ipl/validator/src/LessThanValidator.php @@ -0,0 +1,69 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates whether the value is less than the given max + */ +class LessThanValidator extends BaseValidator +{ + use Translation; + + /** @var mixed Comparison value for less than */ + protected $max; + + /** + * Create a new LessThanValidator + * + * Optional options: + * - max: (int) Comparison value for less than, default 0 + */ + public function __construct(array $options = []) + { + $this->setMax($options['max'] ?? 0); + } + + /** + * Get the max option + * + * @return mixed + */ + public function getMax() + { + return $this->max; + } + + /** + * Set the max option + * + * @param mixed $max + * + * @return $this + */ + public function setMax($max): self + { + $this->max = $max; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if ($this->getMax() <= $value) { + $this->addMessage(sprintf( + $this->translate("'%s' is not less than '%s'"), + $value, + $this->getMax() + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/PrivateKeyValidator.php b/vendor/ipl/validator/src/PrivateKeyValidator.php new file mode 100644 index 0000000..b629398 --- /dev/null +++ b/vendor/ipl/validator/src/PrivateKeyValidator.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates a private key + */ +class PrivateKeyValidator extends BaseValidator +{ + use Translation; + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (preg_match('/\A\s*\w+:/', $value)) { + $this->addMessage($this->translate('URLs are not allowed')); + + return false; + } + + if (openssl_pkey_get_private($value) === false) { + $this->addMessage($this->translate('Not a valid PEM-encoded private key')); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/StringLengthValidator.php b/vendor/ipl/validator/src/StringLengthValidator.php new file mode 100644 index 0000000..57df1eb --- /dev/null +++ b/vendor/ipl/validator/src/StringLengthValidator.php @@ -0,0 +1,179 @@ +<?php + +namespace ipl\Validator; + +use InvalidArgumentException; +use ipl\I18n\Translation; +use LogicException; + +/** + * Validates string length with given options + */ +class StringLengthValidator extends BaseValidator +{ + use Translation; + + /** @var mixed Minimum required length */ + protected $min; + + /** @var mixed Maximum required length */ + protected $max; + + /** @var ?string Encoding to use */ + protected $encoding; + + /** + * Create a new StringLengthValidator + * + * Optional options: + * - min: (scalar) Minimum required string length, default 0 + * - max: (scalar) Maximum required string length, default null + * - encoding: (string) Encoding type, default null + */ + public function __construct(array $options = []) + { + $this + ->setMin($options['min'] ?? 0) + ->setMax($options['max'] ?? null) + ->setEncoding($options['encoding'] ?? null); + } + + /** + * Get the minimum required string length + * + * @return mixed + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the minimum required string length + * + * @param mixed $min + * + * @return $this + * + * @throws LogicException When the $min is greater than the $max value + */ + public function setMin($min): self + { + if ($this->getMax() !== null && $min > $this->getMax()) { + throw new LogicException( + sprintf( + 'The min must be less than or equal to the max length, but min: %d and max: %d given.', + $min, + $this->getMax() + ) + ); + } + + $this->min = $min; + + return $this; + } + + /** + * Get the maximum required string length + * + * @return mixed + */ + public function getMax() + { + return $this->max; + } + + /** + * Set the minimum required string length + * + * @param mixed $max + * + * @return $this + * + * @throws LogicException When the $min is greater than the $max value + */ + public function setMax($max): self + { + if ($max !== null && $this->getMin() > $max) { + throw new LogicException( + sprintf( + 'The min must be less than or equal to the max length, but min: %d and max: %d given.', + $this->getMin(), + $max + ) + ); + } + + $this->max = $max; + + return $this; + } + + /** + * Get the encoding type to use + * + * @return ?string + */ + public function getEncoding(): ?string + { + return $this->encoding; + } + + /** + * Set the encoding type to use + * + * @param ?string $encoding + * + * @return $this + */ + public function setEncoding(?string $encoding): self + { + if ($encoding !== null) { + $availableEncodings = array_map('strtolower', mb_list_encodings()); + if (! in_array(strtolower($encoding), $availableEncodings, true)) { + throw new InvalidArgumentException( + sprintf('Given encoding "%s" is not supported on this OS!', $encoding) + ); + } + } + + $this->encoding = $encoding; + + return $this; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if ($encoding = $this->getEncoding()) { // because encoding is only nullable in php >= 8.0 + $length = mb_strlen($value, $encoding); + } else { + $length = mb_strlen($value); + } + + if ($length < $this->getMin()) { + $this->addMessage(sprintf( + $this->translate('String should be %d characters long, %d given'), + $this->getMin(), + $length + )); + + return false; + } + + if ($this->getMax() && $this->getMax() < $length) { + $this->addMessage(sprintf( + $this->translate('String should be %d characters long, %d given'), + $this->getMax(), + $length + )); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/ValidatorChain.php b/vendor/ipl/validator/src/ValidatorChain.php new file mode 100644 index 0000000..2860a12 --- /dev/null +++ b/vendor/ipl/validator/src/ValidatorChain.php @@ -0,0 +1,284 @@ +<?php + +namespace ipl\Validator; + +use Countable; +use InvalidArgumentException; +use ipl\Stdlib\Contract\Validator; +use ipl\Stdlib\Messages; +use ipl\Stdlib\Plugins; +use ipl\Stdlib\PriorityQueue; +use IteratorAggregate; +use SplObjectStorage; +use Traversable; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +class ValidatorChain implements Countable, IteratorAggregate, Validator +{ + use Messages; + use Plugins; + + /** Default priority at which validators are added */ + const DEFAULT_PRIORITY = 1; + + /** @var PriorityQueue Validator chain */ + protected $validators; + + /** @var SplObjectStorage Validators that break the chain on failure */ + protected $validatorsThatBreakTheChain; + + /** + * Create a new validator chain + */ + public function __construct() + { + $this->validators = new PriorityQueue(); + $this->validatorsThatBreakTheChain = new SplObjectStorage(); + + $this->addDefaultPluginLoader('validator', __NAMESPACE__, 'Validator'); + } + + /** + * Get the validators that break the chain + * + * @return SplObjectStorage + */ + public function getValidatorsThatBreakTheChain() + { + return $this->validatorsThatBreakTheChain; + } + + /** + * Add a validator to the chain + * + * If $breakChainOnFailure is true and the validator fails, subsequent validators won't be executed. + * + * @param Validator $validator + * @param bool $breakChainOnFailure + * @param int $priority Priority at which to add validator + * + * @return $this + * + */ + public function add(Validator $validator, $breakChainOnFailure = false, $priority = self::DEFAULT_PRIORITY) + { + $this->validators->insert($validator, $priority); + + if ($breakChainOnFailure) { + $this->validatorsThatBreakTheChain->attach($validator); + } + + return $this; + } + + /** + * Add the validators from the given validator specification to the chain + * + * @param iterable $validators + * + * @return $this + * + * @throws InvalidArgumentException If $validators is not iterable or if the validator specification is invalid + */ + public function addValidators($validators) + { + if ($validators instanceof static) { + return $this->merge($validators); + } + + if (! is_iterable($validators)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter one to be iterable, got %s instead', + __METHOD__, + get_php_type($validators) + )); + } + + foreach ($validators as $name => $validator) { + $breakChainOnFailure = false; + + if (! $validator instanceof Validator) { + if (is_int($name)) { + if (! is_array($validator)) { + $name = $validator; + $validator = null; + } else { + if (! isset($validator['name'])) { + throw new InvalidArgumentException( + 'Invalid validator array specification: Key "name" is missing' + ); + } + + $name = $validator['name']; + unset($validator['name']); + } + } + + if (is_array($validator)) { + if (isset($validator['options'])) { + $options = $validator['options']; + + unset($validator['options']); + + $validator = array_merge($validator, $options); + } + + if (isset($validator['break_chain_on_failure'])) { + $breakChainOnFailure = $validator['break_chain_on_failure']; + + unset($validator['break_chain_on_failure']); + } + } + + $validator = $this->createValidator($name, $validator); + } + + $this->add($validator, $breakChainOnFailure); + } + + return $this; + } + + /** + * Add a validator loader + * + * @param string $namespace Namespace of the validators + * @param string $postfix Validator name postfix, if any + * + * @return $this + */ + public function addValidatorLoader($namespace, $postfix = null) + { + $this->addPluginLoader('validator', $namespace, $postfix); + + return $this; + } + + /** + * Remove all validators from the chain + * + * @return $this + */ + public function clearValidators() + { + $this->validators = new PriorityQueue(); + $this->validatorsThatBreakTheChain = new SplObjectStorage(); + + return $this; + } + + /** + * Create a validator from the given name and options + * + * @param string $name + * @param mixed $options + * + * @return Validator + * + * @throws InvalidArgumentException If the validator to load is unknown + * @throws UnexpectedValueException If a validator loader did not return an instance of {@link Validator} + */ + public function createValidator($name, $options = null) + { + $class = $this->loadPlugin('validator', $name); + + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't load validator '%s'. Validator unknown", + $name + )); + } + + if (empty($options)) { + $validator = new $class(); + } else { + $validator = new $class($options); + } + + if (! $validator instanceof Validator) { + throw new UnexpectedValueException(sprintf( + "%s expects loader to return an instance of %s for validator '%s', got %s instead", + __METHOD__, + Validator::class, + $name, + get_php_type($validator) + )); + } + + return $validator; + } + + /** + * Merge all validators from the given chain into this one + * + * @param ValidatorChain $validatorChain + * + * @return $this + */ + public function merge(ValidatorChain $validatorChain) + { + $validatorsThatBreakTheChain = $validatorChain->getValidatorsThatBreakTheChain(); + + foreach ($validatorChain->validators->yieldAll() as $priority => $validator) { + $this->add($validator, $validatorsThatBreakTheChain->contains($validator), $priority); + } + + return $this; + } + + public function __clone() + { + $this->validators = clone $this->validators; + } + + /** + * Export the chain as array + * + * @return array + */ + public function toArray() + { + return array_values(iterator_to_array($this)); + } + + public function count(): int + { + return count($this->validators); + } + + /** + * Get an iterator for traversing the validators + * + * @return Validator[]|PriorityQueue + */ + public function getIterator(): Traversable + { + // Clone validators because the PriorityQueue acts as a heap and thus items are removed upon iteration + return clone $this->validators; + } + + public function isValid($value) + { + $this->clearMessages(); + + $valid = true; + + foreach ($this as $validator) { + if ($validator->isValid($value)) { + continue; + } + + $valid = false; + + $this->addMessages($validator->getMessages()); + + if ($this->validatorsThatBreakTheChain->contains($validator)) { + break; + } + } + + return $valid; + } +} diff --git a/vendor/ipl/validator/src/X509CertValidator.php b/vendor/ipl/validator/src/X509CertValidator.php new file mode 100644 index 0000000..7dfc4f7 --- /dev/null +++ b/vendor/ipl/validator/src/X509CertValidator.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates an X.509 certificate + */ +class X509CertValidator extends BaseValidator +{ + use Translation; + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (preg_match('/\A\s*\w+:/', $value)) { + $this->addMessage($this->translate('URLs are not allowed')); + + return false; + } + + if (openssl_x509_parse($value) === false) { + $this->addMessage($this->translate('Not a valid PEM-encoded X.509 certificate')); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/web/LICENSE b/vendor/ipl/web/LICENSE new file mode 100644 index 0000000..a904102 --- /dev/null +++ b/vendor/ipl/web/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/web/asset/static/font/icinga-icons/selection.json b/vendor/ipl/web/asset/static/font/icinga-icons/selection.json new file mode 100644 index 0000000..015faf0 --- /dev/null +++ b/vendor/ipl/web/asset/static/font/icinga-icons/selection.json @@ -0,0 +1 @@ +{"IcoMoonType":"selection","icons":[{"icon":{"paths":["M64 0h896c35.328 0 64 28.672 64 64v640c0 35.328-28.672 64-64 64h-350.72v-64h350.72v-640h-896v640h33.28v64h-33.28c-35.328 0-64-28.672-64-64v-640c0-35.328 28.672-64 64-64z","M576.048 481.122c0 123.698-100.277 223.976-223.976 223.976s-223.976-100.277-223.976-223.976c0-123.698 100.277-223.976 223.976-223.976s223.976 100.277 223.976 223.976z","M511.488 720.896l64 303.104-224.32-128-223.168 128 64-303.488c45.76 30.656 100.8 48.576 160 48.576 58.944 0 113.792-17.728 159.488-48.192z","M896 192c0-16.96-6.784-33.28-18.752-45.248-12.032-12.032-28.288-18.752-45.248-18.752-71.040 0-185.024 0-256 0-17.024 0-33.28 6.72-45.248 18.752-12.032 11.968-18.752 28.288-18.752 45.248s6.72 33.28 18.752 45.248c11.968 12.032 28.224 18.752 45.248 18.752 70.976 0 184.96 0 256 0 16.96 0 33.216-6.72 45.248-18.752 11.968-11.968 18.752-28.288 18.752-45.248z","M896 384c0-16.96-6.784-33.28-18.752-45.248-12.032-12.032-28.288-18.752-45.248-18.752-71.040 0-57.024 0-128 0-17.024 0-33.28 6.72-45.248 18.752-12.032 11.968-18.752 28.288-18.752 45.248s6.72 33.28 18.752 45.248c11.968 12.032 28.224 18.752 45.248 18.752 70.976 0 56.96 0 128 0 16.96 0 33.216-6.72 45.248-18.752 11.968-11.968 18.752-28.288 18.752-45.248z"],"attrs":[],"grid":0,"tags":["certificate"]},"attrs":[],"properties":{"order":18,"id":11,"name":"certificate","prevSize":32,"code":59654},"setIdx":0,"setId":1,"iconIdx":0},{"icon":{"paths":["M7.232 90.496c11.776-24.896 36.672-40.704 64.192-40.704h768c27.584 0 52.48 15.808 64.192 40.704s8.192 54.208-9.216 75.52l-189.376 231.488c-142.592 29.312-249.6 155.392-249.6 306.496 0 48.704 11.2 94.912 31.104 136-2.816-1.408-5.696-3.2-8.32-5.184l-113.792-85.312c-14.4-10.688-22.784-27.52-22.784-45.504v-140.608l-325.312-397.504c-17.216-21.184-20.992-50.688-9.088-75.392z","M1024 705.024c-2.176 72.512-27.456 132.992-75.84 181.504-48.384 48.576-108.672 73.344-180.992 74.496-72.256-1.152-132.288-25.92-180.096-74.496-47.808-48.512-72.832-108.992-75.072-181.504 2.24-72.512 27.264-133.056 75.072-181.568s107.84-73.344 180.096-74.432c72.32 1.088 132.608 25.92 180.992 74.432s73.664 109.056 75.84 181.568zM966.144 581.248c-0.512-0.704-1.088-1.344-1.728-1.92-22.656-22.72-59.52-22.72-82.176 0l-80.448 80.32-33.024 76.608c0 0-25.6-65.728-25.6-65.728l-29.312-29.312c-22.656-22.656-59.52-22.656-82.176 0-22.72 22.72-22.72 59.52 0 82.24l94.272 94.272c11.2 11.2 25.856 16.832 40.512 16.96 0.448 0 0.896 0 1.28 0 14.656-0.192 29.248-5.824 40.384-16.96l156.224-156.224c22.016-22.016 22.656-57.472 1.792-80.256z"],"attrs":[],"grid":0,"tags":["filter-check-circle"]},"attrs":[],"properties":{"order":15,"id":10,"name":"filter-check-circle","prevSize":32,"code":59659},"setIdx":0,"setId":1,"iconIdx":1},{"icon":{"paths":["M177.038 607.479c-5.88 0.539-12.299 0.833-19.306 0.833-22.54 0-43.317-4.018-62.378-12.103-19.061-8.036-35.672-19.453-49.883-34.202-14.259-14.749-25.382-32.34-33.418-52.725s-12.103-43.072-12.103-68.013c0-24.941 4.165-47.727 12.495-68.405s19.6-38.22 33.81-52.725c14.21-14.504 30.968-25.48 50.324-33.026 19.306-7.497 40.23-11.27 62.77-11.27 20.384 0 40.671 3.92 60.761 11.711 20.139 7.742 36.358 19.159 48.707 34.202l-48.266 52.333c-6.468-10.241-15.043-17.591-25.774-22.148s-22.001-6.86-33.81-6.86c-11.809 0-22.932 2.401-33.418 7.252-10.437 4.851-19.453 11.515-26.95 20.139-7.497 8.575-13.279 18.767-17.297 30.576s-6.076 24.696-6.076 38.613c0 13.965 2.058 26.999 6.076 39.054 4.018 12.103 9.8 22.295 17.297 30.576 7.497 8.33 16.219 14.896 26.166 19.747 9.898 4.802 21.070 7.252 33.369 7.252 13.965 0 26.313-2.989 37.044-8.869 8.575-4.851 15.827-11.025 21.756-18.522l-41.896 96.58z","M484.864 594.112l-18.752-49.408h-123.2l-23.296 63.616h-82.944l132.8-321.984h74.88l90.688 222.4c-21.632 25.92-38.336 54.336-50.176 85.376zM405.696 372.48l-40.192 110.272h79.68l-39.488-110.272z","M1024 705.024c-2.176 72.512-27.456 132.992-75.84 181.504-48.384 48.576-108.672 73.344-180.992 74.496-72.256-1.152-132.288-25.92-180.096-74.496-47.808-48.512-72.832-108.992-75.072-181.504 2.24-72.512 27.264-133.056 75.072-181.568s107.84-73.344 180.096-74.432c72.32 1.088 132.608 25.92 180.992 74.432s73.664 109.056 75.84 181.568zM966.144 581.248c-0.512-0.704-1.088-1.344-1.728-1.92-22.656-22.72-59.52-22.72-82.176 0l-80.448 80.32-33.024 76.608c0 0-25.6-65.728-25.6-65.728l-29.312-29.312c-22.656-22.656-59.52-22.656-82.176 0-22.72 22.72-22.72 59.52 0 82.24l94.272 94.272c11.2 11.2 25.856 16.832 40.512 16.96 0.448 0 0.896 0 1.28 0 14.656-0.192 29.248-5.824 40.384-16.96l156.224-156.224c22.016-22.016 22.656-57.472 1.792-80.256z"],"attrs":[],"grid":0,"tags":["filter-circle-check"]},"attrs":[],"properties":{"order":16,"id":9,"name":"ca-circle-check","prevSize":32,"code":59656},"setIdx":0,"setId":1,"iconIdx":2},{"icon":{"paths":["M319.936 448h384v257.088h-384v-257.088z","M383.936 416v-96c0-35.328 28.672-64 64-64h128c35.328 0 64 28.672 64 64v96h-256zM575.936 320h-128v96h128v-96z","M192.448 724.864c68.864 103.168 186.368 171.136 319.552 171.136 141.568 0 265.344-76.8 331.904-190.912h142.4c-76.288 187.008-260.032 318.912-474.304 318.912-180.544 0-339.392-93.632-430.592-235.008l-81.536 47.104 2.112-324.096 281.472 160.384-91.008 52.48zM833.472 302.080c-68.544-104.832-187.008-174.080-321.472-174.080-167.040 0-309.376 106.944-362.112 256h-133.76c56.896-220.736 257.472-384 495.872-384 181.824 0 341.632 94.976 432.448 237.952l79.68-45.952-2.112 324.096-281.472-160.384 92.928-53.632z"],"attrs":[],"grid":0,"tags":["refresh-cert"]},"attrs":[],"properties":{"order":17,"id":8,"name":"refresh-cert","prevSize":32,"code":59657},"setIdx":0,"setId":1,"iconIdx":3},{"icon":{"paths":["M64.491 787.398c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h30.6c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-30.6c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768zM64.491 512c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h30.6c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-30.6c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768zM64.491 236.602c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h30.6c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-30.6c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768zM288.252 787.398c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h534.54c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-534.54c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768zM288.252 512c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h534.54c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-534.54c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768zM288.252 236.602c0 19.125 6.694 35.381 20.081 48.768s29.644 20.081 48.768 20.081h534.54c19.125 0 35.381-6.694 48.768-20.081s20.081-29.644 20.081-48.768c0-19.125-6.694-35.381-20.081-48.768s-29.644-20.081-48.768-20.081h-534.54c-19.125 0-35.381 6.694-48.768 20.081s-20.081 29.644-20.081 48.768z"],"attrs":[],"grid":0,"tags":["th-list"]},"attrs":[],"properties":{"order":11,"id":7,"name":"th-list","prevSize":32,"code":59658},"setIdx":0,"setId":1,"iconIdx":4},{"icon":{"paths":["M574.496 81.411l45.262 8.286-149.128 422.304-45.261-8.286 149.126-422.304z","M896.001 369.329v29.341l-379.111 128.001-9.78-29.341 388.891-128.001z","M656 768v43.787l-164.906-224.295 41.812-23.571 123.093 204.079z","M205.566 845.588l-27.131-27.175 205.565-242.414 63.999 13.589-242.434 255.999z","M192.001 320.001l-38.798-52.36 308.952 225.624-28.309 38.763-241.845-212.028z","M576.418 0.897c75.061 0 136.002 60.94 136.002 136s-60.941 136-136.002 136c-75.058 0-135.999-60.94-135.999-136s60.941-136 135.999-136z","M911.998 272.897c61.815 0 111.999 50.187 111.999 112 0 61.816-50.185 112-111.999 112s-111.999-50.184-111.999-112c0-61.813 50.185-112 111.999-112z","M656 719.998c44.154 0 80.002 35.85 80.002 80.001 0 44.155-35.848 80.001-80.002 80.001-44.15 0-79.998-35.846-79.998-80.001 0-44.151 35.848-80.001 79.998-80.001z","M143.999 735.999c79.478 0 144.002 64.526 144.002 144.002s-64.524 143.999-144.002 143.999c-79.475 0-143.999-64.524-143.999-143.999s64.524-144.002 143.999-144.002z","M139.048 191.022c52.984 0 96.001 43.016 96.001 96.001 0 52.982-43.018 95.998-96.001 95.998s-95.998-43.016-95.998-95.998c0-52.985 43.014-96.001 95.998-96.001z","M448 319.706c105.968 0 192 86.034 192 192.001s-86.032 191.999-192 191.999c-105.968 0-192-86.032-192-191.999s86.032-192.001 192-192.001z"],"attrs":[],"grid":0,"tags":["icinga"]},"attrs":[],"properties":{"order":10,"id":6,"name":"icinga","prevSize":32,"code":59655},"setIdx":0,"setId":1,"iconIdx":5},{"icon":{"paths":["M192.009 128.005c0 35.361-28.665 64.026-64.026 64.026s-64.026-28.665-64.026-64.026c0-35.361 28.665-64.026 64.026-64.026s64.026 28.665 64.026 64.026z","M192.009 320.329c0 35.361-28.665 64.026-64.026 64.026s-64.026-28.665-64.026-64.026c0-35.361 28.665-64.026 64.026-64.026s64.026 28.665 64.026 64.026z","M192.009 512.329c0 35.361-28.665 64.026-64.026 64.026s-64.026-28.665-64.026-64.026c0-35.361 28.665-64.026 64.026-64.026s64.026 28.665 64.026 64.026z","M320.006 96.003h640.004v64.005h-640.004v-64.005z","M320.006 480.326h640.004v64.005h-640.004v-64.005z","M320.006 288.326h640.004v64.005h-640.004v-64.005z","M320.006 672.326h640.004v64.005h-640.004v-64.005z","M320.006 864.326h640.004v64.005h-640.004v-64.005z","M192.009 704.652c0 35.361-28.665 64.026-64.026 64.026s-64.026-28.665-64.026-64.026c0-35.361 28.665-64.026 64.026-64.026s64.026 28.665 64.026 64.026z","M192.009 896.329c0 35.361-28.665 64.026-64.026 64.026s-64.026-28.665-64.026-64.026c0-35.361 28.665-64.026 64.026-64.026s64.026 28.665 64.026 64.026z"],"attrs":[],"grid":0,"tags":["list-view-minimal"]},"attrs":[],"properties":{"order":4,"id":5,"name":"list-view-minimal","prevSize":32,"code":59648},"setIdx":0,"setId":1,"iconIdx":6},{"icon":{"paths":["M320.007 128.321h639.993v191.358h-639.993v-191.358z","M320.007 384.003h639.993v63.992h-639.993v-63.992z","M256.014 223.683c0 53.041-42.998 96.039-96.039 96.039s-96.039-42.998-96.039-96.039c0-53.041 42.998-96.039 96.039-96.039s96.039 42.998 96.039 96.039z","M256.014 672.008c0 53.041-42.998 96.039-96.039 96.039s-96.039-42.998-96.039-96.039c0-53.041 42.998-96.039 96.039-96.039s96.039 42.998 96.039 96.039z","M320.007 575.996h639.993v192.005h-639.993v-192.005z","M320.007 831.995h639.993v63.992h-639.993v-63.992z"],"attrs":[],"grid":0,"tags":["list-view-detailed"]},"attrs":[],"properties":{"order":5,"id":4,"name":"list-view-detailed","prevSize":32,"code":59649},"setIdx":0,"setId":1,"iconIdx":7},{"icon":{"paths":["M256.015 192.008c0 53.041-42.998 96.039-96.039 96.039s-96.039-42.998-96.039-96.039c0-53.041 42.998-96.039 96.039-96.039s96.039 42.998 96.039 96.039z","M384.001 96h576.002v192.008h-576.002v-192.008z","M384.001 416.002h576.002v192.008h-576.002v-192.008z","M384.001 736.002h576.002v192.008h-576.002v-192.008z","M256.015 512.010c0 53.041-42.998 96.039-96.039 96.039s-96.039-42.998-96.039-96.039c0-53.041 42.998-96.039 96.039-96.039s96.039 42.998 96.039 96.039z","M256.015 832.010c0 53.041-42.998 96.039-96.039 96.039s-96.039-42.998-96.039-96.039c0-53.041 42.998-96.039 96.039-96.039s96.039 42.998 96.039 96.039z"],"attrs":[],"grid":0,"tags":["listr-view-default"]},"attrs":[],"properties":{"order":6,"id":3,"name":"list-view-default","prevSize":32,"code":59650},"setIdx":0,"setId":1,"iconIdx":8},{"icon":{"paths":["M64.059 911.075v-274.364c0-14.39 4.781-25.902 14.342-35.495s21.035-14.39 35.377-15.349h273.451c13.386 0 24.859 4.797 35.377 15.349s15.298 22.064 14.342 35.495v274.364c0 13.43-4.781 24.942-14.342 34.535s-21.035 14.39-35.377 14.39h-273.451c-14.342 0-25.815-4.797-35.377-14.39s-14.342-21.105-14.342-34.535zM64.059 387.289v-274.364c0-13.43 4.781-24.942 14.342-34.535s21.035-14.39 35.377-14.39h273.451c13.386 0 24.859 4.797 35.377 14.39s15.298 21.105 14.342 34.535v274.364c0 14.39-4.781 25.902-14.342 35.495s-21.035 14.39-35.377 15.349h-273.451c-13.386 0-24.859-4.797-35.377-15.349s-15.298-22.064-14.342-35.495zM148.197 876.539h204.61v-205.293h-204.61v205.293zM148.197 352.753h204.61v-204.334h-204.61v204.334zM587.058 911.075v-274.364c0-14.39 4.781-25.902 14.342-35.495s21.035-14.39 35.377-15.349h273.451c13.386 0 24.859 4.797 35.377 15.349s15.298 22.064 14.342 35.495v274.364c0 13.43-4.781 24.942-14.342 34.535s-21.035 14.39-35.377 14.39h-273.451c-13.386 0-24.859-4.797-35.377-14.39s-15.298-21.105-14.342-34.535zM587.058 387.289v-274.364c0-13.43 4.781-24.942 14.342-34.535s21.035-14.39 35.377-14.39h273.451c14.342 0 25.815 4.797 35.377 14.39s14.342 21.105 14.342 34.535v274.364c0 14.39-4.781 25.902-14.342 35.495s-21.035 14.39-35.377 15.349h-273.451c-13.386 0-24.859-4.797-35.377-15.349s-15.298-22.064-14.342-35.495zM671.197 876.539h205.566v-205.293h-205.566v205.293zM671.197 352.753h205.566v-204.334h-205.566v204.334z"],"attrs":[],"grid":0,"tags":["th-thumb-empty"]},"attrs":[],"properties":{"order":7,"id":2,"name":"grid","prevSize":32,"code":59651},"setIdx":0,"setId":1,"iconIdx":9},{"icon":{"paths":["M473.568 780.8c0 59.098 44.467 107.789 101.76 114.432l6.682 0.576 6.758 0.192h-153.6l-6.758-0.192c-58.253-3.379-104.87-49.997-108.25-108.25l-0.192-6.758v-537.6l0.192-6.758c3.226-55.91 46.349-101.107 101.299-107.635l6.95-0.614 6.758-0.192h153.6l-6.758 0.192c-55.91 3.226-101.107 46.349-107.635 101.299l-0.614 6.95-0.192 6.758v537.6z"],"attrs":[],"grid":0,"tags":["bracket-open"]},"attrs":[],"properties":{"order":8,"id":1,"name":"bracket-open","prevSize":32,"code":59652},"setIdx":0,"setId":1,"iconIdx":10},{"icon":{"paths":["M447.136 780.8c0 59.098-44.467 107.789-101.76 114.432l-6.682 0.576-6.758 0.192h153.6l6.758-0.192c58.253-3.379 104.87-49.997 108.25-108.25l0.192-6.758v-537.6l-0.192-6.758c-3.226-55.91-46.349-101.107-101.299-107.635l-6.95-0.614-6.758-0.192h-153.6l6.758 0.192c55.91 3.226 101.107 46.349 107.635 101.299l0.614 6.95 0.192 6.758v537.6z"],"attrs":[],"grid":0,"tags":["bracket-close"]},"attrs":[],"properties":{"order":13,"id":0,"name":"bracket-close","prevSize":32,"code":59653},"setIdx":0,"setId":1,"iconIdx":11}],"height":1024,"metadata":{"name":"Icinga-Icons","url":"https://icinga.com","designer":"Florian Strohmaier (Icinga)","designerURL":"https://icinga.com","license":"Proprietary"},"preferences":{"showGlyphs":true,"showQuickUse":true,"showQuickUse2":true,"showSVGs":true,"fontPref":{"prefix":"iicon-","metadata":{"fontFamily":"Icinga-Icons","majorVersion":1,"minorVersion":0,"fontURL":"https://icinga.com","copyright":"Icinga GmbH","designer":"Florian Strohmaier (Icinga)","designerURL":"https://icinga.com","license":"Proprietary"},"metrics":{"emSize":1024,"baseline":6.25,"whitespace":50},"embed":false,"noie8":true,"ie7":false,"showSelector":true,"showMetrics":false,"showMetadata":false,"showVersion":false},"imagePref":{"prefix":"icon-","png":true,"useClassSelector":true,"color":0,"bgColor":16777215,"classSelector":".icon"},"historySize":50,"showCodes":true,"gridSize":16,"showGrid":false}}
\ No newline at end of file diff --git a/vendor/ipl/web/composer.json b/vendor/ipl/web/composer.json new file mode 100644 index 0000000..642039e --- /dev/null +++ b/vendor/ipl/web/composer.json @@ -0,0 +1,39 @@ +{ + "name": "ipl/web", + "type": "library", + "description": "Icinga PHP Library - Web Components", + "keywords": ["html"], + "homepage": "https://github.com/Icinga/ipl-web", + "license": "MIT", + "autoload": { + "psr-4": { + "ipl\\Web\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Web\\": "tests", + "ipl\\Tests\\Html\\": "vendor/ipl/html/tests" + } + }, + "require": { + "php": ">=7.2", + "ext-json": "*", + "psr/http-message": "^1.1", + "ipl/html": ">=0.8.0", + "ipl/i18n": ">=0.2.0", + "ipl/orm": ">=0.5.2", + "ipl/scheduler": ">=0.1.0", + "ipl/stdlib": ">=0.13.0", + "fortawesome/font-awesome": "^6", + "wikimedia/less.php": "^3.2.1" + }, + "require-dev": { + "ipl/html": "dev-main", + "ipl/i18n": "dev-main", + "ipl/orm": "dev-main", + "ipl/scheduler": "dev-main", + "ipl/stdlib": "dev-main", + "shardj/zf1-future": "^1.22" + } +} diff --git a/vendor/ipl/web/src/Common/BaseItemList.php b/vendor/ipl/web/src/Common/BaseItemList.php new file mode 100644 index 0000000..ce0946c --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseItemList.php @@ -0,0 +1,73 @@ +<?php + +namespace ipl\Web\Common; + +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Orm\ResultSet; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Widget\EmptyStateBar; + +/** + * Base class for item lists + */ +abstract class BaseItemList extends BaseHtmlElement +{ + use BaseFilter; + + /** @var array<string, mixed> */ + protected $baseAttributes = [ + 'class' => ['item-list', 'default-layout'], + 'data-base-target' => '_next', + 'data-pdfexport-page-breaks-at' => '.list-item' + ]; + + /** @var ResultSet|iterable<object> */ + protected $data; + + protected $tag = 'ul'; + + /** + * Create a new item list + * + * @param ResultSet|iterable<object> $data Data source of the list + */ + public function __construct($data) + { + if (! is_iterable($data)) { + throw new InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + abstract protected function getItemClass(): string; + + /** + * Initialize the item list + * + * If you want to adjust the item list after construction, override this method. + */ + protected function init(): void + { + } + + protected function assemble(): void + { + $itemClass = $this->getItemClass(); + foreach ($this->data as $data) { + /** @var BaseListItem|BaseTableRowItem $item */ + $item = new $itemClass($data, $this); + $this->addHtml($item); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar(t('No items found.'))); + } + } +} diff --git a/vendor/ipl/web/src/Common/BaseItemTable.php b/vendor/ipl/web/src/Common/BaseItemTable.php new file mode 100644 index 0000000..f6ca212 --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseItemTable.php @@ -0,0 +1,88 @@ +<?php + +namespace ipl\Web\Common; + +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Orm\ResultSet; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Widget\EmptyStateBar; + +/** + * Base class for item tables + */ +abstract class BaseItemTable extends BaseHtmlElement +{ + use BaseFilter; + + /** @var string Defines the layout used by this item */ + public const TABLE_LAYOUT = 'table-layout'; + + /** @var array<string, mixed> */ + protected $baseAttributes = [ + 'class' => 'item-table', + 'data-base-target' => '_next' + ]; + + /** @var ResultSet|iterable<object> */ + protected $data; + + protected $tag = 'ul'; + + /** + * Create a new item table + * + * @param ResultSet|iterable<object> $data Data source of the table + */ + public function __construct($data) + { + if (! is_iterable($data)) { + throw new InvalidArgumentException('Data must be an array or an instance of Traversable'); + } + + $this->data = $data; + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + /** + * Initialize the item table + * + * If you want to adjust the item table after construction, override this method. + */ + protected function init(): void + { + } + + /** + * Get the table layout to use + * + * @return string + */ + protected function getLayout(): string + { + return static::TABLE_LAYOUT; + } + + abstract protected function getItemClass(): string; + + protected function assemble(): void + { + $this->addAttributes(['class' => $this->getLayout()]); + + $itemClass = $this->getItemClass(); + foreach ($this->data as $data) { + /** @var BaseTableRowItem $item */ + $item = new $itemClass($data, $this); + + $this->addHtml($item); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar(t('No items found.'))); + } + } +} diff --git a/vendor/ipl/web/src/Common/BaseListItem.php b/vendor/ipl/web/src/Common/BaseListItem.php new file mode 100644 index 0000000..cf143ee --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseListItem.php @@ -0,0 +1,145 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; + +/** + * Base class for list items + */ +abstract class BaseListItem extends BaseHtmlElement +{ + /** @var array<string, mixed> */ + protected $baseAttributes = ['class' => 'list-item']; + + /** @var object The associated list item */ + protected $item; + + /** @var BaseItemList The list where the item is part of */ + protected $list; + + protected $tag = 'li'; + + /** + * Create a new list item + * + * @param object $item + * @param BaseItemList $list + */ + public function __construct($item, BaseItemList $list) + { + $this->item = $item; + $this->list = $list; + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + abstract protected function assembleHeader(BaseHtmlElement $header): void; + + abstract protected function assembleMain(BaseHtmlElement $main): void; + + protected function assembleFooter(BaseHtmlElement $footer): void + { + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + } + + protected function createCaption(): BaseHtmlElement + { + $caption = new HtmlElement('section', Attributes::create(['class' => 'caption'])); + + $this->assembleCaption($caption); + + return $caption; + } + + protected function createHeader(): BaseHtmlElement + { + $header = new HtmlElement('header'); + + $this->assembleHeader($header); + + return $header; + } + + protected function createMain(): BaseHtmlElement + { + $main = new HtmlElement('div', Attributes::create(['class' => 'main'])); + + $this->assembleMain($main); + + return $main; + } + + protected function createFooter(): ?BaseHtmlElement + { + $footer = new HtmlElement('footer'); + + $this->assembleFooter($footer); + if ($footer->isEmpty()) { + return null; + } + + return $footer; + } + + protected function createTimestamp(): ?BaseHtmlElement + { + return null; + } + + protected function createTitle(): BaseHtmlElement + { + $title = new HtmlElement('div', Attributes::create(['class' => 'title'])); + + $this->assembleTitle($title); + + return $title; + } + + /** + * @return ?BaseHtmlElement + */ + protected function createVisual(): ?BaseHtmlElement + { + $visual = new HtmlElement('div', Attributes::create(['class' => 'visual'])); + + $this->assembleVisual($visual); + if ($visual->isEmpty()) { + return null; + } + + return $visual; + } + + /** + * Initialize the list item + * + * If you want to adjust the list item after construction, override this method. + */ + protected function init(): void + { + } + + protected function assemble(): void + { + $this->add([ + $this->createVisual(), + $this->createMain() + ]); + } +} diff --git a/vendor/ipl/web/src/Common/BaseOrderedItemList.php b/vendor/ipl/web/src/Common/BaseOrderedItemList.php new file mode 100644 index 0000000..c141fc5 --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseOrderedItemList.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Web\Widget\EmptyStateBar; + +/** + * @method BaseOrderedListItem getItemClass() + */ +abstract class BaseOrderedItemList extends BaseItemList +{ + protected $tag = 'ol'; + + protected function assemble(): void + { + $itemClass = $this->getItemClass(); + + $i = 0; + foreach ($this->data as $data) { + $item = new $itemClass($data, $this); + $item->setOrder($i++); + + $this->addHtml($item); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar(t('No items found.'))); + } + } +} diff --git a/vendor/ipl/web/src/Common/BaseOrderedListItem.php b/vendor/ipl/web/src/Common/BaseOrderedListItem.php new file mode 100644 index 0000000..03b387d --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseOrderedListItem.php @@ -0,0 +1,42 @@ +<?php + +namespace ipl\Web\Common; + +use LogicException; + +abstract class BaseOrderedListItem extends BaseListItem +{ + /** @var ?int This element's position */ + protected $order; + + /** + * Set this element's position + * + * @param int $order + * + * @return $this + */ + public function setOrder(int $order): self + { + $this->order = $order; + + return $this; + } + + /** + * Get this element's position + * + * @return int + * @throws LogicException When calling this method without setting the `order` property + */ + public function getOrder(): int + { + if ($this->order === null) { + throw new LogicException( + 'You are accessing an unset property. Please make sure to set it beforehand.' + ); + } + + return $this->order; + } +} diff --git a/vendor/ipl/web/src/Common/BaseTableRowItem.php b/vendor/ipl/web/src/Common/BaseTableRowItem.php new file mode 100644 index 0000000..bc61c8e --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseTableRowItem.php @@ -0,0 +1,119 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; + +abstract class BaseTableRowItem extends BaseHtmlElement +{ + /** @var array<string, mixed> */ + protected $baseAttributes = ['class' => 'table-row']; + + /** @var object The associated list item */ + protected $item; + + /** @var ?BaseItemTable The list where the item is part of */ + protected $table; + + protected $tag = 'li'; + + /** + * Create a new table row item + * + * @param object $item + * @param BaseItemTable|null $table + */ + public function __construct($item, BaseItemTable $table = null) + { + $this->item = $item; + $this->table = $table; + + if ($table === null) { + $this->setTag('div'); + } + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + abstract protected function assembleTitle(BaseHtmlElement $title): void; + + protected function assembleColumns(HtmlDocument $columns): void + { + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + } + + /** + * Create column + * + * @param mixed $content + * + * @return BaseHtmlElement + */ + protected function createColumn($content = null): BaseHtmlElement + { + return new HtmlElement( + 'div', + Attributes::create(['class' => 'col']), + new HtmlElement( + 'div', + Attributes::create(['class' => 'content']), + ...Html::wantHtmlList($content) + ) + ); + } + + protected function createColumns(): HtmlDocument + { + $columns = new HtmlDocument(); + + $this->assembleColumns($columns); + + return $columns; + } + + protected function createTitle(): BaseHtmlElement + { + $title = $this->createColumn()->addAttributes(['class' => 'title']); + + $this->assembleTitle($title->getFirst('div')); + + $title->prepend($this->createVisual()); + + return $title; + } + + protected function createVisual(): ?BaseHtmlElement + { + $visual = new HtmlElement('div', Attributes::create(['class' => 'visual'])); + + $this->assembleVisual($visual); + + return $visual->isEmpty() ? null : $visual; + } + + /** + * Initialize the list item + * + * If you want to adjust the list item after construction, override this method. + */ + protected function init(): void + { + } + + protected function assemble(): void + { + $this->addHtml( + $this->createTitle(), + $this->createColumns() + ); + } +} diff --git a/vendor/ipl/web/src/Common/BaseTarget.php b/vendor/ipl/web/src/Common/BaseTarget.php new file mode 100644 index 0000000..080f6c6 --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseTarget.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Web\Common; + +/** + * @method \ipl\Html\Attributes getAttributes() + */ +trait BaseTarget +{ + /** + * Get the data-base-target attribute + * + * @return string|null + */ + public function getBaseTarget(): ?string + { + /** @var ?string $baseTarget */ + $baseTarget = $this->getAttributes()->get('data-base-target')->getValue(); + + return $baseTarget; + } + + /** + * Set the data-base-target attribute + * + * @param string $target + * + * @return $this + */ + public function setBaseTarget(string $target): self + { + $this->getAttributes()->set('data-base-target', $target); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Common/Card.php b/vendor/ipl/web/src/Common/Card.php new file mode 100644 index 0000000..434132c --- /dev/null +++ b/vendor/ipl/web/src/Common/Card.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +abstract class Card extends BaseHtmlElement +{ + protected $tag = 'section'; + + abstract protected function assembleBody(BaseHtmlElement $body); + + abstract protected function assembleHeader(BaseHtmlElement $header); + + protected function assembleFooter(BaseHtmlElement $footer) + { + } + + protected function createBody() + { + $body = Html::tag('div', ['class' => 'card-body']); + + $this->assembleBody($body); + + return $body; + } + + protected function createFooter() + { + $footer = Html::tag('div', ['class' => 'card-footer']); + + $this->assembleFooter($footer); + + if (! $footer->isEmpty()) { + return $footer; + } + } + + protected function createHeader() + { + $header = Html::tag('div', ['class' => 'card-header']); + + $this->assembleHeader($header); + + return $header; + } + + protected function assemble() + { + $this->addAttributes(['class' => 'card']); + + $this->add([ + $this->createHeader(), + $this->createBody(), + $this->createFooter() + ]); + } +} diff --git a/vendor/ipl/web/src/Common/CsrfCounterMeasure.php b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php new file mode 100644 index 0000000..348c4ee --- /dev/null +++ b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php @@ -0,0 +1,48 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; + +trait CsrfCounterMeasure +{ + /** + * Create a form element to counter measure CSRF attacks + * + * @param string $uniqueId A unique ID that persists through different requests + * + * @return FormElement + */ + protected function createCsrfCounterMeasure($uniqueId) + { + $hashAlgo = in_array('sha3-256', hash_algos(), true) ? 'sha3-256' : 'sha256'; + + $seed = random_bytes(16); + $token = base64_encode($seed) . '|' . hash($hashAlgo, $uniqueId . $seed); + + /** @var Form $this */ + return $this->createElement( + 'hidden', + 'CSRFToken', + [ + 'ignore' => true, + 'required' => true, + 'value' => $token, + 'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) { + if (strpos($token, '|') === false) { + die('Invalid CSRF token provided'); + } + + list($seed, $hash) = explode('|', $token); + + if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) { + die('Invalid CSRF token provided'); + } + + return true; + }] + ] + ); + } +} diff --git a/vendor/ipl/web/src/Common/FormUid.php b/vendor/ipl/web/src/Common/FormUid.php new file mode 100644 index 0000000..05aac7b --- /dev/null +++ b/vendor/ipl/web/src/Common/FormUid.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Form; +use ipl\Html\Contract\FormElement; +use LogicException; + +trait FormUid +{ + protected $uidElementName = 'uid'; + + /** + * Create a form element to make this form distinguishable from others + * + * You'll have to define a name for the form for this to work. + * + * @return FormElement + */ + protected function createUidElement() + { + /** @var Form $this */ + $element = $this->createElement('hidden', $this->uidElementName, ['ignore' => true]); + $element->getAttributes()->registerAttributeCallback('value', function () { + /** @var Form $this */ + return $this->getAttributes()->get('name')->getValue(); + }); + + return $element; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method + * and the sent UID is the form's UID. + * + * @return bool + */ + public function hasBeenSent() + { + if (! parent::hasBeenSent()) { + return false; + } elseif ($this->getMethod() === 'GET') { + // Get forms are unlikely to require a UID. If they do, change this. + return true; + } + + /** @var Form $this */ + $name = $this->getAttributes()->get('name')->getValue(); + if (! $name) { + throw new LogicException('Form has no name'); + } + + $values = $this->getRequest()->getParsedBody(); + + return isset($values[$this->uidElementName]) && $values[$this->uidElementName] === $name; + } +} diff --git a/vendor/ipl/web/src/Common/RedirectOption.php b/vendor/ipl/web/src/Common/RedirectOption.php new file mode 100644 index 0000000..0d73ef8 --- /dev/null +++ b/vendor/ipl/web/src/Common/RedirectOption.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; +use LogicException; + +trait RedirectOption +{ + /** + * Create a form element to retrieve the redirect target upon form submit + * + * @return FormElement + */ + protected function createRedirectOption() + { + /** @var Form $this */ + return $this->createElement('hidden', 'redirect'); + } + + /** + * @see Form::getRedirectUrl() + */ + public function getRedirectUrl() + { + /** @var Form $this */ + $redirectOption = $this->getValue('redirect'); + if (! $redirectOption) { + return parent::getRedirectUrl(); + } + + if (! $this->hasElement('CSRFToken') || ! $this->getElement('CSRFToken')->isValid()) { + throw new LogicException( + 'It is not safe to accept redirect targets from submit values without CSRF protection' + ); + } + + return $redirectOption; + } +} diff --git a/vendor/ipl/web/src/Common/StateBadges.php b/vendor/ipl/web/src/Common/StateBadges.php new file mode 100644 index 0000000..e6e9cfd --- /dev/null +++ b/vendor/ipl/web/src/Common/StateBadges.php @@ -0,0 +1,194 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +/** + * @deprecated Use {@see \Icinga\Module\Icingadb\Common\StateBadges} instead. + */ +abstract class StateBadges extends BaseHtmlElement +{ + use BaseFilter; + + /** @var object $item */ + protected $item; + + /** @var string */ + protected $type; + + /** @var string Prefix */ + protected $prefix; + + /** @var Url Badge link */ + protected $url; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'state-badges']; + + /** + * Create a new widget for state badges + * + * @param object $item + */ + public function __construct($item) + { + $this->item = $item; + $this->type = $this->getType(); + $this->prefix = $this->getPrefix(); + $this->url = $this->getBaseUrl(); + } + + /** + * Get the badge base URL + * + * @return Url + */ + abstract protected function getBaseUrl(): Url; + + /** + * Get the type of the items + * + * @return string + */ + abstract protected function getType(): string; + + /** + * Get the prefix for accessing state information + * + * @return string + */ + abstract protected function getPrefix(): string; + + /** + * Get the integer of the given state text + * + * @param string $state + * + * @return int + */ + abstract protected function getStateInt(string $state): int; + + /** + * Get the badge URL + * + * @return Url + */ + public function getUrl(): Url + { + return $this->url; + } + + /** + * Set the badge URL + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url): self + { + $this->url = $url; + + return $this; + } + + /** + * Create a badge link + * + * @param mixed $content + * @param ?array $filter + * + * @return Link + */ + public function createLink($content, array $filter = null): Link + { + $url = clone $this->getUrl(); + + $urlFilter = Filter::all(); + if (! empty($filter)) { + foreach ($filter as $column => $value) { + $urlFilter->add(Filter::equal($column, $value)); + } + } + + if ($this->hasBaseFilter()) { + $urlFilter->add($this->getBaseFilter()); + } + + if (! $urlFilter->isEmpty()) { + $url->setFilter($urlFilter); + } + + return new Link($content, $url); + } + + /** + * Create a state bade + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createBadge(string $state) + { + $key = $this->prefix . "_{$state}"; + + if (isset($this->item->$key) && $this->item->$key) { + return Html::tag('li', $this->createLink( + new StateBadge($this->item->$key, $state), + [$this->type . '.state.soft_state' => $this->getStateInt($state)] + )); + } + + return null; + } + + /** + * Create a state group + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createGroup(string $state) + { + $content = []; + $handledKey = $this->prefix . "_{$state}_handled"; + $unhandledKey = $this->prefix . "_{$state}_unhandled"; + + if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$unhandledKey, $state), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'n' + ] + )); + } + + if (isset($this->item->$handledKey) && $this->item->$handledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$handledKey, $state, true), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'y' + ] + )); + } + + if (empty($content)) { + return null; + } + + return Html::tag('li', Html::tag('ul', $content)); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatController.php b/vendor/ipl/web/src/Compat/CompatController.php new file mode 100644 index 0000000..f4c2fb0 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatController.php @@ -0,0 +1,512 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use Icinga\Application\Version; +use InvalidArgumentException; +use Icinga\Web\Controller; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; +use ipl\Orm\Query; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Web\Control\LimitControl; +use ipl\Web\Control\PaginationControl; +use ipl\Web\Control\SearchBar; +use ipl\Web\Control\SortControl; +use ipl\Web\Layout\Content; +use ipl\Web\Layout\Controls; +use ipl\Web\Layout\Footer; +use ipl\Web\Url; +use ipl\Web\Widget\Tabs; +use LogicException; +use Psr\Http\Message\ServerRequestInterface; + +class CompatController extends Controller +{ + /** @var Content */ + protected $content; + + /** @var Controls */ + protected $controls; + + /** @var HtmlDocument */ + protected $document; + + /** @var Footer */ + protected $footer; + + /** @var Tabs */ + protected $tabs; + + /** @var array */ + protected $parts; + + protected function prepareInit() + { + parent::prepareInit(); + + $this->params->shift('isIframe'); + $this->params->shift('showFullscreen'); + $this->params->shift('showCompact'); + $this->params->shift('renderLayout'); + $this->params->shift('_disableLayout'); + $this->params->shift('_dev'); + if ($this->params->get('view') === 'compact') { + $this->params->remove('view'); + } + + $this->document = new HtmlDocument(); + $this->document->setSeparator("\n"); + $this->controls = new Controls(); + $this->controls->setAttribute('id', $this->getRequest()->protectId('controls')); + $this->content = new Content(); + $this->content->setAttribute('id', $this->getRequest()->protectId('content')); + $this->footer = new Footer(); + $this->footer->setAttribute('id', $this->getRequest()->protectId('footer')); + $this->tabs = new Tabs(); + $this->tabs->setAttribute('id', $this->getRequest()->protectId('tabs')); + $this->parts = []; + + $this->view->tabs = $this->tabs; + $this->controls->setTabs($this->tabs); + + ViewRenderer::inject(); + + $this->view->document = $this->document; + } + + /** + * Get the current server request + * + * @return ServerRequestInterface + */ + public function getServerRequest() + { + return ServerRequest::fromGlobals(); + } + + /** + * Get the document + * + * @return HtmlDocument + */ + public function getDocument() + { + return $this->document; + } + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Add content + * + * @param ValidHtml $content + * + * @return $this + */ + protected function addContent(ValidHtml $content) + { + $this->content->add($content); + + return $this; + } + + /** + * Add a control + * + * @param ValidHtml $control + * + * @return $this + */ + protected function addControl(ValidHtml $control) + { + $this->controls->add($control); + + if ( + $control instanceof PaginationControl + || $control instanceof LimitControl + || $control instanceof SortControl + || $control instanceof SearchBar + ) { + $this->controls->getAttributes() + ->get('class') + ->removeValue('default-layout') + ->addValue('default-layout'); + } + + return $this; + } + + /** + * Add footer + * + * @param ValidHtml $footer + * + * @return $this + */ + protected function addFooter(ValidHtml $footer) + { + $this->footer->add($footer); + + return $this; + } + + /** + * Add a part to be served as multipart-content + * + * If an id is passed the element is used as-is as the part's content. + * Otherwise (no id given) the element's content is used instead. + * + * @param ValidHtml $element + * @param string $id If not given, this is taken from $element + * + * @throws InvalidArgumentException If no id is given and the element also does not have one + * + * @return $this + */ + protected function addPart(ValidHtml $element, $id = null) + { + $part = new Multipart(); + + if ($id === null) { + if (! $element instanceof BaseHtmlElement) { + throw new InvalidArgumentException('If no id is given, $element must be a BaseHtmlElement'); + } + + $id = $element->getAttributes()->get('id')->getValue(); + if (! $id) { + throw new InvalidArgumentException('Element has no id'); + } + + $part->addFrom($element); + } else { + $part->add($element); + } + + $this->parts[] = $part->setFor($id); + + return $this; + } + + /** + * Set the given title as the window's title + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function setTitle($title, ...$args) + { + if (! empty($args)) { + $title = vsprintf($title, $args); + } + + $this->view->title = $title; + + return $this; + } + + /** + * Add an active tab with the given title and set it as the window's title too + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function addTitleTab($title, ...$args) + { + $this->setTitle($title, ...$args); + + $tabName = uniqid(); + $this->getTabs()->add($tabName, [ + 'label' => $this->view->title, + 'url' => $this->getRequest()->getUrl() + ])->activate($tabName); + + return $this; + } + + /** + * Create and return the LimitControl + * + * This automatically shifts the limit URL parameter from {@link $params}. + * + * @return LimitControl + */ + public function createLimitControl(): LimitControl + { + $limitControl = new LimitControl(Url::fromRequest()); + $limitControl->setDefaultLimit($this->getPageSize(null)); + + $this->params->shift($limitControl->getLimitParam()); + + return $limitControl; + } + + /** + * Create and return the PaginationControl + * + * This automatically shifts the pagination URL parameters from {@link $params}. + * + * @param Paginatable $paginatable + * + * @return PaginationControl + */ + public function createPaginationControl(Paginatable $paginatable): PaginationControl + { + $paginationControl = new PaginationControl($paginatable, Url::fromRequest()); + $paginationControl->setDefaultPageSize($this->getPageSize(null)); + $paginationControl->setAttribute('id', $this->getRequest()->protectId('pagination-control')); + + $this->params->shift($paginationControl->getPageParam()); + $this->params->shift($paginationControl->getPageSizeParam()); + + return $paginationControl->apply(); + } + + /** + * Create and return the SortControl + * + * This automatically shifts the sort URL parameter from {@link $params}. + * + * @param Query $query + * @param array $columns Possible sort columns as sort string-label pairs + * @param ?array|string $defaultSort Optional default sort column + * + * @return SortControl + */ + public function createSortControl(Query $query, array $columns): SortControl + { + $sortControl = SortControl::create($columns); + + $this->params->shift($sortControl->getSortParam()); + + $sortControl->handleRequest($this->getServerRequest()); + + $defaultSort = null; + + if (func_num_args() === 3) { + $defaultSort = func_get_args()[2]; + } + + return $sortControl->apply($query, $defaultSort); + } + + /** + * Send a multipart update instead of a standard response + * + * As part of a multipart update, the tabs, content and footer as well as selected controls are + * transmitted in a way the client can render them exclusively instead of a full column reload. + * + * By default the only control included in the response is the pagination control, if added. + * + * @param BaseHtmlElement ...$additionalControls Additional controls to include + * + * @throws LogicException In case an additional control has not been added + */ + public function sendMultipartUpdate(BaseHtmlElement ...$additionalControls) + { + $searchBar = null; + $pagination = null; + $redirectUrl = null; + foreach ($this->controls->getContent() as $control) { + if ($control instanceof PaginationControl) { + $pagination = $control; + } elseif ($control instanceof SearchBar) { + $searchBar = $control; + $redirectUrl = $control->getRedirectUrl(); /** @var Url $redirectUrl */ + } + } + + if ($searchBar !== null && ($changes = $searchBar->getChanges()) !== null) { + $this->addPart(HtmlString::create(json_encode($changes)), 'Behavior:InputEnrichment'); + } + + foreach ($additionalControls as $control) { + $this->addPart($control); + } + + if ($searchBar !== null && $this->content->isEmpty() && ! $searchBar->isValid()) { + // No content and an invalid search bar? That's it then, further updates are not required + return; + } + + if ($this->tabs->count() > 0) { + if ($redirectUrl !== null) { + $this->tabs->setRefreshUrl($redirectUrl); + $this->tabs->getActiveTab()->setUrl($redirectUrl); + + // As long as we still depend on the legacy tab implementation + // there is no other way to influence what the tab extensions + // use as url. (https://github.com/Icinga/icingadb-web/issues/373) + $oldPathInfo = $this->getRequest()->getPathInfo(); + $oldQuery = $_SERVER['QUERY_STRING']; + $this->getRequest()->setPathInfo('/' . $redirectUrl->getPath()); + $_SERVER['QUERY_STRING'] = $redirectUrl->getParams()->toString(); + $this->tabs->ensureAssembled(); + $this->getRequest()->setPathInfo($oldPathInfo); + $_SERVER['QUERY_STRING'] = $oldQuery; + } + + $this->addPart($this->tabs); + } + + if ($pagination !== null) { + if ($redirectUrl !== null) { + $pagination->setUrl(clone $redirectUrl); + } + + $this->addPart($pagination); + } + + if (! $this->content->isEmpty()) { + $this->addPart($this->content); + } + + if (! $this->footer->isEmpty()) { + $this->addPart($this->footer); + } + + if ($redirectUrl !== null) { + $this->getResponse()->setHeader('X-Icinga-Location-Query', $redirectUrl->getQueryString()); + } + } + + /** + * Instruct the client to side-load additional updates + * + * If an item in the given array is indexed by an integer, its value will be used by the client to refresh + * the parent of the element identified by it. The value is expected to be a valid CSS selector such + * as `.foo`, `#foo`. If indexed by a string, the client will use this index to identify a container (by id) and + * will use the value (a URL) to load content into it. Since Icinga Web >= 2.12, the indices can be specified with + * or without the `#` indicator. If you require compatibility with older Icinga Web versions, you have to specify + * the indices (container ids) without the `#` char. + * + * @param array $updates + * + * @return void + */ + public function sendExtraUpdates(array $updates) + { + if (empty($updates)) { + return; + } + + $extraUpdates = []; + foreach ($updates as $key => $value) { + if (is_int($key)) { + $extraUpdates[] = $value; + } else { + $extraUpdates[] = sprintf( + '%s;%s', + $key, + $value instanceof Url ? $value->getAbsoluteUrl() : $value + ); + } + } + + $this->getResponse()->setHeader('X-Icinga-Extra-Updates', join(',', $extraUpdates)); + } + + /** + * Close the modal content and refresh the related view + * + * NOTE: If you use this with older Icinga Web versions (< 2.12), you will need to specify a valid redirect url, + * that will produce the same result as using the `__REFRESH__` redirect with the latest Icinga Web version. + * + * This is supposed to be used in combination with a modal view and closes only the modal, + * and refreshes the modal opener (regardless of whether it is col1 or col2). + * + * @param Url|string $url + * @param bool $refreshCol1 Whether to refresh col1 after the redirect. Is just for compatibility reasons and + * won't be used with latest Icinga Web versions. + * + * @return never + */ + public function closeModalAndRefreshRelatedView($url, bool $refreshCol1 = false) + { + if (version_compare(Version::VERSION, '2.12.0', '<')) { + if (! $url) { + throw new InvalidArgumentException('No redirect url provided'); + } + + if ($refreshCol1) { + $this->sendExtraUpdates(['#col1']); + } + + $this->redirectNow($url); + } else { + $this->redirectNow('__REFRESH__'); + } + } + + /** + * Close the modal content and refresh all the remaining views + * + * NOTE: If you use this with older Icinga Web versions (< 2.12), you will need to specify a valid redirect url, + * that will produce the same result as using the `__REFRESH__` redirect with the latest Icinga Web version. + * + * This is supposed to be used in combination with a modal view and closes only the modal content. It refreshes + * the modal opener (expects to be always col2) and forces a refresh of col1. + * + * @param Url|string $url + * + * @return never + */ + public function closeModalAndRefreshRemainingViews($url) + { + $this->sendExtraUpdates(['#col1']); + + $this->closeModalAndRefreshRelatedView($url); + } + + /** + * Redirect using `__CLOSE__` + * + * Change to a single column layout and refresh col1 + * + * @return never + */ + public function switchToSingleColumnLayout() + { + $this->redirectNow('__CLOSE__'); + } + + public function postDispatch() + { + if (empty($this->parts)) { + if (! $this->content->isEmpty()) { + $this->document->prepend($this->content); + + if (! $this->view->compact && ! $this->controls->isEmpty()) { + $this->document->prepend($this->controls); + } + + if (! $this->footer->isEmpty()) { + $this->document->add($this->footer); + } + } + } else { + $partSeparator = base64_encode(random_bytes(16)); + $this->getResponse()->setHeader('X-Icinga-Multipart-Content', $partSeparator); + + $this->document->setSeparator("\n$partSeparator\n"); + $this->document->add($this->parts); + } + + parent::postDispatch(); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatDecorator.php b/vendor/ipl/web/src/Compat/CompatDecorator.php new file mode 100644 index 0000000..856b758 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatDecorator.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Web\FormDecorator\IcingaFormDecorator; + +/** + * Compat form element decorator based on div elements + * + * @deprecated Use {@see \ipl\Web\FormDecorator\IcingaFormDecorator} instead + */ +class CompatDecorator extends IcingaFormDecorator +{ +} diff --git a/vendor/ipl/web/src/Compat/CompatForm.php b/vendor/ipl/web/src/Compat/CompatForm.php new file mode 100644 index 0000000..97ad10c --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatForm.php @@ -0,0 +1,100 @@ +<?php + +namespace ipl\Web\Compat; + +use http\Exception\InvalidArgumentException; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\Form; +use ipl\Html\FormElement\SubmitButtonElement; +use ipl\Html\FormElement\SubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\I18n\Translation; +use ipl\Web\FormDecorator\IcingaFormDecorator; + +class CompatForm extends Form +{ + use Translation; + + protected $defaultAttributes = ['class' => 'icinga-form icinga-controls']; + + /** + * Render the content of the element to HTML + * + * A duplicate of the primary submit button is being prepended if there is more than one present + * + * @return string + */ + public function renderContent(): string + { + if (count($this->submitElements) > 1) { + return (new HtmlDocument()) + ->setHtmlContent( + $this->duplicateSubmitButton($this->submitButton), + new HtmlString(parent::renderContent()) + ) + ->render(); + } + + return parent::renderContent(); + } + + public function hasDefaultElementDecorator() + { + if (parent::hasDefaultElementDecorator()) { + return true; + } + + $this->setDefaultElementDecorator(new IcingaFormDecorator()); + + return true; + } + + protected function ensureDefaultElementLoaderRegistered() + { + if (! $this->defaultElementLoaderRegistered) { + $this->addPluginLoader( + 'element', + 'ipl\\Web\\FormElement', + 'Element' + ); + + parent::ensureDefaultElementLoaderRegistered(); + } + + return $this; + } + + /** + * Return a duplicate of the given submit button with the `class` attribute fixed to `primary-submit-btn-duplicate` + * + * @param FormSubmitElement $originalSubmitButton + * + * @return FormSubmitElement + */ + public function duplicateSubmitButton(FormSubmitElement $originalSubmitButton): FormSubmitElement + { + $attributes = (clone $originalSubmitButton->getAttributes()) + ->set('class', 'primary-submit-btn-duplicate'); + $attributes->remove('id'); + // Remove to avoid `type="submit submit"` in SubmitButtonElement + $attributes->remove('type'); + + if ($originalSubmitButton instanceof SubmitElement) { + $newSubmitButton = new SubmitElement($originalSubmitButton->getName(), $attributes); + $newSubmitButton->setLabel($originalSubmitButton->getButtonLabel()); + + return $newSubmitButton; + } elseif ($originalSubmitButton instanceof SubmitButtonElement) { + $newSubmitButton = new SubmitButtonElement($originalSubmitButton->getName(), $attributes); + $newSubmitButton->setSubmitValue($originalSubmitButton->getSubmitValue()); + + return $newSubmitButton; + } + + throw new InvalidArgumentException(sprintf( + 'Cannot duplicate submit button of type "%s"', + get_class($originalSubmitButton) + )); + } +} diff --git a/vendor/ipl/web/src/Compat/Multipart.php b/vendor/ipl/web/src/Compat/Multipart.php new file mode 100644 index 0000000..432f837 --- /dev/null +++ b/vendor/ipl/web/src/Compat/Multipart.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; + +class Multipart extends HtmlDocument +{ + /** @var string */ + protected $for; + + protected $contentSeparator = "\n"; + + /** + * Set the container's id which this part is for + * + * @param string $id + * + * @return $this + */ + public function setFor($id) + { + $this->for = $id; + + return $this; + } + + protected function assemble() + { + $this->prepend(HtmlString::create(sprintf('for=%s', $this->for))); + } +} diff --git a/vendor/ipl/web/src/Compat/SearchControls.php b/vendor/ipl/web/src/Compat/SearchControls.php new file mode 100644 index 0000000..f6e74ab --- /dev/null +++ b/vendor/ipl/web/src/Compat/SearchControls.php @@ -0,0 +1,260 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use ipl\Html\Html; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Query; +use ipl\Stdlib\Seq; +use ipl\Web\Control\SearchBar; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Stdlib\Filter; + +trait SearchControls +{ + /** + * Fetch available filter columns for the given query + * + * @param Query $query + * + * @return array<string, string> Keys are column paths, values are labels + */ + public function fetchFilterColumns(Query $query) + { + $columns = []; + foreach ($query->getResolver()->getColumnDefinitions($query->getModel()) as $name => $definition) { + $columns[$name] = $definition->getLabel(); + } + + return $columns; + } + + /** + * Get whether {@see SearchControls::createSearchBar()} and {@see SearchControls::createSearchEditor()} + * should handle form submits. + * + * @return bool + */ + private function callHandleRequest() + { + return true; + } + + /** + * Create and return the SearchBar + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchBar + */ + public function createSearchBar(Query $query, ...$params): SearchBar + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = $requestUrl->onlyWith($preserveParams); + } + + $filter = QueryString::fromString((string) $this->params) + ->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + $this->enrichFilterCondition($condition, $query); + }) + ->parse(); + + $searchBar = new SearchBar(); + $searchBar->setFilter($filter); + $searchBar->setRedirectUrl($redirectUrl); + $searchBar->setAction($redirectUrl->getAbsoluteUrl()); + $searchBar->setIdProtector([$this->getRequest(), 'protectId']); + $searchBar->addWrapper(Html::tag('div', ['class' => 'search-controls'])); + + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if (method_exists($this, 'completeAction')) { + $searchBar->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + if (method_exists($this, 'searchEditorAction')) { + $searchBar->setEditorUrl(Url::fromPath( + "$moduleName/$controllerName/search-editor" + )->setParams($redirectUrl->getParams())); + } + + $filterColumns = $this->fetchFilterColumns($query); + $columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $filterColumns) { + $searchPath = $column->getSearchValue(); + if (strpos($searchPath, '.') === false) { + $column->setSearchValue($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableAlias() + )); + } + + try { + $definition = $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + list($columnPath, $columnLabel) = Seq::find($filterColumns, $searchPath, false); + if ($columnPath === null) { + $column->setMessage(t('Is not a valid column')); + $column->setSearchValue($searchPath); // Resets the qualification made above + } else { + $column->setSearchValue($columnPath); + $column->setLabel($columnLabel); + } + } + + if (isset($definition)) { + $column->setLabel($definition->getLabel()); + } + }; + + $searchBar->on(SearchBar::ON_ADD, $columnValidator) + ->on(SearchBar::ON_INSERT, $columnValidator) + ->on(SearchBar::ON_SAVE, $columnValidator) + ->on(SearchBar::ON_SENT, function (SearchBar $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $redirectUrl->setFilter($form->getFilter()); + $form->setRedirectUrl($redirectUrl); + })->on(SearchBar::ON_SUCCESS, function (SearchBar $form) { + $this->getResponse()->redirectAndExit($form->getRedirectUrl()); + }); + + if ($this->callHandleRequest()) { + $searchBar->handleRequest(ServerRequest::fromGlobals()); + } + + return $searchBar; + } + + /** + * Create and return the SearchEditor + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchEditor + */ + public function createSearchEditor(Query $query, ...$params): SearchEditor + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = Url::fromPath("$moduleName/$controllerName"); + if (! empty($preserveParams)) { + $redirectUrl->setParams($requestUrl->onlyWith($preserveParams)->getParams()); + } + } + + $editor = new SearchEditor(); + $editor->setRedirectUrl($redirectUrl); + $editor->setAction($requestUrl->getAbsoluteUrl()); + $editor->setQueryString((string) $this->params->without($preserveParams)); + + if (method_exists($this, 'completeAction')) { + $editor->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + $editor->getParser()->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + if ($condition->getColumn()) { + $this->enrichFilterCondition($condition, $query); + } + }); + + $filterColumns = $this->fetchFilterColumns($query); + $editor->on(SearchEditor::ON_VALIDATE_COLUMN, function ( + Filter\Condition $condition + ) use ( + $query, + $filterColumns + ) { + $searchPath = $condition->getColumn(); + if (strpos($searchPath, '.') === false) { + $condition->setColumn($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableAlias() + )); + } + + try { + $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + $columnPath = Seq::findKey( + $filterColumns, + $condition->metaData()->get('columnLabel', $searchPath), + false + ); + if ($columnPath === null) { + $condition->setColumn($searchPath); + throw new SearchBar\SearchException(t('Is not a valid column')); + } else { + $condition->setColumn($columnPath); + } + } + })->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $redirectUrl->setFilter($form->getFilter()); + + $this->getResponse() + ->setHeader('X-Icinga-Container', '_self') + ->redirectAndExit($redirectUrl); + }); + + if ($this->callHandleRequest()) { + $editor->handleRequest(ServerRequest::fromGlobals()); + } + + return $editor; + } + + /** + * Enrich the filter condition with meta data from the query + * + * @param Filter\Condition $condition + * @param Query $query + * + * @return void + */ + protected function enrichFilterCondition(Filter\Condition $condition, Query $query) + { + $path = $condition->getColumn(); + if (strpos($path, '.') === false) { + $path = $query->getResolver()->qualifyPath($path, $query->getModel()->getTableAlias()); + $condition->setColumn($path); + } + + try { + $label = $query->getResolver()->getColumnDefinition($path)->getLabel(); + } catch (InvalidRelationException $_) { + $label = null; + } + + if (isset($label)) { + $condition->metaData()->set('columnLabel', $label); + } + } +} diff --git a/vendor/ipl/web/src/Compat/StyleWithNonce.php b/vendor/ipl/web/src/Compat/StyleWithNonce.php new file mode 100644 index 0000000..f4c7185 --- /dev/null +++ b/vendor/ipl/web/src/Compat/StyleWithNonce.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Web\Compat; + +use Icinga\Application\Version; +use Icinga\Util\Csp; +use ipl\Web\Style; + +/** + * Use this class to define inline style which is compatible + * with Icinga Web < 2.12 and with CSP support in >= 2.12 + */ +class StyleWithNonce extends Style +{ + public function getNonce(): ?string + { + if ($this->nonce === null) { + $this->nonce = version_compare(Version::VERSION, '2.12.0', '>=') + ? Csp::getStyleNonce() ?? '' + : ''; + } + + return parent::getNonce(); + } +} diff --git a/vendor/ipl/web/src/Compat/ViewRenderer.php b/vendor/ipl/web/src/Compat/ViewRenderer.php new file mode 100644 index 0000000..48ddcc3 --- /dev/null +++ b/vendor/ipl/web/src/Compat/ViewRenderer.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Web\Compat; + +use Zend_Controller_Action_Helper_ViewRenderer as Zf1ViewRenderer; +use Zend_Controller_Action_HelperBroker as Zf1HelperBroker; + +class ViewRenderer extends Zf1ViewRenderer +{ + /** + * Inject the view renderer + */ + public static function inject() + { + /** @var \Zend_Controller_Action_Helper_ViewRenderer $viewRenderer */ + $viewRenderer = Zf1HelperBroker::getStaticHelper('ViewRenderer'); + + $inject = new static(); + + foreach (get_object_vars($viewRenderer) as $property => $value) { + if ($property === '_inflector') { + continue; + } + + $inject->$property = $value; + } + + Zf1HelperBroker::removeHelper('ViewRenderer'); + Zf1HelperBroker::addHelper($inject); + } + + public function getName() + { + return 'ViewRenderer'; + } + + /** + * Render the view w/o using a view script + * + * {@inheritdoc} + */ + public function render($action = null, $name = null, $noController = null) + { + $view = $this->view; + + if ($view->document->isEmpty() || $this->getRequest()->getParam('error_handler') !== null) { + parent::render($action, $name, $noController); + + return; + } + + if ($name === null) { + $name = $this->getResponseSegment(); + } + + $this->getResponse()->appendBody($view->document->render(), $name); + + $this->setNoRender(); + } +} diff --git a/vendor/ipl/web/src/Control/LimitControl.php b/vendor/ipl/web/src/Control/LimitControl.php new file mode 100644 index 0000000..b390a0a --- /dev/null +++ b/vendor/ipl/web/src/Control/LimitControl.php @@ -0,0 +1,123 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; + +/** + * Allows to adjust the limit of the number of items to display + */ +class LimitControl extends CompatForm +{ + /** @var int Default limit */ + const DEFAULT_LIMIT = 25; + + /** @var string Default limit param */ + const DEFAULT_LIMIT_PARAM = 'limit'; + + /** @var int[] Selectable default limits */ + public static $limits = [ + '25' => '25', + '50' => '50', + '100' => '100', + '500' => '500' + ]; + + /** @var string Name of the URL parameter which stores the limit */ + protected $limitParam = self::DEFAULT_LIMIT_PARAM; + + /** @var int */ + protected $defaultLimit; + + /** @var Url */ + protected $url; + + protected $method = 'GET'; + + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Get the name of the URL parameter which stores the limit + * + * @return string + */ + public function getLimitParam() + { + return $this->limitParam; + } + + /** + * Set the name of the URL parameter which stores the limit + * + * @param string $limitParam + * + * @return $this + */ + public function setLimitParam($limitParam) + { + $this->limitParam = $limitParam; + + return $this; + } + + /** + * Get the default limit + * + * @return int + */ + public function getDefaultLimit() + { + return $this->defaultLimit ?: static::DEFAULT_LIMIT; + } + + /** + * Set the default limit + * + * @param int $limit + * + * @return $this + */ + public function setDefaultLimit($limit) + { + $this->defaultLimit = $limit; + + return $this; + } + + /** + * Get the limit + * + * @return int + */ + public function getLimit() + { + return $this->url->getParam($this->getLimitParam(), $this->getDefaultLimit()); + } + + protected function assemble() + { + $this->addAttributes(['class' => 'limit-control inline']); + + $limits = static::$limits; + if ($this->defaultLimit && ! isset($limits[$this->defaultLimit])) { + $limits[$this->defaultLimit] = $this->defaultLimit; + } + + $limit = $this->getLimit(); + if (! isset($limits[$limit])) { + $limits[$limit] = $limit; + } + + $this->addElement('select', $this->getLimitParam(), [ + 'class' => 'autosubmit', + 'label' => '#', + 'options' => $limits, + 'title' => t('Change item count per page'), + 'value' => $limit + ]); + } +} diff --git a/vendor/ipl/web/src/Control/PaginationControl.php b/vendor/ipl/web/src/Control/PaginationControl.php new file mode 100644 index 0000000..00f5c20 --- /dev/null +++ b/vendor/ipl/web/src/Control/PaginationControl.php @@ -0,0 +1,523 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * The pagination control displays a list of links that point to different pages of the current view + * + * The default HTML markup (tag and attributes) for the paginator look like the following: + * <div class="pagination-control" role="navigation">...</div> + */ +class PaginationControl extends BaseHtmlElement +{ + /** @var int Default maximum number of items which should be shown per page */ + protected $defaultPageSize; + + /** @var string Name of the URL parameter which stores the current page number */ + protected $pageParam = 'page'; + + /** @var string Name of the URL parameter which holds the page size. If given, overrides {@link $defaultPageSize} */ + protected $pageSizeParam = 'limit'; + + /** @var string */ + protected $pageSpacer = '…'; + + /** @var Paginatable The pagination adapter which handles the underlying data source */ + protected $paginatable; + + /** @var Url The URL to base off pagination URLs */ + protected $url; + + /** @var int Cache for the total number of items */ + private $totalCount; + + protected $tag = 'div'; + + protected $defaultAttributes = [ + 'class' => 'pagination-control', + 'role' => 'navigation' + ]; + + /** + * Create a pagination control + * + * @param Paginatable $paginatable The paginatable + * @param Url $url The URL to base off paging URLs + */ + public function __construct(Paginatable $paginatable, Url $url) + { + $this->paginatable = $paginatable; + $this->url = $url; + } + + /** + * Set the URL to base off paging URLs + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url) + { + $this->url = $url; + + return $this; + } + + /** + * Get the default page size + * + * @return int + */ + public function getDefaultPageSize() + { + return $this->defaultPageSize ?: LimitControl::DEFAULT_LIMIT; + } + + /** + * Set the default page size + * + * @param int $defaultPageSize + * + * @return $this + */ + public function setDefaultPageSize($defaultPageSize) + { + $this->defaultPageSize = $defaultPageSize; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the current page number + * + * @return string + */ + public function getPageParam() + { + return $this->pageParam; + } + + /** + * Set the name of the URL parameter which stores the current page number + * + * @param string $pageParam + * + * @return $this + */ + public function setPageParam($pageParam) + { + $this->pageParam = $pageParam; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the page size + * + * @return string + */ + public function getPageSizeParam() + { + return $this->pageSizeParam; + } + /** + * Set the name of the URL parameter which stores the page size + * + * @param string $pageSizeParam + * + * @return $this + */ + public function setPageSizeParam($pageSizeParam) + { + $this->pageSizeParam = $pageSizeParam; + + return $this; + } + + /** + * Get the total number of items + * + * @return int + */ + public function getTotalCount() + { + if ($this->totalCount === null) { + $this->totalCount = $this->paginatable->count(); + } + + return $this->totalCount; + } + + /** + * Get the current page number + * + * @return int + */ + public function getCurrentPageNumber() + { + return (int) $this->url->getParam($this->pageParam, 1); + } + + /** + * Get the configured page size + * + * @return int + */ + public function getPageSize() + { + return (int) $this->url->getParam($this->pageSizeParam, $this->getDefaultPageSize()); + } + + /** + * Get the total page count + * + * @return int + */ + public function getPageCount() + { + $pageSize = $this->getPageSize(); + + if ($pageSize === 0) { + return 0; + } + + if ($pageSize < 0) { + return 1; + } + + return (int) ceil($this->getTotalCount() / $pageSize); + } + + /** + * Get the limit + * + * Use this method to set the LIMIT part of a query for fetching the current page. + * + * @return int If the page size is infinite, -1 will be returned + */ + public function getLimit() + { + $pageSize = $this->getPageSize(); + + return $pageSize < 0 ? -1 : $pageSize; + } + + /** + * Get the offset + * + * Use this method to set the OFFSET part of a query for fetching the current page. + * + * @return int + */ + public function getOffset() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $pageSize = $this->getPageSize(); + + return $currentPageNumber <= 1 ? 0 : ($currentPageNumber - 1) * $pageSize; + } + + /** + * Apply limit and offset on the paginatable + * + * @return $this + */ + public function apply() + { + $this->paginatable->limit($this->getLimit()); + $this->paginatable->offset($this->getOffset()); + + return $this; + } + + /** + * Create a URL for paging from the given page number + * + * @param int $pageNumber The page number + * @param int $pageSize The number of items per page. If you want to stick to the defaults, + * don't set this parameter + * + * @return Url + */ + public function createUrl($pageNumber, $pageSize = null) + { + $params = [$this->getPageParam() => $pageNumber]; + + if ($pageSize !== null) { + $params[$this->getPageSizeParam()] = $pageSize; + } + + return $this->url->with($params); + } + + /** + * Get the first item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getFirstItemNumberOfPage($pageNumber) + { + return ($pageNumber - 1) * $this->getPageSize() + 1; + } + + /** + * Get the last item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getLastItemNumberOfPage($pageNumber) + { + return min($pageNumber * $this->getPageSize(), $this->getTotalCount()); + } + + /** + * Create the label for the given page number + * + * @param int $pageNumber + * + * @return string + */ + protected function createLabel($pageNumber) + { + return sprintf( + $this->translate('Show items %u to %u of %u'), + $this->getFirstItemNumberOfPage($pageNumber), + $this->getLastItemNumberOfPage($pageNumber), + $this->getTotalCount() + ); + } + + /** + * Create and return the previous page item + * + * @return BaseHtmlElement + */ + protected function createPreviousPageItem() + { + $prevIcon = new Icon('angle-left'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber > 1) { + $prevItem = Html::tag('li', ['class' => 'nav-item']); + + $prevItem->add(Html::tag( + 'a', + [ + 'class' => 'previous-page', + 'href' => $this->createUrl($currentPageNumber - 1), + 'title' => $this->createLabel($currentPageNumber - 1) + ], + $prevIcon + )); + } else { + $prevItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $prevItem->add(Html::tag('span', ['class' => 'previous-page'], $prevIcon)); + } + + return $prevItem; + } + + /** + * Create and return the next page item + * + * @return BaseHtmlElement + */ + protected function createNextPageItem() + { + $nextIcon = new Icon('angle-right'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber < $this->getPageCount()) { + $nextItem = Html::tag('li', ['class' => 'nav-item']); + + $nextItem->add(Html::tag( + 'a', + [ + 'class' => 'next-page', + 'href' => $this->createUrl($currentPageNumber + 1), + 'title' => $this->createLabel($currentPageNumber + 1) + ], + $nextIcon + )); + } else { + $nextItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $nextItem->add(Html::tag('span', ['class' => 'next-page'], $nextIcon)); + } + + return $nextItem; + } + + /** @TODO(el): Use ipl-translation when it's ready instead */ + private function translate($message) + { + return $message; + } + + /** + * Create and return the first page item + * + * @return BaseHtmlElement + */ + protected function createFirstPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $url = clone $this->url; + + $firstItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === 1) { + $firstItem->addAttributes(['class' => 'disabled']); + $firstItem->add(Html::tag( + 'span', + ['class' => 'first-page'], + $this->getFirstItemNumberOfPage(1) + )); + } else { + $firstItem->add(Html::tag( + 'a', + [ + 'class' => 'first-page', + 'href' => $url->remove(['page'])->getAbsoluteUrl(), + 'title' => $this->createLabel(1) + ], + $this->getFirstItemNumberOfPage(1) + )); + } + + return $firstItem; + } + + /** + * Create and return the last page item + * + * @return BaseHtmlElement + */ + protected function createLastPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $lastItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === $this->getPageCount()) { + $lastItem->addAttributes(['class' => 'disabled']); + $lastItem->add(Html::tag( + 'span', + ['class' => 'last-page'], + $this->getPageCount() + )); + } else { + $lastItem->add(Html::tag( + 'a', + [ + 'class' => 'last-page', + 'href' => $this->url->setParam('page', $this->getPageCount()), + 'title' => $this->createLabel($this->getPageCount()) + ], + $this->getPageCount() + )); + } + + return $lastItem; + } + + /** + * Create and return the page selector item + * + * @return BaseHtmlElement + */ + protected function createPageSelectorItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $form = new CompatForm($this->url); + $form->addAttributes(['class' => 'inline']); + $form->setMethod('GET'); + + $select = Html::tag('select', [ + 'name' => $this->getPageParam(), + 'class' => 'autosubmit', + 'title' => t('Go to page …') + ]); + + if (isset($currentPageNumber)) { + if ($currentPageNumber === 1 || $currentPageNumber === $this->getPageCount()) { + $select->add(Html::tag('option', ['disabled' => '', 'selected' => ''], '…')); + } + } + + foreach (range(2, $this->getPageCount() - 1) as $page) { + $option = Html::tag('option', [ + 'value' => $page + ], $page); + + if ($page == $currentPageNumber) { + $option->addAttributes(['selected' => '']); + } + + $select->add($option); + } + + $form->add($select); + + $pageSelectorItem = Html::tag('li', $form); + + return $pageSelectorItem; + } + + protected function assemble() + { + if ($this->getPageCount() < 2) { + return; + } + + // Accessibility info + $this->add(Html::tag( + 'h2', + [ + 'class' => 'sr-only', + 'tabindex' => '-1' + ], + $this->translate('Pagination') + )); + + $paginator = Html::tag('ul', ['class' => 'tab-nav nav']); + + $paginator->add([ + $this->createFirstPageItem(), + $this->createPreviousPageItem(), + $this->createPageSelectorItem(), + $this->createNextPageItem(), + $this->createLastPageItem() + ]); + + $this->add($paginator); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar.php b/vendor/ipl/web/src/Control/SearchBar.php new file mode 100644 index 0000000..ab935ef --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar.php @@ -0,0 +1,541 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\FormElement\SubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Validator\CallbackValidator; +use ipl\Web\Common\FormUid; +use ipl\Web\Control\SearchBar\Terms; +use ipl\Web\Control\SearchBar\ValidatedColumn; +use ipl\Web\Control\SearchBar\ValidatedOperator; +use ipl\Web\Control\SearchBar\ValidatedValue; +use ipl\Web\Filter\ParseException; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchBar extends Form +{ + use FormUid; + + /** @var string Emitted in case the user added a new condition */ + const ON_ADD = 'on_add'; + + /** @var string Emitted in case the user inserted a new condition */ + const ON_INSERT = 'on_insert'; + + /** @var string Emitted in case the user changed an existing condition */ + const ON_SAVE = 'on_save'; + + /** @var string Emitted in case the user removed a condition */ + const ON_REMOVE = 'on_remove'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-bar', + 'class' => 'search-bar', + 'name' => 'search-bar', + 'role' => 'search' + ]; + + /** @var Url */ + protected $editorUrl; + + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $searchParameter; + + /** @var Url */ + protected $suggestionUrl; + + /** @var string */ + protected $submitLabel; + + /** @var callable */ + protected $protector; + + /** @var array */ + protected $changes; + + /** + * Set the url from which to load the editor + * + * @param Url $url + * + * @return $this + */ + public function setEditorUrl(Url $url) + { + $this->editorUrl = $url; + + return $this; + } + + /** + * Get the url from which to load the editor + * + * @return Url + */ + public function getEditorUrl() + { + return $this->editorUrl; + } + + /** + * Set the filter to use + * + * @param Filter\Rule $filter + * @return $this + */ + public function setFilter(Filter\Rule $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Get the filter in use + * + * @return Filter\Rule + */ + public function getFilter() + { + return $this->filter; + } + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Get the search parameter in use + * + * @return string + */ + public function getSearchParameter() + { + return $this->searchParameter ?: 'q'; + } + + /** + * Set the suggestion url + * + * @param Url $url + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return Url + */ + public function getSuggestionUrl() + { + return $this->suggestionUrl; + } + + /** + * Set the submit label + * + * @param string $label + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Get the submit label + * + * @return string + */ + public function getSubmitLabel() + { + return $this->submitLabel; + } + + /** + * Set callback to protect ids with + * + * @param callable $protector + * + * @return $this + */ + public function setIdProtector($protector) + { + $this->protector = $protector; + + return $this; + } + + /** + * Get changes to be applied on the client + * + * @return array + */ + public function getChanges() + { + return $this->changes; + } + + private function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } + + public function populate($values) + { + if (array_key_exists($this->getSearchParameter(), (array) $values)) { + // If a filter is set, it must be reset in case new data arrives. The new data controls the filter, + // though if no data is sent, (populate() is only called if the form is sent) then the filter must + // be reset explicitly here to not keep the outdated filter. + $this->filter = Filter::all(); + } + + parent::populate($values); + } + + public function isValidEvent($event) + { + switch ($event) { + case self::ON_ADD: + case self::ON_SAVE: + case self::ON_INSERT: + case self::ON_REMOVE: + return true; + default: + return parent::isValidEvent($event); + } + } + + private function validateCondition($eventType, $indices, $termsData, &$changes) + { + // TODO: In case of the query string validation, all three are guaranteed to be set. + // The Parser also provides defaults, why shouldn't we here? + $column = ValidatedColumn::fromTermData($termsData[0]); + $operator = isset($termsData[1]) + ? ValidatedOperator::fromTermData($termsData[1]) + : null; + $value = isset($termsData[2]) + ? ValidatedValue::fromTermData($termsData[2]) + : null; + + $this->emit($eventType, [$column, $operator, $value]); + + if ($eventType !== self::ON_REMOVE) { + if (! $column->isValid() || $column->hasBeenChanged()) { + $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData()); + } + + if ($operator && ! $operator->isValid()) { + $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData()); + } + + if ($value && (! $value->isValid() || $value->hasBeenChanged())) { + $changes[$indices[2]] = array_merge($termsData[2], $value->toTermData()); + } + } + + return $column->isValid() && (! $operator || $operator->isValid()) && (! $value || $value->isValid()); + } + + + protected function assemble() + { + $termContainerId = $this->protectId('terms'); + $termInputId = $this->protectId('term-input'); + $dataInputId = $this->protectId('data-input'); + $searchInputId = $this->protectId('search-input'); + $suggestionsId = $this->protectId('suggestions'); + + $termContainer = (new Terms())->setAttribute('id', $termContainerId); + $termInput = new HiddenElement($this->getSearchParameter(), [ + 'id' => $termInputId, + 'disabled' => true + ]); + + if (! $this->getRequest()->getHeaderLine('X-Icinga-Autorefresh')) { + $termContainer->setFilter(function () { + return $this->getFilter(); + }); + $termInput->getAttributes()->registerAttributeCallback('value', function () { + return QueryString::render($this->getFilter()); + }); + } + + $dataInput = new HiddenElement('data', [ + 'id' => $dataInputId, + 'validators' => [ + new CallbackValidator(function ($data, CallbackValidator $_) use ($termContainer, $searchInputId) { + $data = $data ? json_decode($data, true) : null; + if (empty($data)) { + return true; + } + + switch ($data['type']) { + case 'add': + case 'exchange': + $type = self::ON_ADD; + + break; + case 'insert': + $type = self::ON_INSERT; + + break; + case 'save': + $type = self::ON_SAVE; + + break; + case 'remove': + $type = self::ON_REMOVE; + + break; + default: + return true; + } + + $changes = []; + $invalid = false; + $indices = [null, null, null]; + $termsData = [null, null, null]; + foreach (isset($data['terms']) ? $data['terms'] : [] as $termIndex => $termData) { + switch ($termData['type']) { + case 'column': + $indices[0] = $termIndex; + $termsData[0] = $termData; + + break; + case 'operator': + $indices[1] = $termIndex; + $termsData[1] = $termData; + + break; + case 'value': + $indices[2] = $termIndex; + $termsData[2] = $termData; + + break; + default: + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + $indices = $termsData = [null, null, null]; + } + } + + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + $termContainer->applyChanges($changes); + } + + return ! $invalid; + }) + ] + ]); + $this->registerElement($dataInput); + + $filterInput = new InputElement($this->getSearchParameter(), [ + 'type' => 'text', + 'placeholder' => 'Type to search. Use * as wildcard.', + 'class' => 'filter-input', + 'id' => $searchInputId, + 'autocomplete' => 'off', + 'data-enrichment-type' => 'filter', + 'data-data-input' => '#' . $dataInputId, + 'data-term-input' => '#' . $termInputId, + 'data-term-container' => '#' . $termContainerId, + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-missing-log-op' => t('Please add a logical operator on the left.'), + 'data-incomplete-group' => t('Please close or remove this group.'), + 'data-choose-template' => t('Please type one of: %s', '..<comma separated list>'), + 'data-choose-column' => t('Please enter a valid column.'), + 'validators' => [ + new CallbackValidator(function ($q, CallbackValidator $validator) use ($searchInputId) { + $submitted = $this->hasBeenSubmitted(); + $invalid = false; + $changes = []; + + $parser = QueryString::fromString($q); + $parser->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ( + &$invalid, + &$changes, + $submitted + ) { + $columnIndex = $condition->metaData()->get('columnIndex'); + if (isset($this->changes[1][$columnIndex])) { + $change = $this->changes[1][$columnIndex]; + $condition->setColumn($change['search']); + } elseif (empty($this->changes)) { + $column = ValidatedColumn::fromFilterCondition($condition); + $operator = ValidatedOperator::fromFilterCondition($condition); + $value = ValidatedValue::fromFilterCondition($condition); + $this->emit(self::ON_ADD, [$column, $operator, $value]); + + $condition->setColumn($column->getSearchValue()); + $condition->setValue($value->getSearchValue()); + + if (! $column->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($column->toMetaData()); + } else { + $changes[$columnIndex] = $column->toTermData(); + } + } + + if (! $operator->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($operator->toMetaData()); + } else { + $changes[$condition->metaData()->get('operatorIndex')] = $operator->toTermData(); + } + } + + if (! $value->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($value->toMetaData()); + } else { + $changes[$condition->metaData()->get('valueIndex')] = $value->toTermData(); + } + } + } + }); + + try { + $filter = $parser->parse(); + } catch (ParseException $e) { + $charAt = $e->getCharPos() - 1; + $char = $e->getChar(); + + $this->getElement($this->getSearchParameter()) + ->addAttributes([ + 'title' => sprintf(t('Unexpected %s at start of input'), $char), + 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char), + 'data-has-syntax-error' => true + ]) + ->getAttributes() + ->registerAttributeCallback('value', function () use ($q, $charAt) { + return substr($q, $charAt); + }); + + $probablyValidQueryString = substr($q, 0, $charAt); + $this->setFilter(QueryString::parse($probablyValidQueryString)); + return false; + } + + $this->getElement($this->getSearchParameter()) + ->getAttributes() + ->registerAttributeCallback('value', function () { + return ''; + }); + $this->setFilter($filter); + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + } + + return ! $invalid; + }) + ] + ]); + if ($this->getSuggestionUrl() !== null) { + $filterInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + } + + $this->registerElement($filterInput); + + $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel() ?: 'hidden']); + $this->registerElement($submitButton); + + $editorOpener = null; + if ($this->getEditorUrl() !== null) { + $editorOpener = new HtmlElement( + 'button', + Attributes::create([ + 'type' => 'button', + 'class' => 'search-editor-opener control-button', + 'title' => t('Adjust Filter') + ])->registerAttributeCallback('data-search-editor-url', function () { + return (string) $this->getEditorUrl(); + }), + new Icon('cog') + ); + } + + $this->addHtml( + new HtmlElement( + 'button', + Attributes::create(['type' => 'button', 'class' => 'search-options']), + new Icon('search') + ), + new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-input-area']), + $termContainer, + new HtmlElement('label', Attributes::create(['data-label' => '']), $filterInput) + ), + $dataInput, + $termInput, + $submitButton, + $this->createUidElement(), + new HtmlElement('div', Attributes::create([ + 'id' => $suggestionsId, + 'class' => 'search-suggestions', + 'data-base-target' => $suggestionsId + ])) + ); + + // Render the editor container outside of this form. It will contain a form as well later on + // loaded by XHR and HTML prohibits nested forms. It's style-wise also better... + $doc = new HtmlDocument(); + $this->prependWrapper($doc); + $doc->addHtml($this, ...($editorOpener ? [$editorOpener] : [])); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/SearchException.php b/vendor/ipl/web/src/Control/SearchBar/SearchException.php new file mode 100644 index 0000000..a89c6ce --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/SearchException.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Exception; + +class SearchException extends Exception +{ +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Suggestions.php b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php new file mode 100644 index 0000000..fe4a2db --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php @@ -0,0 +1,451 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Countable; +use Generator; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\FormElement\ButtonElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use IteratorIterator; +use LimitIterator; +use OuterIterator; +use Psr\Http\Message\ServerRequestInterface; +use Traversable; + +use function ipl\I18n\t; + +abstract class Suggestions extends BaseHtmlElement +{ + const DEFAULT_LIMIT = 50; + const SUGGESTION_TITLE_CLASS = 'suggestion-title'; + + protected $tag = 'ul'; + + /** @var string */ + protected $searchTerm; + + /** @var Traversable */ + protected $data; + + /** @var array */ + protected $default; + + /** @var string */ + protected $type; + + /** @var string */ + protected $failureMessage; + + public function setSearchTerm($term) + { + $this->searchTerm = $term; + + return $this; + } + + public function setData($data) + { + $this->data = $data; + + return $this; + } + + public function setDefault($default) + { + $this->default = $default; + + return $this; + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setFailureMessage($message) + { + $this->failureMessage = $message; + + return $this; + } + + /** + * Return whether the relation should be shown for the given column + * + * @param string $column + * + * @return bool + */ + protected function shouldShowRelationFor(string $column): bool + { + return false; + } + + /** + * Create a filter to provide as default for column suggestions + * + * @param string $searchTerm + * + * @return Filter\Rule + */ + abstract protected function createQuickSearchFilter($searchTerm); + + /** + * Fetch value suggestions for a particular column + * + * @param string $column + * @param string $searchTerm + * @param Filter\Chain $searchFilter + * + * @return Traversable + */ + abstract protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter); + + /** + * Fetch column suggestions + * + * @param string $searchTerm + * + * @return Traversable + */ + abstract protected function fetchColumnSuggestions($searchTerm); + + protected function filterToTerms(Filter\Chain $filter) + { + $logicalSep = [ + 'label' => QueryString::getRuleSymbol($filter), + 'search' => QueryString::getRuleSymbol($filter), + 'class' => 'logical_operator', + 'type' => 'logical_operator' + ]; + + $terms = []; + foreach ($filter as $child) { + if ($child instanceof Filter\Chain) { + $terms[] = [ + 'search' => '(', + 'label' => '(', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_open' + ]; + $terms = array_merge($terms, $this->filterToTerms($child)); + $terms[] = [ + 'search' => ')', + 'label' => ')', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_close' + ]; + } else { + /** @var Filter\Condition $child */ + + $terms[] = [ + 'search' => $child->getColumn(), + 'label' => $child->metaData()->get('columnLabel') ?? $child->getColumn(), + 'type' => 'column' + ]; + $terms[] = [ + 'search' => QueryString::getRuleSymbol($child), + 'label' => QueryString::getRuleSymbol($child), + 'type' => 'operator' + ]; + $terms[] = [ + 'search' => $child->getValue(), + 'label' => $child->getValue(), + 'type' => 'value' + ]; + } + + $terms[] = $logicalSep; + } + + array_pop($terms); + return $terms; + } + + protected function assembleDefault() + { + if ($this->default === null) { + return; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-label' => $this->default['search'], + 'value' => $this->default['search'] + ]; + if (isset($this->default['type'])) { + $attributes['data-type'] = $this->default['type']; + } elseif ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + $button = new ButtonElement(null, $attributes); + if (isset($this->default['type']) && $this->default['type'] === 'terms') { + $terms = $this->filterToTerms($this->default['terms']); + $list = new HtmlElement('ul', Attributes::create(['class' => 'comma-separated'])); + foreach ($terms as $data) { + if ($data['type'] === 'column') { + $list->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement('em', null, Text::create($data['label'])) + )); + } + } + + $button->setAttribute('data-terms', json_encode($terms)); + $button->addHtml(FormattedString::create( + t('Search for %s in: %s'), + new HtmlElement('em', null, Text::create($this->default['search'])), + $list + )); + } else { + $button->addHtml(FormattedString::create( + t('Search for %s'), + new HtmlElement('em', null, Text::create($this->default['search'])) + )); + } + + $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button)); + } + + protected function assemble() + { + if ($this->failureMessage !== null) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'failure-message']), + new HtmlElement('em', null, Text::create(t('Can\'t search:'))), + Text::create($this->failureMessage) + )); + return; + } + + if ($this->data === null) { + $data = []; + } elseif ($this->data instanceof Paginatable) { + $this->data->limit(self::DEFAULT_LIMIT); + $data = $this->data; + } else { + $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT); + } + + foreach ($data as $term => $meta) { + if (is_int($term)) { + $term = $meta; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-search' => $term, + 'data-title' => $term + ]; + if ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + if (is_array($meta)) { + foreach ($meta as $key => $value) { + if ($key === 'label') { + $label = $value; + } + + $attributes['data-' . $key] = $value; + } + } else { + $label = $meta; + $attributes['data-label'] = $meta; + } + + $button = (new ButtonElement(null, $attributes)) + ->setAttribute('value', $label) + ->addHtml(Text::create($label)); + if ($this->type === 'column' && $this->shouldShowRelationFor($term)) { + $relationPath = substr($term, 0, strrpos($term, '.')); + $button->getAttributes()->add('class', 'has-details'); + $button->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => 'relation-path']), + Text::create($relationPath) + )); + } + + $this->addHtml(new HtmlElement('li', null, $button)); + } + + if ($this->hasMore($data, self::DEFAULT_LIMIT)) { + $this->getAttributes()->add('class', 'has-more'); + } + + $showDefault = true; + if ($this->searchTerm && $this->count() === 1) { + // The default option is only shown if the user's input does not result in an exact match + $input = $this->getFirst('li')->getFirst('button'); + $showDefault = $input->getContent() != $this->searchTerm + && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm; + } + + if ($this->type === 'column' && ! $this->isEmpty() && ! $this->getFirst('li')->getAttributes()->has('class')) { + // The column title is only added if there are any suggestions and the first item is not a title already + $this->prependHtml(new HtmlElement( + 'li', + Attributes::create(['class' => static::SUGGESTION_TITLE_CLASS]), + Text::create(t('Columns')) + )); + } + + if ($showDefault) { + $this->assembleDefault(); + } + + if (! $this->searchTerm && $this->isEmpty()) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create(t('Nothing to suggest'))) + )); + } + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request) + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $search = $requestData['term']['search']; + $label = $requestData['term']['label']; + $type = $requestData['term']['type']; + + $this->setSearchTerm($search); + $this->setType($type); + + switch ($type) { + case 'value': + if (! $requestData['column'] || $requestData['column'] === SearchEditor::FAKE_COLUMN) { + $this->setFailureMessage(t('Missing column name')); + break; + } + + $searchFilter = QueryString::parse( + isset($requestData['searchFilter']) + ? $requestData['searchFilter'] + : '' + ); + if ($searchFilter instanceof Filter\Condition) { + $searchFilter = Filter::all($searchFilter); + } + + try { + $this->setData($this->fetchValueSuggestions($requestData['column'], $label, $searchFilter)); + } catch (SearchException $e) { + $this->setFailureMessage($e->getMessage()); + } + + if ($search) { + $this->setDefault([ + 'search' => $requestData['operator'] === '~' || $requestData['operator'] === '!~' + ? $label + : $search + ]); + } + + break; + case 'column': + $this->setData($this->filterColumnSuggestions($this->fetchColumnSuggestions($label), $label)); + + if ($search && isset($requestData['showQuickSearch']) && $requestData['showQuickSearch']) { + $quickFilter = $this->createQuickSearchFilter($label); + if (! $quickFilter instanceof Filter\Chain || ! $quickFilter->isEmpty()) { + $this->setDefault([ + 'search' => $label, + 'type' => 'terms', + 'terms' => $quickFilter + ]); + } + } + } + + return $this; + } + + protected function hasMore($data, $than) + { + if (is_array($data)) { + return count($data) > $than; + } elseif ($data instanceof Countable) { + return $data->count() > $than; + } elseif ($data instanceof OuterIterator) { + return $this->hasMore($data->getInnerIterator(), $than); + } + + return false; + } + + /** + * Filter the given suggestions by the client's input + * + * @param Traversable $data + * @param string $searchTerm + * + * @return Generator + */ + protected function filterColumnSuggestions($data, $searchTerm) + { + foreach ($data as $key => $value) { + if ($this->matchSuggestion($key, $value, $searchTerm)) { + yield $key => $value; + } + } + } + + /** + * Get whether the given suggestion should be provided to the client + * + * @param string $path + * @param string $label + * @param string $searchTerm + * + * @return bool + */ + protected function matchSuggestion($path, $label, $searchTerm) + { + return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD); + } + + public function renderUnwrapped() + { + $this->ensureAssembled(); + + if ($this->isEmpty()) { + return ''; + } + + return parent::renderUnwrapped(); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Terms.php b/vendor/ipl/web/src/Control/SearchBar/Terms.php new file mode 100644 index 0000000..c81e336 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Terms.php @@ -0,0 +1,255 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\Icon; + +class Terms extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'terms']; + + /** @var callable|Filter\Rule */ + protected $filter; + + /** @var array */ + protected $changes; + + /** @var int */ + private $changeIndexCorrection = 0; + + /** @var int */ + private $currentIndex = 0; + + public function setFilter($filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Apply term changes + * + * @param array $changes + * + * @return $this + */ + public function applyChanges(array $changes) + { + $this->changes = $changes; + + return $this; + } + + protected function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); + } + + if ($filter === null) { + return; + } + + if ($filter instanceof Filter\Chain) { + if ($filter->isEmpty()) { + return; + } + + if ($filter instanceof Filter\None) { + $this->assembleChain($filter, $this, $filter->count() > 1); + } else { + $this->assembleConditions($filter, $this); + } + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $this); + } + } + + protected function assembleConditions(Filter\Chain $filters, BaseHtmlElement $where) + { + foreach ($filters as $i => $filter) { + if ($i > 0) { + $logicalOperator = QueryString::getRuleSymbol($filters); + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'logical_operator', + 'search' => $logicalOperator, + 'label' => $logicalOperator + ], $where); + } + + if ($filter instanceof Filter\Chain) { + $this->assembleChain($filter, $where, $filter->count() > 1); + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $where); + } + } + } + + protected function assembleChain(Filter\Chain $chain, BaseHtmlElement $where, $wrap = false) + { + if ($wrap) { + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-chain', 'data-group-type' => 'chain']) + ); + } else { + $group = $where; + } + + if ($chain instanceof Filter\None) { + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'negation_operator', + 'search' => '!', + 'label' => '!' + ], $where); + } + + if ($wrap) { + $opening = $this->assembleTerm([ + 'class' => 'grouping_operator_open', + 'type' => 'grouping_operator', + 'search' => '(', + 'label' => '(' + ], $group); + } + + $this->assembleConditions($chain, $group); + + if ($wrap) { + $closing = $this->assembleTerm([ + 'class' => 'grouping_operator_close', + 'type' => 'grouping_operator', + 'search' => ')', + 'label' => ')' + ], $group); + + $opening->addAttributes([ + 'data-counterpart' => $closing->getAttributes()->get('data-index')->getValue() + ]); + $closing->addAttributes([ + 'data-counterpart' => $opening->getAttributes()->get('data-index')->getValue() + ]); + + $where->addHtml($group); + } + } + + protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $where) + { + $column = $filter->getColumn(); + $operator = QueryString::getRuleSymbol($filter); + $value = $filter->getValue(); + $columnLabel = $filter->metaData()->get('columnLabel', $column); + + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-condition', 'data-group-type' => 'condition']), + new HtmlElement('button', Attributes::create(['type' => 'button']), new Icon('trash')) + ); + + $columnData = [ + 'class' => 'column', + 'type' => 'column', + 'search' => rawurlencode($column), + 'label' => $columnLabel, + 'title' => $column + ]; + if ($filter->metaData()->has('invalidColumnPattern')) { + $columnData['pattern'] = $filter->metaData()->get('invalidColumnPattern'); + if ($filter->metaData()->has('invalidColumnMessage')) { + $columnData['invalidMsg'] = $filter->metaData()->get('invalidColumnMessage'); + } + } + + $this->assembleTerm($columnData, $group); + + if ($value !== true) { + $operatorData = [ + 'class' => 'operator', + 'type' => 'operator', + 'search' => $operator, + 'label' => $operator + ]; + if ($filter->metaData()->has('invalidOperatorPattern')) { + $operatorData['pattern'] = $filter->metaData()->get('invalidOperatorPattern'); + if ($filter->metaData()->has('invalidOperatorMessage')) { + $operatorData['invalidMsg'] = $filter->metaData()->get('invalidOperatorMessage'); + } + } + + $this->assembleTerm($operatorData, $group); + + if (! empty($value) || ! is_string($value) || ctype_digit($value)) { + $valueData = [ + 'class' => 'value', + 'type' => 'value', + 'search' => rawurlencode($value), + 'label' => $value + ]; + if ($filter->metaData()->has('invalidValuePattern')) { + $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern'); + if ($filter->metaData()->has('invalidValueMessage')) { + $valueData['invalidMsg'] = $filter->metaData()->get('invalidValueMessage'); + } + } + + $this->assembleTerm($valueData, $group); + } + } + + $where->addHtml($group); + } + + protected function assembleTerm(array $data, BaseHtmlElement $where) + { + if (isset($this->changes[$this->currentIndex - $this->changeIndexCorrection])) { + $change = $this->changes[$this->currentIndex - $this->changeIndexCorrection]; + if ($change['type'] !== $data['type']) { + // This can happen because the user didn't insert parentheses but the parser did + $this->changeIndexCorrection++; + } else { + $data = array_merge($data, $change); + } + } + + $term = new HtmlElement('label', Attributes::create([ + 'class' => $data['class'], + 'data-index' => $this->currentIndex++, + 'data-type' => $data['type'], + 'data-search' => $data['search'], + 'data-label' => $data['label'] + ]), new HtmlElement('input', Attributes::create([ + 'type' => 'text', + 'value' => $data['label'] + ]))); + + if (isset($data['title'])) { + $term->setAttribute('title', $data['title']); + } + + if (isset($data['pattern'])) { + $term->getFirst('input')->setAttribute('pattern', $data['pattern']); + + if (isset($data['invalidMsg'])) { + $term->getFirst('input')->setAttribute('data-invalid-msg', $data['invalidMsg']); + } + } + + $where->addHtml($term); + + return $term; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php new file mode 100644 index 0000000..5825790 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php @@ -0,0 +1,44 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedColumn extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getColumn(), $condition->metaData()->get('columnLabel')); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'column'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + if (($label = $this->getLabel()) !== null) { + $data->set('columnLabel', $label); + } + + if (! $this->isValid()) { + $data->set('invalidColumnMessage', $this->getMessage()) + ->set('invalidColumnPattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php new file mode 100644 index 0000000..67fdbf0 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use InvalidArgumentException; +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter; +use LogicException; + +class ValidatedOperator extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Filter\Condition $condition + * + * @return static + * + * @throws InvalidArgumentException In case the condition type is unknown + */ + public static function fromFilterCondition(Filter\Condition $condition) + { + switch (true) { + case $condition instanceof Filter\Unlike: + case $condition instanceof Filter\Unequal: + $operator = '!='; + break; + case $condition instanceof Filter\Like: + case $condition instanceof Filter\Equal: + $operator = '='; + break; + case $condition instanceof Filter\GreaterThan: + $operator = '>'; + break; + case $condition instanceof Filter\LessThan: + $operator = '<'; + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $operator = '>='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $operator = '<='; + break; + default: + throw new InvalidArgumentException('Unknown condition type'); + } + + return new static($operator); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'operator'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidOperatorMessage', $this->getMessage()) + ->set('invalidOperatorPattern', $this->getPattern()); + } + + return $data; + } + + public function setSearchValue(string $searchValue): ValidatedTerm + { + throw new LogicException('Operators cannot be changed'); + } + + public function setLabel(?string $label): ValidatedTerm + { + throw new LogicException('Operators cannot be changed'); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php new file mode 100644 index 0000000..f616880 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php @@ -0,0 +1,196 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; + +abstract class ValidatedTerm +{ + /** @var string The default validation constraint */ + const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$'; + + /** @var string The search value */ + protected $searchValue; + + /** @var ?string The label */ + protected $label; + + /** @var ?string The validation message */ + protected $message; + + /** @var ?string The validation constraint */ + protected $pattern; + + /** @var bool Whether the term has been adjusted */ + protected $changed = false; + + /** + * Create a new ValidatedTerm + * + * @param string $searchValue The search value + * @param ?string $label The label + */ + public function __construct(string $searchValue, ?string $label = null) + { + $this->searchValue = $searchValue; + $this->label = $label; + } + + /** + * Create a new ValidatedTerm from the given data + * + * @param array $data + * + * @return static + */ + public static function fromTermData(array $data) + { + return new static($data['search'], isset($data['label']) ? $data['label'] : null); + } + + /** + * Check whether the term is valid + * + * @return bool + */ + public function isValid() + { + return $this->message === null; + } + + /** + * Check whether the term has been adjusted + * + * @return bool + */ + public function hasBeenChanged() + { + return $this->changed; + } + + /** + * Get the search value + * + * @return string + */ + public function getSearchValue(): string + { + return $this->searchValue; + } + + /** + * Set the search value + * + * @param string $searchValue + * + * @return $this + */ + public function setSearchValue(string $searchValue): self + { + $this->searchValue = $searchValue; + $this->changed = true; + + return $this; + } + + /** + * Get the label + * + * @return string + */ + public function getLabel(): ?string + { + return $this->label; + } + + /** + * Set the label + * + * @param ?string $label + * + * @return $this + */ + public function setLabel(?string $label): self + { + $this->label = $label; + $this->changed = true; + + return $this; + } + + /** + * Get the validation message + * + * @return ?string + */ + public function getMessage(): ?string + { + return $this->message; + } + + /** + * Set the validation message + * + * @param string $message + * + * @return $this + */ + public function setMessage(string $message): self + { + $this->message = $message; + + return $this; + } + + /** + * Get the validation constraint + * + * Returns the default constraint if none is set. + * + * @return string + */ + public function getPattern(): ?string + { + if ($this->message === null) { + return null; + } + + return $this->pattern ?? sprintf(self::DEFAULT_PATTERN, $this->getLabel() ?: $this->getSearchValue()); + } + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern(string $pattern): self + { + $this->pattern = $pattern; + + return $this; + } + + /** + * Get this term's data + * + * @return array + */ + public function toTermData() + { + return [ + 'search' => $this->getSearchValue(), + 'label' => $this->getLabel() ?: $this->getSearchValue(), + 'invalidMsg' => $this->getMessage(), + 'pattern' => $this->getPattern() + ]; + } + + /** + * Get this term's metadata + * + * @return Data + */ + abstract public function toMetaData(); +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php new file mode 100644 index 0000000..423102d --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedValue extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getValue()); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'value'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidValueMessage', $this->getMessage()) + ->set('invalidValuePattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchEditor.php b/vendor/ipl/web/src/Control/SearchEditor.php new file mode 100644 index 0000000..f975471 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchEditor.php @@ -0,0 +1,615 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormDecorator\CallbackDecorator; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Filter\Parser; +use ipl\Web\Filter\QueryString; +use ipl\Web\Filter\Renderer; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchEditor extends Form +{ + use Events; + + /** @var string Emitted for every validated column */ + const ON_VALIDATE_COLUMN = 'validate-column'; + + /** @var string The column name used for empty conditions */ + const FAKE_COLUMN = '_fake_'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-editor', + 'class' => 'search-editor' + ]; + + /** @var string */ + protected $queryString; + + /** @var Url */ + protected $suggestionUrl; + + /** @var Parser */ + protected $parser; + + /** @var Filter\Rule */ + protected $filter; + + /** @var bool */ + protected $cleared = false; + + /** + * Set the filter query string to populate the form with + * + * Use {@see SearchEditor::getParser()} to subscribe to parser events. + * + * @param string $query + * + * @return $this + */ + public function setQueryString($query) + { + $this->queryString = $query; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the query string parser being used + * + * @return Parser + */ + public function getParser() + { + if ($this->parser === null) { + $this->parser = new Parser(); + } + + return $this->parser; + } + + /** + * Get the current filter + * + * @return Filter\Rule + */ + public function getFilter() + { + if ($this->filter === null) { + $this->filter = $this->getParser() + ->setQueryString($this->queryString) + ->parse(); + } + + return $this->filter; + } + + public function populate($values) + { + // applyChanges() is basically this form's own populate implementation, hence + // why it changes $values and needs to run before actually populating the form + $filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString)) + ->setStrict() + ->parse(); + $filter = $this->applyChanges($filter, $values); + + parent::populate($values); + + $this->filter = $this->applyStructuralChange($filter); + if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) { + $this->queryString = (new Renderer($this->filter))->setStrict()->render(); + } else { + $this->queryString = ''; + } + + return $this; + } + + public function hasBeenSubmitted() + { + if (parent::hasBeenSubmitted()) { + return true; + } + + return $this->cleared; + } + + public function validate() + { + if ($this->cleared) { + $this->isValid = true; + } else { + parent::validate(); + } + + return $this; + } + + protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $newColumn = $this->popKey($values, $identifier . '-column-search'); + if ($newColumn === null) { + $newColumn = $this->popKey($values, $identifier . '-column'); + } else { + // Make sure we don't forget to present the column labels again + $rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column')); + } + + if ($newColumn !== null && $rule->getColumn() !== $newColumn) { + $rule->setColumn($newColumn ?: static::FAKE_COLUMN); + // TODO: Clear meta data? + } + + $newValue = $this->popKey($values, $identifier . '-value'); + $oldValue = $rule->getValue(); + if ($newValue !== null && $oldValue !== $newValue) { + $rule->setValue($newValue); + } + + $newOperator = $this->popKey($values, $identifier . '-operator'); + if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) { + $value = $rule->getValue(); + $column = $rule->getColumn(); + switch ($newOperator) { + case '~': + return Filter::like($column, $value); + case '!~': + return Filter::unlike($column, $value); + case '=': + return Filter::equal($column, $value); + case '!=': + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + $value = $rule->getValue(); + if ($oldValue !== $value && is_string($value) && strpos($value, '*') !== false) { + if (QueryString::getRuleSymbol($rule) === '=') { + return Filter::like($rule->getColumn(), $value); + } elseif (QueryString::getRuleSymbol($rule) === '!=') { + return Filter::unlike($rule->getColumn(), $value); + } + } + } else { + /** @var Filter\Chain $rule */ + $newGroupOperator = $this->popKey($values, $identifier); + $oldGroupOperator = $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule); + if ($newGroupOperator !== null && $oldGroupOperator !== $newGroupOperator) { + switch ($newGroupOperator) { + case '&': + $rule = Filter::all(...$rule); + break; + case '|': + $rule = Filter::any(...$rule); + break; + case '!': + $rule = Filter::none(...$rule); + break; + } + } + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $newChild = $this->applyChanges($child, $values, $childPath); + if ($child !== $newChild) { + $rule->replace($child, $newChild); + } + } + } + + return $rule; + } + + protected function applyStructuralChange(Filter\Rule $rule) + { + $structuralChange = $this->getPopulatedValue('structural-change'); + if (empty($structuralChange)) { + return $rule; + } elseif (is_array($structuralChange)) { + ksort($structuralChange); + } + + list($type, $where) = explode(':', is_array($structuralChange) + ? array_shift($structuralChange) + : $structuralChange); + $targetPath = explode('-', substr($where, 5)); + + $targetFinder = function ($path) use ($rule) { + $parent = null; + $target = null; + $children = [$rule]; + foreach ($path as $targetPos) { + if ($target !== null) { + $parent = $target; + $children = $parent instanceof Filter\Chain + ? iterator_to_array($parent) + : []; + } + + if (! isset($children[$targetPos])) { + return [null, null]; + } + + $target = $children[$targetPos]; + } + + return [$parent, $target]; + }; + + list($parent, $target) = $targetFinder($targetPath); + if ($target === null) { + return $rule; + } + + $emptyEqual = Filter::equal(static::FAKE_COLUMN, ''); + switch ($type) { + case 'move-rule': + if (! is_array($structuralChange) || empty($structuralChange)) { + return $rule; + } + + list($placement, $moveToPath) = explode(':', array_shift($structuralChange)); + list($moveToParent, $moveToTarget) = $targetFinder(explode('-', substr($moveToPath, 5))); + + $parent->remove($target); + if ($placement === 'to') { + $moveToTarget->add($target); + } elseif ($placement === 'before') { + $moveToParent->insertBefore($target, $moveToTarget); + } else { + $moveToParent->insertAfter($target, $moveToTarget); + } + + break; + case 'add-condition': + $target->add($emptyEqual); + + break; + case 'add-group': + $target->add(Filter::all($emptyEqual)); + + break; + case 'wrap-rule': + if ($parent !== null) { + $parent->replace($target, Filter::all($target)); + } else { + $rule = Filter::all($target); + } + + break; + case 'drop-rule': + if ($parent !== null) { + $parent->remove($target); + } else { + $rule = $emptyEqual; + } + + break; + case 'clear': + $this->cleared = true; + $rule = null; + } + + return $rule; + } + + protected function createTree(Filter\Rule $rule, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $parts = [$this->createCondition($rule, $identifier), $this->createButtons($rule, $identifier)]; + + if (count($path) === 1) { + $item = new HtmlElement('ol', null, new HtmlElement( + 'li', + Attributes::create(['id' => $identifier]), + ...$parts + )); + } else { + array_splice($parts, 1, 0, [ + new Icon('bars', ['class' => 'drag-initiator']) + ]); + + $item = (new HtmlDocument())->addHtml(...$parts); + } + } else { + /** @var Filter\Chain $rule */ + $item = new HtmlElement('ul'); + + $groupOperatorInput = $this->createElement('select', $identifier, [ + 'options' => [ + '&' => 'ALL', + '|' => 'ANY', + '!' => 'NONE' + ], + 'value' => $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule) + ]); + $this->registerElement($groupOperatorInput); + $item->addHtml(HtmlElement::create('li', ['id' => $identifier], [ + $groupOperatorInput, + count($path) > 1 + ? new Icon('bars', ['class' => 'drag-initiator']) + : null, + $this->createButtons($rule, $identifier) + ])); + + $children = new HtmlElement('ol'); + $item->addHtml(new HtmlElement('li', null, $children)); + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $children->addHtml(new HtmlElement( + 'li', + Attributes::create([ + 'id' => 'rule-' . join('-', $childPath), + 'class' => $child instanceof Filter\Condition + ? 'filter-condition' + : 'filter-chain' + ]), + $this->createTree($child, $childPath) + )); + } + } + + return $item; + } + + protected function createButtons(Filter\Rule $for, $identifier) + { + $buttons = []; + + if ($for instanceof Filter\Chain) { + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-condition:' . $identifier, + 'label' => t('Add Condition', 'to a group of filter conditions'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-group:' . $identifier, + 'label' => t('Add Group', 'of filter conditions'), + 'formnovalidate' => true + ]); + } + + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'wrap-rule:' . $identifier, + 'label' => t('Wrap in Group', 'a filter rule'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'drop-rule:' . $identifier, + 'label' => t('Delete', 'a filter rule'), + 'formnovalidate' => true + ]); + + $ul = new HtmlElement('ul'); + foreach ($buttons as $button) { + $ul->addHtml(new HtmlElement('li', null, $button)); + } + + return new HtmlElement( + 'div', + Attributes::create(['class' => 'buttons']), + $ul, + new Icon('ellipsis-h') + ); + } + + protected function createCondition(Filter\Condition $condition, $identifier) + { + $columnInput = $this->createElement('text', $identifier . '-column', [ + 'value' => $condition->metaData()->get( + 'columnLabel', + $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null + ), + 'title' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'required' => true, + 'autocomplete' => 'off', + 'data-type' => 'column', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $columnInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + (new CallbackDecorator(function ($element) { + $errors = new HtmlElement('ul', Attributes::create(['class' => 'search-errors'])); + + foreach ($element->getMessages() as $message) { + $errors->addHtml(new HtmlElement('li', null, Text::create($message))); + } + + if (! $errors->isEmpty()) { + if (trim($element->getValue())) { + $element->getAttributes()->add( + 'pattern', + sprintf( + '^\s*(?!%s\b).*\s*$', + $element->getValue() + ) + ); + } + + $element->prependWrapper(new HtmlElement( + 'div', + Attributes::create(['class' => 'search-error']), + $element, + $errors + )); + } + }))->decorate($columnInput); + + $columnFakeInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => static::FAKE_COLUMN + ]); + $columnSearchInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'validators' => ['Callback' => function ($value) use ($condition, $columnInput, &$columnSearchInput) { + if (! $this->hasBeenSubmitted()) { + return true; + } + + try { + $this->emit(static::ON_VALIDATE_COLUMN, [$condition]); + } catch (SearchException $e) { + $columnInput->addMessage($e->getMessage()); + return false; + } + + $columnSearchInput->setValue($condition->getColumn()); + $columnInput->setValue($condition->metaData()->get('columnLabel', $condition->getColumn())); + + return true; + }] + ]); + + $operatorInput = $this->createElement('select', $identifier . '-operator', [ + 'options' => [ + '~' => '~', + '!~' => '!~', + '=' => '=', + '!=' => '!=', + '>' => '>', + '<' => '<', + '>=' => '>=', + '<=' => '<=' + ], + 'value' => QueryString::getRuleSymbol($condition) + ]); + + $valueInput = $this->createElement('text', $identifier . '-value', [ + 'value' => $condition->getValue(), + 'autocomplete' => 'off', + 'data-type' => 'value', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $valueInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + + $this->registerElement($columnInput); + $this->registerElement($columnSearchInput); + $this->registerElement($operatorInput); + $this->registerElement($valueInput); + + return new HtmlElement( + 'fieldset', + Attributes::create(['name' => $identifier . '-']), + $columnInput, + $columnFakeInput, + $columnSearchInput, + $operatorInput, + $valueInput + ); + } + + protected function assemble() + { + $filterInput = $this->createElement('hidden', 'filter'); + $filterInput->getAttributes()->registerAttributeCallback( + 'value', + function () { + return $this->queryString ?: static::FAKE_COLUMN; + }, + [$this, 'setQueryString'] + ); + $this->addElement($filterInput); + + $filter = $this->getFilter(); + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $filter = Filter::equal('', ''); + } + + $this->addHtml($this->createTree($filter)); + $this->addHtml(new HtmlElement('div', Attributes::create([ + 'id' => 'search-editor-suggestions', + 'class' => 'search-suggestions' + ]))); + + if ($this->queryString) { + $this->addHtml($this->createElement('submitButton', 'structural-change', [ + 'value' => 'clear:rule-0', + 'class' => 'cancel-button', + 'label' => t('Clear Filter'), + 'formnovalidate' => true + ])); + } + + $this->addElement('submit', 'btn_submit', [ + 'label' => t('Apply') + ]); + + // Add submit button also as first element to make Web 2 submit + // the form instead of using a structural change to submit if + // the user just presses Enter. + $this->prepend($this->getElement('btn_submit')); + } + + private function popKey(array &$from, $key, $default = null) + { + if (isset($from[$key])) { + $value = $from[$key]; + unset($from[$key]); + + return $value; + } + + return $default; + } +} diff --git a/vendor/ipl/web/src/Control/SortControl.php b/vendor/ipl/web/src/Control/SortControl.php new file mode 100644 index 0000000..65c2c3d --- /dev/null +++ b/vendor/ipl/web/src/Control/SortControl.php @@ -0,0 +1,293 @@ +<?php + +namespace ipl\Web\Control; + +use GuzzleHttp\Psr7\ServerRequest; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DivDecorator; +use ipl\Html\FormElement\ButtonElement; +use ipl\Html\HtmlElement; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Query; +use ipl\Stdlib\Str; +use ipl\Web\Common\FormUid; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Allows to adjust the order of the items to display + */ +class SortControl extends Form +{ + use FormUid; + + /** @var string Default sort param */ + public const DEFAULT_SORT_PARAM = 'sort'; + + protected $defaultAttributes = ['class' => 'sort-control']; + + /** @var string Name of the URL parameter which stores the sort column */ + protected $sortParam = self::DEFAULT_SORT_PARAM; + + /** + * @var Url Request URL + * @deprecated Access {@see self::getRequest()} instead. + * @todo Remove once cube calls {@see self::handleRequest()}. + */ + protected $url; + + /** @var array Possible sort columns as sort string-value pairs */ + private $columns; + + /** @var ?string Default sort string */ + private $default; + + protected $method = 'GET'; + + /** + * Create a new sort control + * + * @param array $columns Possible sort columns + * @param Url $url Request URL + * + * @internal Use {@see self::create()} instead. + */ + private function __construct(array $columns, Url $url) + { + $this->setColumns($columns); + $this->url = $url; + } + + /** + * Create a new sort control with the given options + * + * @param array<string,string> $options A sort spec to label map + * + * @return static + */ + public static function create(array $options) + { + $normalized = []; + foreach ($options as $spec => $label) { + $normalized[SortUtil::normalizeSortSpec($spec)] = $label; + } + + $self = new static($normalized, Url::fromRequest()); + + $self->on(self::ON_REQUEST, function (ServerRequestInterface $request) use ($self) { + if (! $self->hasBeenSent()) { + // If the form is submitted by POST, handleRequest() won't access the URL, so we have to + if (($sort = $request->getQueryParams()[$self->getSortParam()] ?? null)) { + $self->populate([$self->getSortParam() => $sort]); + } + } + }); + + return $self; + } + + /** + * Get the possible sort columns + * + * @return array Sort string-value pairs + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Set the possible sort columns + * + * @param array $columns Sort string-value pairs + * + * @return $this + */ + public function setColumns(array $columns): self + { + // We're working with lowercase keys throughout the sort control + $this->columns = array_change_key_case($columns, CASE_LOWER); + + return $this; + } + + /** + * Get the default sort string + * + * @return ?string + */ + public function getDefault(): ?string + { + return $this->default; + } + + /** + * Set the default sort string + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + // We're working with lowercase keys throughout the sort control + $this->default = strtolower($default); + + return $this; + } + + /** + * Get the name of the URL parameter which stores the sort + * + * @return string + */ + public function getSortParam(): string + { + return $this->sortParam; + } + + /** + * Set the name of the URL parameter which stores the sort + * + * @param string $sortParam + * + * @return $this + */ + public function setSortParam(string $sortParam): self + { + $this->sortParam = $sortParam; + + return $this; + } + + /** + * Get the sort string + * + * @return ?string + */ + public function getSort(): ?string + { + if ($this->getRequest() === null) { + $sort = $this->url->getParam($this->getSortParam(), $this->getDefault()); + } else { + $sort = $this->getPopulatedValue($this->getSortParam(), $this->getDefault()); + } + + if (! empty($sort)) { + $columns = $this->getColumns(); + + if (! isset($columns[$sort])) { + // Choose sort string based on the first closest match + foreach (array_keys($columns) as $key) { + if (Str::startsWith($key, $sort)) { + $this->populate([$this->getSortParam() => $key]); + $sort = $key; + + break; + } + } + } + } + + return $sort; + } + + /** + * Sort the given query according to the request + * + * @param Query $query + * @param ?array|string $defaultSort + * + * @return $this + */ + public function apply(Query $query, $defaultSort = null): self + { + if ($this->getRequest() === null) { + // handleRequest() has not been called yet + // TODO: Remove this once everything using this requires ipl v0.12.0 + $this->handleRequest(ServerRequest::fromGlobals()); + } + + $default = $defaultSort ?? (array) $query->getModel()->getDefaultSort(); + if (! empty($default)) { + $this->setDefault(SortUtil::normalizeSortSpec($default)); + } + + $sort = $this->getSort(); + if (! empty($sort)) { + $query->orderBy(SortUtil::createOrderBy($sort)); + } + + return $this; + } + + protected function assemble() + { + $columns = $this->getColumns(); + $sort = $this->getSort(); + + if (empty($sort)) { + reset($columns); + $sort = key($columns); + } + + $sort = explode(',', $sort, 2); + list($column, $direction) = Str::symmetricSplit(array_shift($sort), ' ', 2); + + if (! $direction || strtolower($direction) === 'asc') { + $toggleIcon = 'sort-alpha-down'; + $toggleDirection = 'desc'; + } else { + $toggleIcon = 'sort-alpha-down-alt'; + $toggleDirection = 'asc'; + } + + if ($direction !== null) { + $value = implode(',', array_merge(["{$column} {$direction}"], $sort)); + if (! isset($columns[$value])) { + foreach ([$column, "{$column} {$toggleDirection}"] as $key) { + $key = implode(',', array_merge([$key], $sort)); + if (isset($columns[$key])) { + $columns[$value] = $columns[$key]; + unset($columns[$key]); + + break; + } + } + } + } else { + $value = implode(',', array_merge([$column], $sort)); + } + + if (! isset($columns[$value])) { + $columns[$value] = 'Custom'; + } + + $this->addElement('select', $this->getSortParam(), [ + 'class' => 'autosubmit', + 'label' => 'Sort By', + 'options' => $columns, + 'value' => $value + ]); + $select = $this->getElement($this->getSortParam()); + (new DivDecorator())->decorate($select); + + // Apply Icinga Web 2 style, for now + $select->prependWrapper(HtmlElement::create('div', ['class' => 'icinga-controls'])); + + $toggleButton = new ButtonElement($this->getSortParam(), [ + 'class' => 'control-button spinner', + 'title' => t('Change sort direction'), + 'type' => 'submit', + 'value' => implode(',', array_merge(["{$column} {$toggleDirection}"], $sort)) + ]); + $toggleButton->add(new Icon($toggleIcon)); + + $this->addHtml($toggleButton); + + if ($this->getMethod() === 'POST' && $this->hasAttribute('name')) { + $this->addElement($this->createUidElement()); + } + } +} diff --git a/vendor/ipl/web/src/Filter/ParseException.php b/vendor/ipl/web/src/Filter/ParseException.php new file mode 100644 index 0000000..bcafd09 --- /dev/null +++ b/vendor/ipl/web/src/Filter/ParseException.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Web\Filter; + +use Exception; + +class ParseException extends Exception +{ + protected $char; + + protected $charPos; + + public function __construct($filter, $char, $charPos, $extra) + { + parent::__construct(sprintf( + 'Invalid filter "%s", unexpected %s at pos %d%s', + $filter, + $char, + $charPos, + $extra + )); + + $this->char = $char; + $this->charPos = $charPos; + } + + public function getChar() + { + return $this->char; + } + + public function getCharPos() + { + return $this->charPos; + } +} diff --git a/vendor/ipl/web/src/Filter/Parser.php b/vendor/ipl/web/src/Filter/Parser.php new file mode 100644 index 0000000..d33fd86 --- /dev/null +++ b/vendor/ipl/web/src/Filter/Parser.php @@ -0,0 +1,568 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; + +class Parser +{ + use Events; + + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = 'on_condition'; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = 'on_chain'; + + /** @var string */ + protected $string; + + /** @var int */ + protected $pos; + + /** @var int */ + protected $termIndex; + + /** @var int */ + protected $length; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new Parser + * + * @param string $queryString The string to parse + */ + public function __construct($queryString = null) + { + if ($queryString !== null) { + $this->setQueryString($queryString); + } + } + + /** + * Set the query string to parse + * + * @param string $queryString + * + * @return $this + */ + public function setQueryString($queryString) + { + $this->string = (string) $queryString; + $this->length = strlen($queryString); + + return $this; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Parse the string and derive a filter rule from it + * + * @return Filter\Rule + */ + public function parse() + { + if ($this->length === 0) { + return Filter::all(); + } + + $this->pos = 0; + $this->termIndex = 0; + + return $this->readFilters(); + } + + /** + * Read filters + * + * @param int $nestingLevel + * @param string $op + * @param array $filters + * @param bool $explicit + * + * @return Filter\Chain|Filter\Condition + * @throws ParseException + */ + protected function readFilters($nestingLevel = 0, $op = null, $filters = null, $explicit = true) + { + $filters = empty($filters) ? [] : $filters; + $isNone = false; + + while ($this->pos < $this->length) { + $filter = $this->readCondition(); + $next = $this->readChar(); + + if ($filter === false) { + if ($next === '!') { + $isNone = true; + $this->termIndex++; + continue; + } + + if ($op === null && ($this->strict || count($filters) > 0) && ($next === '&' || $next === '|')) { + $op = $next; + $this->termIndex++; + continue; + } + + if ($next === false) { + // Nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === '(') { + $this->termIndex++; + + $rule = $this->readFilters($nestingLevel + 1, $isNone ? '!' : null); + if ($this->strict || ! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + + $isNone = false; + continue; + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === '&') { + if (! empty($filters)) { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next, "$op level $nestingLevel"); + } else { + if ($isNone) { + $isNone = false; + if ($filter->getValue() === true) { + // $filter is a result of `!column` + $filter->setValue(false); + $filters[] = $filter; + + $this->emit(self::ON_CONDITION, [$filter]); + } else { + // $filter is a result of `!column=[value]` + $none = Filter::none($filter); + $filters[] = $none; + + $this->emit(self::ON_CONDITION, [$filter]); + $this->emit(self::ON_CHAIN, [$none]); + } + } else { + $filters[] = $filter; + $this->emit(self::ON_CONDITION, [$filter]); + } + + if ($next === false) { + // Got filter, nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === null || $op === '&') { + if ($op === '&') { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next); + } + } + + if ($nestingLevel === 0 && $this->pos < $this->length) { + $this->parseError($op, 'Did not read full filter'); + } + + switch ($op) { + case '&': + $chain = Filter::all(...$filters); + break; + case '|': + $chain = Filter::any(...$filters); + break; + case '!': + $chain = Filter::none(...$filters); + break; + case null: + if ((! $this->strict || $nestingLevel === 0) && ! empty($filters)) { + // There is only one filter expression, no chain + return $filters[0]; + } + + $chain = Filter::all(...$filters); + break; + default: + $this->parseError($op); + } + + $this->emit(self::ON_CHAIN, [$chain]); + + return $chain; + } + + /** + * Read the next condition + * + * @return false|Filter\Condition + * + * @throws ParseException + */ + protected function readCondition() + { + if ('' === ($column = $this->readColumn())) { + return false; + } + + $columnIndex = $this->termIndex++; + + foreach (['<', '>'] as $operator) { + if (($pos = strpos($column, $operator)) !== false) { + if ($this->nextChar() === '=') { + break; + } + + $operatorIndex = $this->termIndex++; + + $value = substr($column, $pos + 1); + $column = substr($column, 0, $pos); + + $valueIndex = null; + if (ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + } + + if (in_array($this->nextChar(), ['~', '=', '>', '<', '!'], true)) { + $operator = $this->readChar(); + } else { + $operator = false; + } + + if ($operator === false) { + $condition = Filter::equal($column, true); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', null) + ->set('valueIndex', null); + + return $condition; + } + + $operatorIndex = $this->termIndex++; + + $toFloat = false; + if ($operator === '=') { + $last = substr($column, -1); + if ($last === '>' || $last === '<') { + $operator = $last . $operator; + $column = substr($column, 0, -1); + $toFloat = true; + } + } elseif (in_array($operator, ['>', '<', '!'], true)) { + $toFloat = $operator === '>' || $operator === '<'; + if (in_array($this->nextChar(), ['~', '='], true)) { + $operator .= $this->readChar(); + } + } + + $valueIndex = null; + $value = $this->readValue(); + if ($toFloat && ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + + /** + * Read the next column + * + * @return false|string false if there is none + */ + protected function readColumn() + { + $str = $this->readUntil('~', '=', '(', ')', '&', '|', '>', '<', '!'); + + if ($str === false) { + return $str; + } + + return rawurldecode($str); + } + + /** + * Read the next value + * + * @return string|string[] + * + * @throws ParseException In case there's a missing `)` + */ + protected function readValue() + { + if ($this->nextChar() === '(') { + $this->readChar(); + $var = array_map('rawurldecode', preg_split('~\|~', $this->readUntil(')'))); + + if ($this->readChar() !== ')') { + $this->parseError(null, 'Expected ")"'); + } + } else { + $var = rawurldecode($this->readUntil(')', '&', '|', '>', '<')); + } + + return $var; + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + /** + * Look at the next character + * + * @return false|string false if there is no character left + */ + protected function nextChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos]; + } + + return false; + } + + /** + * Create and return a condition + * + * @param string $column + * @param string $operator + * @param mixed $value + * + * @return Filter\Condition + */ + protected function createCondition($column, $operator, $value) + { + $column = trim($column); + + switch ($operator) { + case '~': + return Filter::like($column, $value); + case '!~': + return Filter::unlike($column, $value); + case '=': + return Filter::equal($column, $value); + case '!=': + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + /** + * Throw a parse exception + * + * @param string $char + * @param string $extraMsg + * + * @throws ParseException + */ + protected function parseError($char = null, $extraMsg = null) + { + if ($extraMsg === null) { + $extra = ''; + } else { + $extra = ': ' . $extraMsg; + } + + if ($char === null) { + if ($this->pos < $this->length) { + $char = $this->string[$this->pos]; + } else { + $char = $this->string[--$this->pos]; + } + } + + throw new ParseException( + $this->string, + $char, + $this->pos, + $extra + ); + } +} diff --git a/vendor/ipl/web/src/Filter/QueryString.php b/vendor/ipl/web/src/Filter/QueryString.php new file mode 100644 index 0000000..e1bb533 --- /dev/null +++ b/vendor/ipl/web/src/Filter/QueryString.php @@ -0,0 +1,94 @@ +<?php + +namespace ipl\Web\Filter; + +use InvalidArgumentException; +use ipl\Stdlib\Filter; + +final class QueryString +{ + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = Parser::ON_CONDITION; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = Parser::ON_CHAIN; + + /** + * This class is only a factory / helper + */ + private function __construct() + { + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Parser + */ + public static function fromString($string) + { + return new Parser($string); + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Filter\Rule + */ + public static function parse($string) + { + return (new Parser($string))->parse(); + } + + /** + * Assemble a query string for the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function render(Filter\Rule $rule) + { + return (new Renderer($rule))->render(); + } + + /** + * Get the symbol associated with the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function getRuleSymbol(Filter\Rule $rule) + { + switch (true) { + case $rule instanceof Filter\Unlike: + return '!~'; + case $rule instanceof Filter\Unequal: + return '!='; + case $rule instanceof Filter\Like: + return '~'; + case $rule instanceof Filter\Equal: + return '='; + case $rule instanceof Filter\GreaterThan: + return '>'; + case $rule instanceof Filter\LessThan: + return '<'; + case $rule instanceof Filter\GreaterThanOrEqual: + return '>='; + case $rule instanceof Filter\LessThanOrEqual: + return '<='; + case $rule instanceof Filter\All: + return '&'; + case $rule instanceof Filter\Any: + case $rule instanceof Filter\None: + return '|'; + default: + throw new InvalidArgumentException('Unknown rule type provided'); + } + } +} diff --git a/vendor/ipl/web/src/Filter/Renderer.php b/vendor/ipl/web/src/Filter/Renderer.php new file mode 100644 index 0000000..513470e --- /dev/null +++ b/vendor/ipl/web/src/Filter/Renderer.php @@ -0,0 +1,186 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Filter; + +class Renderer +{ + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $string; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new filter Renderer + * + * @param Filter\Rule $filter + */ + public function __construct(Filter\Rule $filter) + { + $this->filter = $filter; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Assemble and return the filter as query string + * + * @return string + */ + public function render() + { + if ($this->string !== null) { + return $this->string; + } + + $this->string = ''; + $filter = $this->filter; + + if ($filter instanceof Filter\Chain) { + $this->renderChain($filter, $this->strict); + } else { + /** @var Filter\Condition $filter */ + $this->renderCondition($filter); + } + + return $this->string; + } + + /** + * Assemble the given filter Chain + * + * @param Filter\Chain $chain + * @param bool $wrap + * + * @return void + */ + protected function renderChain(Filter\Chain $chain, $wrap = false) + { + if (! $this->strict && $chain->isEmpty()) { + return; + } + + $chainOperator = null; + switch (true) { + case $chain instanceof Filter\All: + $chainOperator = '&'; + break; + case $chain instanceof Filter\None: + $this->string .= '!'; + + // Force wrap, it may be the root node + if (! $wrap) { + if ($chain->count() > 1) { + $wrap = true; + } else { + $iterator = $chain->getIterator(); + $wrap = $iterator->current() instanceof Filter\None; + } + } + + // None shares the operator with Any + case $chain instanceof Filter\Any: + $chainOperator = '|'; + break; + } + + if ($wrap) { + $this->string .= '('; + } + + foreach ($chain as $rule) { + if ($rule instanceof Filter\Chain) { + $this->renderChain($rule, $this->strict || $rule->count() > 1); + } else { + /** @var Filter\Condition $rule */ + $this->renderCondition($rule); + } + + $this->string .= $chainOperator; + } + + if (! $chain->isEmpty() && (! $this->strict || ! ($chain instanceof Filter\Any && $chain->count() === 1))) { + // Remove redundant chain operator added last + $this->string = substr($this->string, 0, -1); + } elseif ($chain->isEmpty() && $chain instanceof Filter\Any) { + // If the chain is empty and strict mode is on, we need a + // chain operator to designate it's an OR, not an AND + $this->string .= $chainOperator; + } + + if ($wrap) { + $this->string .= ')'; + } + } + + /** + * Assemble the given filter Condition + * + * @param Filter\Condition $condition + * + * @return void + */ + protected function renderCondition(Filter\Condition $condition) + { + $value = $condition->getValue(); + if (is_bool($value) && ! $value) { + $this->string .= '!'; + } + + $this->string .= rawurlencode($condition->getColumn()); + + if (is_bool($value)) { + return; + } + + switch (true) { + case $condition instanceof Filter\Unlike: + $this->string .= '!~'; + break; + case $condition instanceof Filter\Unequal: + $this->string .= '!='; + break; + case $condition instanceof Filter\Like: + $this->string .= '~'; + break; + case $condition instanceof Filter\Equal: + $this->string .= '='; + break; + case $condition instanceof Filter\GreaterThan: + $this->string .= rawurlencode('>'); + break; + case $condition instanceof Filter\LessThan: + $this->string .= rawurlencode('<'); + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $this->string .= rawurlencode('>') . '='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $this->string .= rawurlencode('<') . '='; + break; + } + + if (is_array($value)) { + $this->string .= '(' . join('|', array_map('rawurlencode', $value)) . ')'; + } elseif ($value !== null) { + $this->string .= rawurlencode($value); + } + } +} diff --git a/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php new file mode 100644 index 0000000..f038931 --- /dev/null +++ b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php @@ -0,0 +1,123 @@ +<?php + +namespace ipl\Web\FormDecorator; + +use Icinga\Web\Window; +use ipl\Html\Attributes; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormDecorator\DivDecorator; +use ipl\Html\FormElement\CheckboxElement; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; + +class IcingaFormDecorator extends DivDecorator +{ + const SUBMIT_ELEMENT_CLASS = 'form-controls'; + const INPUT_ELEMENT_CLASS = 'control-group'; + const ERROR_CLASS = 'errors'; + + protected function assembleElement() + { + if ($this->formElement instanceof FormSubmitElement) { + $this->formElement->getAttributes()->add('class', 'btn-primary'); + } + + $element = parent::assembleElement(); + + if ($element instanceof CheckboxElement) { + return $this->createCheckbox($element); + } + + return $element; + } + + protected function createCheckbox(CheckboxElement $checkbox) + { + if (! $checkbox->getAttributes()->has('id')) { + $checkbox->setAttribute( + 'id', + $checkbox->getName() . '_' . Window::getInstance()->getContainerId() + ); + } + + $checkbox->getAttributes()->add('class', 'sr-only'); + + $classes = ['toggle-switch']; + if ($checkbox->getAttributes()->get('disabled')->getValue()) { + $classes[] = 'disabled'; + } + + $document = new HtmlDocument(); + $document->addHtml( + $checkbox, + new HtmlElement( + 'label', + Attributes::create([ + 'class' => $classes, + 'aria-hidden' => 'true', + 'for' => $checkbox->getAttributes()->get('id')->getValue() + ]), + new HtmlElement('span', Attributes::create(['class' => 'toggle-slider'])) + ) + ); + + $checkbox->prependWrapper($document); + + return $checkbox; + } + + protected function assembleLabel() + { + $label = parent::assembleLabel(); + if (! $this->formElement instanceof FieldsetElement) { + if ($label !== null) { + $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group']))); + } elseif (! $this->formElement instanceof FormSubmitElement) { + $label = new HtmlElement( + 'div', + Attributes::create(['class' => 'control-label-group']), + HtmlString::create(' ') + ); + } + } + + return $label; + } + + protected function assembleDescription() + { + if ($this->formElement instanceof FieldsetElement) { + return parent::assembleDescription(); + } + + if (($description = $this->formElement->getDescription()) !== null) { + $iconAttributes = [ + 'class' => 'control-info', + 'role' => 'image', + 'title' => $description + ]; + + $describedBy = null; + if ($this->formElement->getAttributes()->has('id')) { + $iconAttributes['aria-hidden'] = 'true'; + + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $describedBy = new HtmlElement('span', Attributes::create([ + 'id' => $descriptionId, + 'class' => 'sr-only' + ]), Text::create($description)); + + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return [ + new Icon('info-circle', $iconAttributes), + $describedBy + ]; + } + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement.php b/vendor/ipl/web/src/FormElement/ScheduleElement.php new file mode 100644 index 0000000..f872f49 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement.php @@ -0,0 +1,636 @@ +<?php + +namespace ipl\Web\FormElement; + +use DateTime; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\Cron; +use ipl\Scheduler\OneOff; +use ipl\Scheduler\RRule; +use ipl\Validator\BetweenValidator; +use ipl\Validator\CallbackValidator; +use ipl\Web\FormElement\ScheduleElement\AnnuallyFields; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\MonthlyFields; +use ipl\Web\FormElement\ScheduleElement\Recurrence; +use ipl\Web\FormElement\ScheduleElement\WeeklyFields; +use LogicException; +use Psr\Http\Message\RequestInterface; + +class ScheduleElement extends FieldsetElement +{ + use FieldsProtector; + + /** @var string Plain cron expressions */ + protected const CRON_EXPR = 'cron_expr'; + + /** @var string Configure the individual expression parts manually */ + protected const CUSTOM_EXPR = 'custom'; + + /** @var string Used to run a one-off task */ + protected const NO_REPEAT = 'none'; + + protected $defaultAttributes = ['class' => 'schedule-element']; + + /** @var array A list of allowed frequencies used to configure custom expressions */ + protected $customFrequencies = []; + + /** @var array */ + protected $advanced = []; + + /** @var array */ + protected $regulars = []; + + /** @var string Schedule frequency of this element */ + protected $frequency = self::NO_REPEAT; + + /** @var string */ + protected $customFrequency; + + /** @var DateTime */ + protected $start; + + /** @var WeeklyFields Weekly parts of this schedule element */ + protected $weeklyField; + + /** @var MonthlyFields Monthly parts of this schedule element */ + protected $monthlyFields; + + /** @var AnnuallyFields Annually parts of this schedule element */ + protected $annuallyFields; + + protected function init(): void + { + $this->start = new DateTime(); + $this->weeklyField = new WeeklyFields('weekly-fields', [ + 'default' => $this->start->format('D'), + 'protector' => function (string $day) { + return $this->protectId($day); + }, + ]); + + $this->monthlyFields = new MonthlyFields('monthly-fields', [ + 'default' => $this->start->format('j'), + 'availableFields' => (int) $this->start->format('t'), + 'protector' => function ($day) { + return $this->protectId($day); + } + ]); + + $this->annuallyFields = new AnnuallyFields('annually-fields', [ + 'default' => $this->start->format('M'), + 'protector' => function ($month) { + return $this->protectId($month); + } + ]); + + + $this->regulars = [ + RRule::MINUTELY => $this->translate('Minutely'), + RRule::HOURLY => $this->translate('Hourly'), + RRule::DAILY => $this->translate('Daily'), + RRule::WEEKLY => $this->translate('Weekly'), + RRule::MONTHLY => $this->translate('Monthly'), + RRule::QUARTERLY => $this->translate('Quarterly'), + RRule::YEARLY => $this->translate('Annually'), + ]; + + $this->customFrequencies = array_slice($this->regulars, 2); + unset($this->customFrequencies[RRule::QUARTERLY]); + + $this->advanced = [ + static::CUSTOM_EXPR => $this->translate('Custom…'), + static::CRON_EXPR => $this->translate('Cron Expression…') + ]; + } + + /** + * Get whether this element is rendering a cron expression + * + * @return bool + */ + public function hasCronExpression(): bool + { + return $this->getFrequency() === static::CRON_EXPR; + } + + /** + * Get the frequency of this element + * + * @return string + */ + public function getFrequency(): string + { + return $this->getPopulatedValue('frequency', $this->frequency); + } + + /** + * Set the custom frequency of this schedule element + * + * @param string $frequency + * + * @return $this + */ + public function setFrequency(string $frequency): self + { + if ( + $frequency !== static::NO_REPEAT + && ! isset($this->regulars[$frequency]) + && ! isset($this->advanced[$frequency]) + ) { + throw new InvalidArgumentException(sprintf('Invalid frequency provided: %s', $frequency)); + } + + $this->frequency = $frequency; + + return $this; + } + + /** + * Get custom frequency of this element + * + * @return ?string + */ + public function getCustomFrequency(): ?string + { + return $this->getValue('custom-frequency', $this->customFrequency); + } + + /** + * Set custom frequency of this element + * + * @param string $frequency + * + * @return $this + */ + public function setCustomFrequency(string $frequency): self + { + if (! isset($this->customFrequencies[$frequency])) { + throw new InvalidArgumentException(sprintf('Invalid custom frequency provided: %s', $frequency)); + } + + $this->customFrequency = $frequency; + + return $this; + } + + /** + * Set start time of the parsed expressions + * + * @param DateTime $start + * + * @return $this + */ + public function setStart(DateTime $start): self + { + $this->start = $start; + + // Forward the start time update to the sub elements as well! + $this->weeklyField->setDefault($start->format('D')); + $this->annuallyFields->setDefault($start->format('M')); + $this->monthlyFields + ->setDefault((int) $start->format('j')) + ->setAvailableFields((int) $start->format('t')); + + return $this; + } + + public function getValue($name = null, $default = null) + { + if ($name !== null || ! $this->hasBeenValidated()) { + return parent::getValue($name, $default); + } + + $frequency = $this->getFrequency(); + $start = parent::getValue('start'); + switch ($frequency) { + case static::NO_REPEAT: + return new OneOff($start); + case static::CRON_EXPR: + $rule = new Cron(parent::getValue('cron_expression')); + + break; + case RRule::MINUTELY: + case RRule::HOURLY: + case RRule::DAILY: + case RRule::WEEKLY: + case RRule::MONTHLY: + case RRule::QUARTERLY: + case RRule::YEARLY: + $rule = RRule::fromFrequency($frequency); + + break; + default: // static::CUSTOM_EXPR + $interval = parent::getValue('interval', 1); + $customFrequency = parent::getValue('custom-frequency', RRule::DAILY); + switch ($customFrequency) { + case RRule::DAILY: + if ($interval === '*') { + $interval = 1; + } + + $rule = new RRule("FREQ=DAILY;INTERVAL=$interval"); + + break; + case RRule::WEEKLY: + $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); + + $rule = new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); + + break; + /** @noinspection PhpMissingBreakStatementInspection */ + case RRule::MONTHLY: + $runsOn = $this->monthlyFields->getValue('runsOn', MonthlyFields::RUNS_EACH); + if ($runsOn === MonthlyFields::RUNS_EACH) { + $byMonth = implode(',', $this->monthlyFields->getSelectedDays()); + + $rule = new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + + break; + } + // Fall-through to the next switch case + case RRule::YEARLY: + $rule = "FREQ=MONTHLY;INTERVAL=$interval;"; + if ($customFrequency === RRule::YEARLY) { + $runsOn = $this->annuallyFields->getValue('runsOnThe', 'n'); + $month = $this->annuallyFields->getValue('month', (int) $this->start->format('m')); + if (is_string($month)) { + $datetime = DateTime::createFromFormat('!M', $month); + if (! $datetime) { + throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $month)); + } + + $month = (int) $datetime->format('m'); + } + + $rule = "FREQ=YEARLY;INTERVAL=1;BYMONTH=$month;"; + if ($runsOn === 'n') { + $rule = new RRule($rule); + + break; + } + } + + $element = $this->monthlyFields; + if ($customFrequency === RRule::YEARLY) { + $element = $this->annuallyFields; + } + + $runDay = $element->getValue('day', $element::$everyDay); + $ordinal = $element->getValue('ordinal', $element::$first); + $position = $element->getOrdinalAsInteger($ordinal); + + if ($runDay === $element::$everyDay) { + $rule .= "BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekday) { + $rule .= "BYDAY=MO,TU,WE,TH,FR;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekend) { + $rule .= "BYDAY=SA,SU;BYSETPOS=$position"; + } else { + $rule .= sprintf('BYDAY=%d%s', $position, $runDay); + } + + $rule = new RRule($rule); + + break; + default: + throw new LogicException(sprintf('Custom frequency %s is not supported!', $customFrequency)); + } + } + + $rule->startAt($start); + if (parent::getValue('use-end-time', 'n') === 'y') { + $rule->endAt(parent::getValue('end')); + } + + // Sync the start time and first recurrence of the rule + if (! $this->hasCronExpression() && $this->getFrequency() !== static::NO_REPEAT) { + $nextDue = $rule->getNextRecurrences($start)->current() ?? $start; + $rule->startAt($nextDue); + } + + return $rule; + } + + public function setValue($value) + { + $values = $value; + $rule = $value; + if ($rule instanceof Frequency) { + if ($rule->getStart()) { + $this->setStart($rule->getStart()); + } + + $values = []; + if ($rule->getEnd() && ! $rule instanceof OneOff) { + $values['use-end-time'] = 'y'; + $values['end'] = $rule->getEnd(); + } + + if ($rule instanceof OneOff) { + $values['frequency'] = static::NO_REPEAT; + } elseif ($rule instanceof Cron) { + $values['cron_expression'] = $rule->getExpression(); + $values['frequency'] = static::CRON_EXPR; + + $this->setFrequency(static::CRON_EXPR); + } elseif ($rule instanceof RRule) { + $values['interval'] = $rule->getInterval(); + switch ($rule->getFrequency()) { + case RRule::DAILY: + if ($rule->getInterval() <= 1 && strpos($rule->getString(), 'INTERVAL=') === false) { + $this->setFrequency(RRule::DAILY); + } else { + $this + ->setFrequency(static::CUSTOM_EXPR) + ->setCustomFrequency(RRule::DAILY); + } + + break; + case RRule::WEEKLY: + if (! $rule->getByDay() || empty($rule->getByDay())) { + $this->setFrequency(RRule::WEEKLY); + } else { + $values['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); + $this + ->setFrequency(static::CUSTOM_EXPR) + ->setCustomFrequency(RRule::WEEKLY); + } + + break; + case RRule::MONTHLY: + case RRule::YEARLY: + $isMonthly = $rule->getFrequency() === RRule::MONTHLY; + if ($rule->getByDay() || $rule->getByMonthDay() || $rule->getByMonth()) { + $this->setFrequency(static::CUSTOM_EXPR); + + if ($isMonthly) { + $values['monthly-fields'] = $this->monthlyFields->loadRRule($rule); + $this->setCustomFrequency(RRule::MONTHLY); + } else { + $values['annually-fields'] = $this->annuallyFields->loadRRule($rule); + $this->setCustomFrequency(RRule::YEARLY); + } + } elseif ($isMonthly && $rule->getInterval() === 3) { + $this->setFrequency(RRule::QUARTERLY); + } else { + $this->setFrequency($rule->getFrequency()); + } + + break; + default: + $this->setFrequency($rule->getFrequency()); + } + + $values['frequency'] = $this->getFrequency(); + $values['custom-frequency'] = $this->getCustomFrequency(); + } + } + + return parent::setValue($values); + } + + protected function assemble() + { + $start = $this->getPopulatedValue('start') ?: $this->start; + if (! $start instanceof DateTime) { + $start = new DateTime($start); + } + $this->setStart($start); + + $autosubmit = ! $this->hasCronExpression() && $this->getFrequency() !== static::NO_REPEAT; + $this->addElement('localDateTime', 'start', [ + 'class' => $autosubmit ? 'autosubmit' : null, + 'required' => true, + 'label' => $this->translate('Start'), + 'value' => $start, + 'description' => $this->translate('Start time of this schedule') + ]); + + $this->addElement('checkbox', 'use-end-time', [ + 'required' => false, + 'class' => 'autosubmit', + 'disabled' => $this->getPopulatedValue('frequency', static::NO_REPEAT) === static::NO_REPEAT ?: null, + 'value' => $this->getPopulatedValue('use-end-time', 'n'), + 'label' => $this->translate('Use End Time') + ]); + + if ($this->getPopulatedValue('use-end-time', 'n') === 'y') { + $end = $this->getPopulatedValue('end', new DateTime()); + if (! $end instanceof DateTime) { + $end = new DateTime($end); + } + + $this->addElement('localDateTime', 'end', [ + 'class' => ! $this->hasCronExpression() ? 'autosubmit' : null, + 'required' => true, + 'value' => $end, + 'label' => $this->translate('End'), + 'description' => $this->translate('End time of this schedule') + ]); + } + + $this->addElement('select', 'frequency', [ + 'required' => false, + 'class' => 'autosubmit', + 'label' => $this->translate('Frequency'), + 'description' => $this->translate('Specifies how often this job run should be recurring'), + 'options' => [ + static::NO_REPEAT => $this->translate('None'), + $this->translate('Regular') => $this->regulars, + $this->translate('Advanced') => $this->advanced + ], + ]); + + if ($this->getFrequency() === static::CUSTOM_EXPR) { + $this->addElement('select', 'custom-frequency', [ + 'required' => false, + 'class' => 'autosubmit', + 'value' => parent::getValue('custom-frequency'), + 'options' => $this->customFrequencies, + 'label' => $this->translate('Custom Frequency'), + 'description' => $this->translate('Specifies how often this job run should be recurring') + ]); + + switch (parent::getValue('custom-frequency', RRule::DAILY)) { + case RRule::DAILY: + $this->assembleCommonElements(); + + break; + case RRule::WEEKLY: + $this->assembleCommonElements(); + $this->addElement($this->weeklyField); + + break; + case RRule::MONTHLY: + $this->assembleCommonElements(); + $this->addElement($this->monthlyFields); + + break; + case RRule::YEARLY: + $this->addElement($this->annuallyFields); + } + } elseif ($this->hasCronExpression()) { + $this->addElement('text', 'cron_expression', [ + 'required' => true, + 'label' => $this->translate('Cron Expression'), + 'description' => $this->translate('Job cron Schedule'), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if ($value && ! Cron::isValid($value)) { + $validator->addMessage($this->translate('Invalid CRON expression')); + + return false; + } + + return true; + }) + ] + ]); + } + + if ($this->getFrequency() !== static::NO_REPEAT && ! $this->hasCronExpression()) { + $this->addElement( + new Recurrence('schedule-recurrences', [ + 'id' => $this->protectId('schedule-recurrences'), + 'label' => $this->translate('Next occurrences'), + 'validate' => function (): array { + $isValid = $this->isValid(); + $reason = null; + if (! $isValid && $this->getFrequency() === static::CUSTOM_EXPR) { + if ( + $this->getCustomFrequency() !== RRule::YEARLY + && ! $this->getElement('interval')->isValid() + ) { + $reason = current($this->getElement('interval')->getMessages()); + } else { + $frequency = $this->getCustomFrequency(); + switch ($frequency) { + case RRule::WEEKLY: + $reason = current($this->weeklyField->getMessages()); + + break; + case RRule::MONTHLY: + $reason = current($this->monthlyFields->getMessages()); + + break; + default: // annually + $reason = current($this->annuallyFields->getMessages()); + + break; + } + } + } + + return [$isValid, $reason]; + }, + 'frequency' => function (): Frequency { + if ($this->getFrequency() === static::CUSTOM_EXPR) { + $rule = $this->getValue(); + } else { + $rule = RRule::fromFrequency($this->getFrequency()); + } + + $now = new DateTime(); + $start = $this->getValue('start'); + if ($start < $now) { + $now->setTime($start->format('H'), $start->format('i'), $start->format('s')); + $start = $now; + } + + $rule->startAt($start); + if ($this->getPopulatedValue('use-end-time') === 'y') { + $rule->endAt($this->getValue('end')); + } + + return $rule; + } + ]) + ); + } + } + + /** + * Assemble common parts for all the frequencies + */ + private function assembleCommonElements(): void + { + $repeat = $this->getCustomFrequency(); + if ($repeat === RRule::WEEKLY) { + $text = $this->translate('week(s) on'); + $max = 53; + } elseif ($repeat === RRule::MONTHLY) { + $text = $this->translate('month(s)'); + $max = 12; + } else { + $text = $this->translate('day(s)'); + $max = 31; + } + + $options = ['min' => 1, 'max' => $max]; + $this->addElement('number', 'interval', [ + 'class' => 'autosubmit', + 'value' => 1, + 'min' => 1, + 'max' => $max, + 'validators' => [new BetweenValidator($options)] + ]); + + $numberSpecifier = HtmlElement::create('div', ['class' => 'number-specifier']); + $element = $this->getElement('interval'); + $element->prependWrapper($numberSpecifier); + + $numberSpecifier->prependHtml(HtmlElement::create('span', null, $this->translate('Every'))); + $numberSpecifier->addHtml($element); + $numberSpecifier->addHtml(HtmlElement::create('span', null, $text)); + } + + /** + * Get prepared multipart updates + * + * @param RequestInterface $request + * + * @return array + */ + public function prepareMultipartUpdate(RequestInterface $request): array + { + $autoSubmittedBy = $request->getHeader('X-Icinga-AutoSubmittedBy'); + $pattern = '/\[(weekly-fields|monthly-fields|annually-fields)]\[(ordinal|month|day(\d+)?|[A-Z]{2})]$/'; + + $partUpdates = []; + if ( + $autoSubmittedBy + && ( + preg_match('/\[(start|end)]$/', $autoSubmittedBy[0], $matches) + || preg_match($pattern, $autoSubmittedBy[0]) + || preg_match('/\[interval]/', $autoSubmittedBy[0]) + ) + ) { + $this->ensureAssembled(); + + $partUpdates[] = $this->getElement('schedule-recurrences'); + if ( + $this->getFrequency() === static::CUSTOM_EXPR + && $this->getCustomFrequency() === RRule::MONTHLY + && isset($matches[1]) + && $matches[1] === 'start' + ) { + // To update the available fields/days based on the provided start time + $partUpdates[] = $this->monthlyFields; + } + } + + return $partUpdates; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php new file mode 100644 index 0000000..857711a --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php @@ -0,0 +1,133 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\FormattedString; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; +use ipl\Web\Widget\Icon; + +class AnnuallyFields extends FieldsetElement +{ + use FieldsUtils; + use FieldsProtector; + + /** @var array A list of valid months */ + protected $months = []; + + /** @var string A month to preselect by default */ + protected $default = 'JAN'; + + public function __construct($name, $attributes = null) + { + $this->months = [ + 'JAN' => $this->translate('Jan'), + 'FEB' => $this->translate('Feb'), + 'MAR' => $this->translate('Mar'), + 'APR' => $this->translate('Apr'), + 'MAY' => $this->translate('May'), + 'JUN' => $this->translate('Jun'), + 'JUL' => $this->translate('Jul'), + 'AUG' => $this->translate('Aug'), + 'SEP' => $this->translate('Sep'), + 'OCT' => $this->translate('Oct'), + 'NOV' => $this->translate('Nov'), + 'DEC' => $this->translate('Dec') + ]; + + parent::__construct($name, $attributes); + } + + protected function init(): void + { + parent::init(); + $this->initUtils(); + } + + /** + * Set the default month to be activated + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + if (! isset($this->months[strtoupper($this->default)])) { + throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $default)); + } + + $this->default = strtoupper($default); + + return $this; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('annually-fields')); + + $fieldsSelector = new FieldsRadio('month', [ + 'class' => ['autosubmit', 'sr-only'], + 'value' => $this->default, + 'options' => $this->months, + 'protector' => function ($value) { + return $this->protectId($value); + } + ]); + $this->registerElement($fieldsSelector); + + $runsOnThe = $this->getPopulatedValue('runsOnThe', 'n'); + $this->addElement('checkbox', 'runsOnThe', [ + 'class' => 'autosubmit', + 'value' => $runsOnThe + ]); + + $checkboxControls = HtmlElement::create('div', ['class' => 'toggle-slider-controls']); + $checkbox = $this->getElement('runsOnThe'); + $checkbox->prependWrapper($checkboxControls); + $checkboxControls->addHtml($checkbox, HtmlElement::create('span', null, $this->translate('On the'))); + + $annuallyWrapper = HtmlElement::create('div', ['class' => 'annually']); + $checkboxControls->prependWrapper($annuallyWrapper); + $annuallyWrapper->addHtml($fieldsSelector); + + $notes = HtmlElement::create('div', ['class' => 'note']); + $notes->addHtml( + FormattedString::create( + $this->translate('Use %s / %s keys to choose a month by keyboard.'), + new Icon('arrow-left'), + new Icon('arrow-right') + ) + ); + $annuallyWrapper->addHtml($notes); + + $enumerations = $this->createOrdinalElement(); + $enumerations->getAttributes()->set('disabled', $runsOnThe === 'n'); + $this->registerElement($enumerations); + + $selectableDays = $this->createOrdinalSelectableDays(); + $selectableDays->getAttributes()->set('disabled', $runsOnThe === 'n'); + $this->registerElement($selectableDays); + + $ordinalWrapper = HtmlElement::create('div', ['class' => ['ordinal', 'annually']]); + $this + ->decorate($enumerations) + ->addHtml($enumerations); + + $enumerations->prependWrapper($ordinalWrapper); + $ordinalWrapper->addHtml($enumerations, $selectableDays); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php new file mode 100644 index 0000000..affd519 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement\Common; + +trait FieldsProtector +{ + /** @var callable */ + protected $protector; + + /** + * Set callback to protect ids with + * + * @param ?callable $protector + * + * @return $this + */ + public function setIdProtector(?callable $protector): self + { + $this->protector = $protector; + + return $this; + } + + /** + * Protect the given html id + * + * The provided id is returned as is, if no protector is specified + * + * @param string $id + * + * @return string + */ + public function protectId(string $id): string + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php new file mode 100644 index 0000000..bf28255 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -0,0 +1,243 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement\Common; + +use DateInterval; +use DateTime; +use Exception; +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Scheduler\RRule; +use ipl\Web\FormElement\ScheduleElement\MonthlyFields; + +trait FieldsUtils +{ + // Non-standard frequency options + public static $everyDay = 'day'; + public static $everyWeekday = 'weekday'; + public static $everyWeekend = 'weekend'; + + // Enumerators for the monthly and annually schedule of a custom frequency + public static $first = 'first'; + public static $second = 'second'; + public static $third = 'third'; + public static $fourth = 'fourth'; + public static $fifth = 'fifth'; + public static $last = 'last'; + + private $regulars = []; + + protected function initUtils(): void + { + $this->regulars = [ + 'MO' => $this->translate('Monday'), + 'TU' => $this->translate('Tuesday'), + 'WE' => $this->translate('Wednesday'), + 'TH' => $this->translate('Thursday'), + 'FR' => $this->translate('Friday'), + 'SA' => $this->translate('Saturday'), + 'SU' => $this->translate('Sunday') + ]; + } + + protected function createOrdinalElement(): FormElement + { + return $this->createElement('select', 'ordinal', [ + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('ordinal', static::$first), + 'options' => [ + static::$first => $this->translate('First'), + static::$second => $this->translate('Second'), + static::$third => $this->translate('Third'), + static::$fourth => $this->translate('Fourth'), + static::$fifth => $this->translate('Fifth'), + static::$last => $this->translate('Last') + ] + ]); + } + + protected function createOrdinalSelectableDays(): FormElement + { + $select = $this->createElement('select', 'day', [ + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('day', static::$everyDay), + 'options' => $this->regulars + [ + 'separator' => '──────────────────────────', + static::$everyDay => $this->translate('Day'), + static::$everyWeekday => $this->translate('Weekday (Mon - Fri)'), + static::$everyWeekend => $this->translate('WeekEnd (Sat or Sun)') + ] + ]); + $select->getOption('separator')->getAttributes()->set('disabled', true); + + return $select; + } + + /** + * Load the given RRule instance into a list of key=>value pairs + * + * @param RRule $rule + * + * @return array + */ + public function loadRRule(RRule $rule): array + { + $values = []; + $isMonthly = $rule->getFrequency() === RRule::MONTHLY; + if ($isMonthly && (! empty($rule->getByMonthDay()) || empty($rule->getByDay()))) { + $monthDays = $rule->getByMonthDay() ?? []; + foreach (range(1, $this->availableFields) as $value) { + $values["day$value"] = in_array((string) $value, $monthDays, true) ? 'y' : 'n'; + } + + $values['runsOn'] = MonthlyFields::RUNS_EACH; + } else { + $position = $rule->getBySetPosition(); + $byDay = $rule->getByDay() ?? []; + + if ($isMonthly) { + $values['runsOn'] = MonthlyFields::RUNS_ONTHE; + } else { + $months = $rule->getByMonth(); + if (empty($months) && $rule->getStart()) { + $months[] = $rule->getStart()->format('m'); + } elseif (empty($months)) { + $months[] = date('m'); + } + + $values['month'] = strtoupper($this->getMonthByNumber((int)$months[0])); + $values['runsOnThe'] = ! empty($byDay) ? 'y' : 'n'; + } + + if (count($byDay) == 1 && preg_match('/^(-?\d)(\w.*)$/', $byDay[0], $matches)) { + $values['ordinal'] = $this->getOrdinalString($matches[1]); + $values['day'] = $this->getWeekdayName($matches[2]); + } elseif (! empty($byDay)) { + $values['ordinal'] = $this->getOrdinalString(current($position)); + switch (count($byDay)) { + case MonthlyFields::WEEK_DAYS: + $values['day'] = static::$everyDay; + + break; + case MonthlyFields::WEEK_DAYS - 2: + $values['day'] = static::$everyWeekday; + + break; + case 1: + $values['day'] = current($byDay); + + break; + case 2: + $byDay = array_flip($byDay); + if (isset($byDay['SA']) && isset($byDay['SU'])) { + $values['day'] = static::$everyWeekend; + } + } + } + } + + return $values; + } + + /** + * Transform the given expression part into a valid week day string representation + * + * @param string $day + * + * @return string + */ + public function getWeekdayName(string $day): string + { + // Not transformation is needed when the given day is part of the valid weekdays + if (isset($this->regulars[strtoupper($day)])) { + return $day; + } + + try { + // Try to figure it out using date time before raising an error + $datetime = new DateTime('Sunday'); + $datetime->add(new DateInterval("P$day" . 'D')); + + return $datetime->format('D'); + } catch (Exception $_) { + throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $day)); + } + } + + /** + * Transform the given integer enums into something like first,second... + * + * @param string $ordinal + * + * @return string + */ + public function getOrdinalString(string $ordinal): string + { + switch ($ordinal) { + case '1': + return static::$first; + case '2': + return static::$second; + case '3': + return static::$third; + case '4': + return static::$fourth; + case '5': + return static::$fifth; + case '-1': + return static::$last; + default: + throw new InvalidArgumentException( + sprintf('Invalid ordinal string representation provided: %s', $ordinal) + ); + } + } + + /** + * Get the string representation of the given ordinal to an integer + * + * This transforms the given ordinal such as (first, second...) into its respective + * integral representation. At the moment only (1..5 + the non-standard "last") options + * are supported. So if this method returns the character "-1", is meant the last option. + * + * @param string $ordinal + * + * @return int + */ + public function getOrdinalAsInteger(string $ordinal): int + { + switch ($ordinal) { + case static::$first: + return 1; + case static::$second: + return 2; + case static::$third: + return 3; + case static::$fourth: + return 4; + case static::$fifth: + return 5; + case static::$last: + return -1; + default: + throw new InvalidArgumentException(sprintf('Invalid enumerator provided: %s', $ordinal)); + } + } + + /** + * Get a short textual representation of the given month + * + * @param int $month + * + * @return string + */ + public function getMonthByNumber(int $month): string + { + $time = DateTime::createFromFormat('!m', $month); + if ($time) { + return $time->format('M'); + } + + throw new InvalidArgumentException(sprintf('Invalid month number provided: %d', $month)); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php b/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php new file mode 100644 index 0000000..31b77c3 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php @@ -0,0 +1,58 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use ipl\Html\Attributes; +use ipl\Html\FormElement\InputElement; +use ipl\Html\FormElement\RadioElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; + +class FieldsRadio extends RadioElement +{ + use FieldsProtector; + + protected function assemble() + { + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'single-fields']]); + foreach ($this->options as $option) { + $radio = (new InputElement($this->getValueOfNameAttribute())) + ->setValue($option->getValue()) + ->setType($this->type); + + $radio->setAttributes(clone $this->getAttributes()); + + $htmlId = $this->protectId($option->getValue()); + $radio->getAttributes() + ->set('id', $htmlId) + ->registerAttributeCallback('checked', function () use ($option) { + return (string) $this->getValue() === (string) $option->getValue(); + }) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute']) + ->registerAttributeCallback('disabled', function () use ($option) { + return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled(); + }); + + $listItem = HtmlElement::create('li'); + $listItem->addHtml( + $radio, + HtmlElement::create('label', [ + 'for' => $htmlId, + 'class' => $option->getLabelCssClass(), + 'tabindex' => -1 + ], $option->getLabel()) + ); + $listItems->addHtml($listItem); + } + + $this->addHtml($listItems); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php new file mode 100644 index 0000000..26329fc --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php @@ -0,0 +1,191 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; +use ipl\Validator\InArrayValidator; +use ipl\Validator\ValidatorChain; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; + +class MonthlyFields extends FieldsetElement +{ + use FieldsUtils; + use FieldsProtector; + + /** @var string Used as radio option to run each selected days/months */ + public const RUNS_EACH = 'each'; + + /** @var string Used as radio option to build complex job schedules */ + public const RUNS_ONTHE = 'onthe'; + + /** @var int Number of days in a week */ + public const WEEK_DAYS = 7; + + /** @var int Day of the month to preselect by default */ + protected $default = 1; + + /** @var int Number of fields to render */ + protected $availableFields; + + protected function init(): void + { + parent::init(); + $this->initUtils(); + + $this->availableFields = (int) date('t'); + } + + /** + * Set the available fields/days of the month to be rendered + * + * @param int $fields + * + * @return $this + */ + public function setAvailableFields(int $fields): self + { + $this->availableFields = $fields; + + return $this; + } + + /** + * Set the default field/day to be selected + * + * @param int $default + * + * @return $this + */ + public function setDefault(int $default): self + { + $this->default = $default; + + return $this; + } + + /** + * Get all the selected weekdays + * + * @return array + */ + public function getSelectedDays(): array + { + $selectedDays = []; + foreach (range(1, $this->availableFields) as $day) { + if ($this->getValue("day$day", 'n') === 'y') { + $selectedDays[] = $day; + } + } + + if (empty($selectedDays)) { + $selectedDays[] = $this->default; + } + + return $selectedDays; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('monthly-fields')); + + $runsOn = $this->getPopulatedValue('runsOn', static::RUNS_EACH); + $this->addElement('radio', 'runsOn', [ + 'required' => true, + 'class' => 'autosubmit', + 'value' => $runsOn, + 'options' => [static::RUNS_EACH => $this->translate('Each')], + ]); + + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'multiple-fields']]); + if ($runsOn === static::RUNS_ONTHE) { + $listItems->getAttributes()->add('class', 'disabled'); + } + + foreach (range(1, $this->availableFields) as $day) { + $checkbox = $this->createElement('checkbox', "day$day", [ + 'class' => ['autosubmit', 'sr-only'], + 'value' => $day === $this->default && $runsOn === static::RUNS_EACH + ]); + $this->registerElement($checkbox); + + $htmlId = $this->protectId("day$day"); + $checkbox->getAttributes()->set('id', $htmlId); + + $listItem = HtmlElement::create('li'); + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $day)); + $listItems->addHtml($listItem); + } + + $monthlyWrapper = HtmlElement::create('div', ['class' => 'monthly']); + $runsEach = $this->getElement('runsOn'); + $runsEach->prependWrapper($monthlyWrapper); + $monthlyWrapper->addHtml($runsEach, $listItems); + + $this->addElement('radio', 'runsOn', [ + 'required' => $runsOn !== static::RUNS_EACH, + 'class' => 'autosubmit', + 'options' => [static::RUNS_ONTHE => $this->translate('On the')], + 'validators' => [ + new InArrayValidator([ + 'strict' => true, + 'haystack' => [static::RUNS_EACH, static::RUNS_ONTHE] + ]) + ] + ]); + + $ordinalWrapper = HtmlElement::create('div', ['class' => 'ordinal']); + $runsOnThe = $this->getElement('runsOn'); + $runsOnThe->prependWrapper($ordinalWrapper); + $ordinalWrapper->addHtml($runsOnThe); + + $enumerations = $this->createOrdinalElement(); + $enumerations->getAttributes()->set('disabled', $runsOn === static::RUNS_EACH); + $this->registerElement($enumerations); + + $selectableDays = $this->createOrdinalSelectableDays(); + $selectableDays->getAttributes()->set('disabled', $runsOn === static::RUNS_EACH); + $this->registerElement($selectableDays); + + $ordinalWrapper->addHtml($enumerations, $selectableDays); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('availableFields', null, [$this, 'setAvailableFields']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new CallbackValidator(function ($_, CallbackValidator $validator): bool { + if ($this->getValue('runsOn', static::RUNS_EACH) !== static::RUNS_EACH) { + return true; + } + + $valid = false; + foreach (range(1, $this->availableFields) as $day) { + if ($this->getValue("day$day") === 'y') { + $valid = true; + + break; + } + } + + if (! $valid) { + $validator->addMessage($this->translate('You must select at least one of these days')); + } + + return $valid; + }) + ); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php new file mode 100644 index 0000000..8693b20 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use DateTime; +use ipl\Html\Attributes; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\RRule; + +class Recurrence extends BaseFormElement +{ + use Translation; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'schedule-recurrences']; + + /** @var callable A callable that generates a frequency instance */ + protected $frequencyCallback; + + /** @var callable A validation callback for the schedule element */ + protected $validateCallback; + + /** + * Set a validation callback that will be called when assembling this element + * + * @param callable $callback + * + * @return $this + */ + public function setValid(callable $callback): self + { + $this->validateCallback = $callback; + + return $this; + } + + /** + * Set a callback that generates an {@see Frequency} instance + * + * @param callable $callback + * + * @return $this + */ + public function setFrequency(callable $callback): self + { + $this->frequencyCallback = $callback; + + return $this; + } + + protected function assemble() + { + list($isValid, $reason) = ($this->validateCallback)(); + if (! $isValid) { + // Render why we can't generate the recurrences + $this->addHtml(Text::create($reason)); + + return; + } + + /** @var RRule $frequency */ + $frequency = ($this->frequencyCallback)(); + $recurrences = $frequency->getNextRecurrences(new DateTime(), 3); + if (! $recurrences->valid()) { + // Such a situation can be caused by setting an invalid end time + $this->addHtml(HtmlElement::create('p', null, Text::create($this->translate('Never')))); + + return; + } + + foreach ($recurrences as $recurrence) { + $this->addHtml(HtmlElement::create('p', null, $recurrence->format($this->translate('D, Y/m/d, H:i:s')))); + } + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('frequency', null, [$this, 'setFrequency']) + ->registerAttributeCallback('validate', null, [$this, 'setValid']); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php new file mode 100644 index 0000000..01933ca --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php @@ -0,0 +1,151 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; +use ipl\Validator\ValidatorChain; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; + +class WeeklyFields extends FieldsetElement +{ + use FieldsProtector; + + /** @var array A list of valid week days */ + protected $weekdays = []; + + /** @var string A valid weekday to be selected by default */ + protected $default = 'MO'; + + public function __construct($name, $attributes = null) + { + $this->weekdays = [ + 'MO' => $this->translate('Mon'), + 'TU' => $this->translate('Tue'), + 'WE' => $this->translate('Wed'), + 'TH' => $this->translate('Thu'), + 'FR' => $this->translate('Fri'), + 'SA' => $this->translate('Sat'), + 'SU' => $this->translate('Sun') + ]; + + parent::__construct($name, $attributes); + } + + /** + * Set the default weekday to be preselected + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + $weekday = strlen($default) > 2 ? substr($default, 0, -1) : $default; + if (! isset($this->weekdays[strtoupper($weekday)])) { + throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $default)); + } + + $this->default = strtoupper($weekday); + + return $this; + } + + /** + * Get all the selected weekdays + * + * @return array + */ + public function getSelectedWeekDays(): array + { + $selectedDays = []; + foreach ($this->weekdays as $day => $_) { + if ($this->getValue($day, 'n') === 'y') { + $selectedDays[] = $day; + } + } + + if (empty($selectedDays)) { + $selectedDays[] = $this->default; + } + + return $selectedDays; + } + + /** + * Transform the given weekdays into key=>value array that can be populated + * + * @param array $weekdays + * + * @return array + */ + public function loadWeekDays(array $weekdays): array + { + $values = []; + foreach ($this->weekdays as $weekday => $_) { + $values[$weekday] = in_array($weekday, $weekdays, true) ? 'y' : 'n'; + } + + return $values; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('weekly-fields')); + + $fieldsWrapper = HtmlElement::create('div', ['class' => 'weekly']); + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'multiple-fields']]); + + foreach ($this->weekdays as $day => $value) { + $checkbox = $this->createElement('checkbox', $day, [ + 'class' => ['autosubmit', 'sr-only'], + 'value' => $day === $this->default + ]); + $this->registerElement($checkbox); + + $htmlId = $this->protectId("weekday-$day"); + $checkbox->getAttributes()->set('id', $htmlId); + + $listItem = HtmlElement::create('li'); + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $value)); + $listItems->addHtml($listItem); + } + + $fieldsWrapper->addHtml($listItems); + $this->addHtml($fieldsWrapper); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new CallbackValidator(function ($_, CallbackValidator $validator): bool { + $valid = false; + foreach ($this->weekdays as $weekday => $_) { + if ($this->getValue($weekday) === 'y') { + $valid = true; + + break; + } + } + + if (! $valid) { + $validator->addMessage($this->translate('You must select at least one of these weekdays')); + } + + return $valid; + }) + ); + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput.php b/vendor/ipl/web/src/FormElement/TermInput.php new file mode 100644 index 0000000..352cce4 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput.php @@ -0,0 +1,450 @@ +<?php + +namespace ipl\Web\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Stdlib\Events; +use ipl\Web\FormElement\TermInput\RegisteredTerm; +use ipl\Web\FormElement\TermInput\TermContainer; +use ipl\Web\FormElement\TermInput\ValidatedTerm; +use ipl\Web\Url; +use Psr\Http\Message\ServerRequestInterface; + +class TermInput extends FieldsetElement +{ + use Events; + + /** @var string Emitted in case the user added new terms */ + const ON_ADD = 'on_add'; + + /** @var string Emitted in case the user inserted new terms */ + const ON_PASTE = 'on_paste'; + + /** @var string Emitted in case the user changed existing terms */ + const ON_SAVE = 'on_save'; + + /** @var string Emitted in case the user removed terms */ + const ON_REMOVE = 'on_remove'; + + /** @var string Emitted in case terms need to be enriched */ + const ON_ENRICH = 'on_enrich'; + + /** @var Url The suggestion url */ + protected $suggestionUrl; + + /** @var bool Whether term direction is vertical */ + protected $verticalTermDirection = false; + + /** @var array Changes to transmit to the client */ + protected $changes = []; + + /** @var RegisteredTerm[] The terms */ + protected $terms = []; + + /** @var bool Whether this input has been automatically submitted */ + private $hasBeenAutoSubmitted = false; + + /** @var bool Whether the term input value has been pasted */ + private $valueHasBeenPasted; + + /** @var TermContainer The term container */ + protected $termContainer; + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url): self + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set whether term direction should be vertical + * + * @param bool $state + * + * @return $this + */ + public function setVerticalTermDirection(bool $state = true): self + { + $this->verticalTermDirection = $state; + + return $this; + } + + /** + * Get the desired term direction + * + * @return ?string + */ + public function getTermDirection(): ?string + { + return $this->verticalTermDirection ? 'vertical' : null; + } + + /** + * Set terms + * + * @param RegisteredTerm ...$terms + * + * @return $this + */ + public function setTerms(RegisteredTerm ...$terms): self + { + $this->terms = $terms; + + return $this; + } + + /** + * Get the terms + * + * @return RegisteredTerm[] + */ + public function getTerms(): array + { + return $this->terms; + } + + public function getElements() + { + // TODO: Only a quick-fix. Remove once fieldsets are properly partially validated + $this->ensureAssembled(); + + return parent::getElements(); + } + + public function getValue($name = null, $default = null) + { + if ($name !== null) { + return parent::getValue($name, $default); + } + + $terms = []; + foreach ($this->getTerms() as $term) { + $terms[] = $term->render(','); + } + + return implode(',', $terms); + } + + public function setValue($value) + { + $recipients = $value; + if (is_array($value)) { + $recipients = $value['value'] ?? ''; + parent::setValue($value); + } + + $terms = []; + foreach ($this->parseValue($recipients) as $term) { + $terms[] = new RegisteredTerm($term); + } + + return $this->setTerms(...$terms); + } + + /** + * Parse the given separated string of terms + * + * @param string $value + * + * @return string[] + */ + public function parseValue(string $value): array + { + $terms = []; + + $term = ''; + $ignoreSeparator = false; + for ($i = 0; $i <= strlen($value); $i++) { + if (! isset($value[$i])) { + if (! empty($term)) { + $terms[] = rawurldecode($term); + } + + break; + } + + $c = $value[$i]; + if ($c === '"') { + $ignoreSeparator = ! $ignoreSeparator; + } elseif (! $ignoreSeparator && $c === ',') { + $terms[] = rawurldecode($term); + $term = ''; + } else { + $term .= $c; + } + } + + return $terms; + } + + /** + * Prepare updates to transmit for this input during multipart responses + * + * @param ServerRequestInterface $request + * + * @return array + */ + public function prepareMultipartUpdate(ServerRequestInterface $request): array + { + $updates = []; + if ($this->valueHasBeenPasted()) { + $updates[] = $this->termContainer(); + $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', []])), + 'Behavior:InputEnrichment' + ]; + } elseif (! empty($this->changes)) { + $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', $this->changes])), + 'Behavior:InputEnrichment' + ]; + } + + if (empty($updates) && $this->hasBeenAutoSubmitted()) { + $updates[] = $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', 'bogus'])), + 'Behavior:InputEnrichment' + ]; + } + + return $updates; + } + + /** + * Get whether this input has been automatically submitted + * + * @return bool + */ + private function hasBeenAutoSubmitted(): bool + { + return $this->hasBeenAutoSubmitted; + } + + /** + * Get whether the term input value has been pasted + * + * @return bool + */ + private function valueHasBeenPasted(): bool + { + if ($this->valueHasBeenPasted === null) { + $this->valueHasBeenPasted = ($this->getElement('data')->getValue()['type'] ?? null) === 'paste'; + } + + return $this->valueHasBeenPasted; + } + + public function onRegistered(Form $form) + { + $termContainerId = $this->getName() . '-terms'; + $mainInputId = $this->getName() . '-search-input'; + $autoSubmittedBy = $form->getRequest()->getHeader('X-Icinga-Autosubmittedby'); + + $this->hasBeenAutoSubmitted = in_array($mainInputId, $autoSubmittedBy, true) + || in_array($termContainerId, $autoSubmittedBy, true); + + parent::onRegistered($form); + } + + /** + * Validate the given terms + * + * @param string $type The type of change to validate + * @param array $terms The terms affected by the change + * @param array $changes Potential changes made by validators + * + * @return bool + */ + private function validateTerms(string $type, array $terms, array &$changes): bool + { + $validatedTerms = []; + foreach ($terms as $index => $data) { + $validatedTerms[$index] = ValidatedTerm::fromTermData($data); + } + + switch ($type) { + case 'submit': + case 'exchange': + $type = self::ON_ADD; + + break; + case 'paste': + $type = self::ON_PASTE; + + break; + case 'save': + $type = self::ON_SAVE; + + break; + case 'remove': + default: + return true; + } + + $this->emit($type, [$validatedTerms]); + + $invalid = false; + foreach ($validatedTerms as $index => $term) { + if (! $term->isValid()) { + $invalid = true; + } + + if (! $term->isValid() || $term->hasBeenChanged()) { + $changes[$index] = $term->toTermData(); + } + } + + return $invalid; + } + + /** + * Get the term container + * + * @return TermContainer + */ + protected function termContainer(): TermContainer + { + if ($this->termContainer === null) { + $this->termContainer = (new TermContainer($this)) + ->setAttribute('id', $this->getName() . '-terms'); + } + + return $this->termContainer; + } + + protected function assemble() + { + $myName = $this->getName(); + + $termInputId = $myName . '-term-input'; + $dataInputId = $myName . '-data-input'; + $searchInputId = $myName . '-search-input'; + $suggestionsId = $myName . '-suggestions'; + + $termContainer = $this->termContainer(); + + $suggestions = (new HtmlElement('div')) + ->setAttribute('id', $suggestionsId) + ->setAttribute('class', 'search-suggestions'); + + $termInput = $this->createElement('hidden', 'value', [ + 'id' => $termInputId, + 'disabled' => true + ]); + + $dataInput = new class ('data', [ + 'ignore' => true, + 'id' => $dataInputId, + 'validators' => ['callback' => function ($data) use ($termContainer) { + $changes = []; + $invalid = $this->validateTerms($data['type'], $data['terms'] ?? [], $changes); + $this->changes = $changes; + + $terms = $this->getTerms(); + foreach ($changes as $index => $termData) { + $terms[$index]->applyTermData($termData); + } + + return ! $invalid; + }] + ]) extends HiddenElement { + /** @var TermInput */ + private $parent; + + public function setParent(TermInput $parent): void + { + $this->parent = $parent; + } + + public function setValue($value) + { + $data = json_decode($value, true); + if (($data['type'] ?? null) === 'paste') { + array_push($data['terms'], ...array_map(function ($t) { + return ['search' => $t]; + }, $this->parent->parseValue($data['input']))); + } + + return parent::setValue($data); + } + + public function getValueAttribute() + { + return null; + } + }; + $dataInput->setParent($this); + + $label = $this->getLabel(); + $this->setLabel(null); + + // TODO: Separator customization + $mainInput = $this->createElement('text', 'value', [ + 'id' => $searchInputId, + 'label' => $label, + 'required' => $this->isRequired(), + 'placeholder' => $this->translate('Type to search. Separate multiple terms by comma.'), + 'class' => 'term-input', + 'autocomplete' => 'off', + 'data-term-separator' => ',', + 'data-enrichment-type' => 'terms', + 'data-with-multi-completion' => true, + 'data-no-auto-submit-on-remove' => true, + 'data-term-direction' => $this->getTermDirection(), + 'data-data-input' => '#' . $dataInputId, + 'data-term-input' => '#' . $termInputId, + 'data-term-container' => '#' . $termContainer->getAttribute('id')->getValue(), + 'data-term-suggestions' => '#' . $suggestionsId + ]); + $mainInput->getAttributes() + ->registerAttributeCallback('value', function () { + return null; + }); + if ($this->getSuggestionUrl() !== null) { + $mainInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + } + + $this->addElement($termInput); + $this->addElement($dataInput); + $this->addElement($mainInput); + + $mainInput->prependWrapper((new HtmlElement( + 'div', + Attributes::create(['class' => ['term-input-area', $this->getTermDirection()]]), + $termContainer, + new HtmlElement('label', null, $mainInput) + ))); + + $this->addHtml($suggestions); + + if (! $this->hasBeenAutoSubmitted()) { + $this->emit(self::ON_ENRICH, [$this->getTerms()]); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php new file mode 100644 index 0000000..dd79dd1 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php @@ -0,0 +1,144 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +class RegisteredTerm implements Term +{ + /** @var string The search value */ + protected $value; + + /** @var ?string The label */ + protected $label; + + /** @var ?string The CSS class */ + protected $class; + + /** @var string The failure message */ + protected $message; + + /** @var string The validation constraint */ + protected $pattern; + + /** + * Create a new RegisteredTerm + * + * @param string $value The search value + */ + public function __construct(string $value) + { + $this->setSearchValue($value); + } + + public function setSearchValue(string $value): self + { + $this->value = $value; + + return $this; + } + + public function getSearchValue(): string + { + return $this->value; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setClass(string $class): self + { + $this->class = $class; + + return $this; + } + + public function getClass(): ?string + { + return $this->class; + } + + public function setMessage(string $message): self + { + $this->message = $message; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setPattern(string $pattern): self + { + $this->pattern = $pattern; + + return $this; + } + + public function getPattern(): ?string + { + if ($this->message === null) { + return null; + } + + return $this->pattern ?? sprintf(Term::DEFAULT_CONSTRAINT, $this->getLabel() ?? $this->getSearchValue()); + } + + /** + * Render this term as a string + * + * Pass the separator being used to separate multiple terms. If the term's value contains it, + * the result will be automatically quoted. + * + * @param string $separator + * + * @return string + */ + public function render(string $separator): string + { + if (strpos($this->value, $separator) !== false) { + return '"' . $this->value . '"'; + } + + return $this->value; + } + + /** + * Apply the given term data to this term + * + * @param array $termData + * + * @return void + */ + public function applyTermData(array $termData): void + { + if (isset($termData['search'])) { + $this->value = $termData['search']; + } + + if (isset($termData['label'])) { + $this->setLabel($termData['label']); + } + + if (isset($termData['class'])) { + $this->setClass($termData['class']); + } + + if (isset($termData['invalidMsg'])) { + $this->setMessage($termData['invalidMsg']); + } + + if (isset($termData['pattern'])) { + $this->setPattern($termData['pattern']); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/Term.php b/vendor/ipl/web/src/FormElement/TermInput/Term.php new file mode 100644 index 0000000..be08e8a --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/Term.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +interface Term +{ + /** @var string The default validation constraint */ + public const DEFAULT_CONSTRAINT = '^\s*(?!%s\b).*\s*$'; + + /** + * Set the search value + * + * @param string $value + * + * @return $this + */ + public function setSearchValue(string $value); + + /** + * Get the search value + * + * @return string + */ + public function getSearchValue(): string; + + /** + * Set the label + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label); + + /** + * Get the label + * + * @return ?string + */ + public function getLabel(): ?string; + + /** + * Set the CSS class + * + * @param string $class + * + * @return $this + */ + public function setClass(string $class); + + /** + * Get the CSS class + * + * @return ?string + */ + public function getClass(): ?string; + + /** + * Set the failure message + * + * @param string $message + * + * @return $this + */ + public function setMessage(string $message); + + /** + * Get the failure message + * + * @return ?string + */ + public function getMessage(): ?string; + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern(string $pattern); + + /** + * Get the validation constraint + * + * @return ?string + */ + public function getPattern(): ?string; +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php new file mode 100644 index 0000000..c5a614c --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php @@ -0,0 +1,54 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\TermInput; + +class TermContainer extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'terms']; + + /** @var TermInput */ + protected $input; + + /** + * Create a new TermContainer + * + * @param TermInput $input + */ + public function __construct(TermInput $input) + { + $this->input = $input; + } + + protected function assemble() + { + foreach ($this->input->getTerms() as $i => $term) { + $label = $term->getLabel() ?: $term->getSearchValue(); + + $this->addHtml(new HtmlElement( + 'label', + Attributes::create([ + 'class' => $term->getClass(), + 'data-search' => $term->getSearchValue(), + 'data-label' => $label, + 'data-index' => $i + ]), + new HtmlElement( + 'input', + Attributes::create([ + 'type' => 'text', + 'value' => $label, + 'pattern' => $term->getPattern(), + 'data-invalid-msg' => $term->getMessage() + ]) + ) + )); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php new file mode 100644 index 0000000..26b00ea --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php @@ -0,0 +1,281 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use Psr\Http\Message\ServerRequestInterface; +use Traversable; + +use function ipl\Stdlib\yield_groups; + +class TermSuggestions extends BaseHtmlElement +{ + use Translation; + + protected $tag = 'ul'; + + /** @var Traversable */ + protected $provider; + + /** @var ?callable */ + protected $groupingCallback; + + /** @var ?string */ + protected $searchTerm; + + /** @var ?string */ + protected $searchPattern; + + /** @var ?string */ + protected $originalValue; + + /** @var string[] */ + protected $excludeTerms = []; + + /** + * Create new TermSuggestions + * + * The provider must deliver terms in form of arrays with the following keys: + * * (required) search: The search value + * * label: A human-readable label + * * class: A CSS class + * * title: A message shown upon hover on the term + * + * Any excess key is also transferred to the client, but currently unused. + * + * @param Traversable $provider + */ + public function __construct(Traversable $provider) + { + $this->provider = $provider; + } + + /** + * Set a callback to identify groups for terms delivered by the provider + * + * The callback must return a string which is used as label for the group. + * Its interface is: `function (array $data): string` + * + * @param callable $callback + * + * @return $this + */ + public function setGroupingCallback(callable $callback): self + { + $this->groupingCallback = $callback; + + return $this; + } + + /** + * Get the callback used to identify groups for terms delivered by the provider + * + * @return ?callable + */ + public function getGroupingCallback(): ?callable + { + return $this->groupingCallback; + } + + /** + * Set the search term (can contain `*` wildcards) + * + * @param string $term + * + * @return $this + */ + public function setSearchTerm(string $term): self + { + $this->searchTerm = $term; + $this->setSearchPattern( + '/' . str_replace( + '\\000', + '.*', + preg_quote( + str_replace( + '*', + "\0", + $term + ), + '/' + ) + ) . '/i' + ); + + return $this; + } + + /** + * Get the search term + * + * @return ?string + */ + public function getSearchTerm(): ?string + { + return $this->searchTerm; + } + + /** + * Set the search pattern used by {@see matchSearch} + * + * @param string $pattern + * + * @return $this + */ + protected function setSearchPattern(string $pattern): self + { + $this->searchPattern = $pattern; + + return $this; + } + + /** + * Set the original search value + * + * The one without automatically added wildcards. + * + * @param string $term + * + * @return $this + */ + public function setOriginalSearchValue(string $term): self + { + $this->originalValue = $term; + + return $this; + } + + /** + * Get the original search value + * + * @return ?string + */ + public function getOriginalSearchValue(): ?string + { + return $this->originalValue; + } + + /** + * Set the terms to exclude in the suggestion list + * + * @param string[] $terms + * + * @return $this + */ + public function setExcludeTerms(array $terms): self + { + $this->excludeTerms = $terms; + + return $this; + } + + /** + * Get the terms to exclude in the suggestion list + * + * @return string[] + */ + public function getExcludeTerms(): array + { + return $this->excludeTerms; + } + + /** + * Match the given search term against the users search + * + * @param string $term + * + * @return bool Whether the search matches or not + */ + public function matchSearch(string $term): bool + { + if (! $this->searchPattern || $this->searchPattern === '.*') { + return true; + } + + return (bool) preg_match($this->searchPattern, $term); + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request): self + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + /** @var array<string, array<int|string, string>> $requestData */ + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $this->setSearchTerm($requestData['term']['label']); + $this->setOriginalSearchValue($requestData['term']['search']); + $this->setExcludeTerms($requestData['exclude'] ?? []); + + return $this; + } + + protected function assemble() + { + $groupingCallback = $this->getGroupingCallback(); + if ($groupingCallback) { + $provider = yield_groups($this->provider, $groupingCallback); + } else { + $provider = [null => $this->provider]; + } + + /** @var iterable<?string, array<array<string, string>>> $provider */ + foreach ($provider as $group => $suggestions) { + if ($group) { + $this->addHtml( + new HtmlElement( + 'li', + Attributes::create([ + 'class' => 'suggestion-title' + ]), + Text::create($group) + ) + ); + } + + foreach ($suggestions as $data) { + $attributes = [ + 'type' => 'button', + 'value' => $data['label'] ?? $data['search'] + ]; + foreach ($data as $name => $value) { + $attributes["data-$name"] = $value; + } + + $this->addHtml( + new HtmlElement( + 'li', + null, + new HtmlElement( + 'input', + Attributes::create($attributes) + ) + ) + ); + } + } + + if ($this->isEmpty()) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create($this->translate('Nothing to suggest'))) + )); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php new file mode 100644 index 0000000..e91c203 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php @@ -0,0 +1,38 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use BadMethodCallException; + +class ValidatedTerm extends \ipl\Web\Control\SearchBar\ValidatedTerm implements Term +{ + const DEFAULT_PATTERN = Term::DEFAULT_CONSTRAINT; + + /** @var ?string The CSS class */ + protected $class; + + public function setClass(string $class): Term + { + $this->class = $class; + + return $this; + } + + public function getClass(): ?string + { + return $this->class; + } + + public function toTermData() + { + $data = parent::toTermData(); + $data['class'] = $this->getClass(); + + return $data; + } + + public function toMetaData() + { + throw new BadMethodCallException(self::class . '::toTermData() not implemented yet'); + } +} diff --git a/vendor/ipl/web/src/Layout/Content.php b/vendor/ipl/web/src/Layout/Content.php new file mode 100644 index 0000000..bded4ab --- /dev/null +++ b/vendor/ipl/web/src/Layout/Content.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for content + */ +class Content extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'content']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/Layout/Controls.php b/vendor/ipl/web/src/Layout/Controls.php new file mode 100644 index 0000000..8763775 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Controls.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\Tabs; + +/** + * Container for controls + */ +class Controls extends BaseHtmlElement +{ + /** @var Tabs */ + protected $tabs; + + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'controls']; + + protected $tag = 'div'; + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Set the tabs + * + * @param Tabs $tabs + * + * @return $this + */ + public function setTabs(Tabs $tabs) + { + $this->tabs = $tabs; + + return $this; + } + + public function isEmpty() + { + if (! parent::isEmpty()) { + return false; + } + + return $this->tabs->count() === 0; + } + + protected function assemble() + { + $this->prepend($this->getTabs()); + } +} diff --git a/vendor/ipl/web/src/Layout/Footer.php b/vendor/ipl/web/src/Layout/Footer.php new file mode 100644 index 0000000..21bf262 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Footer.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for footer + */ +class Footer extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'footer']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/LessRuleset.php b/vendor/ipl/web/src/LessRuleset.php new file mode 100644 index 0000000..2e30a4b --- /dev/null +++ b/vendor/ipl/web/src/LessRuleset.php @@ -0,0 +1,177 @@ +<?php + +namespace ipl\Web; + +use ArrayObject; +use Less_Parser; + +/** + * @extends ArrayObject<string, string> + */ +class LessRuleset extends ArrayObject +{ + /** @var ?string */ + protected $selector; + + /** @var array<LessRuleset> */ + protected $children = []; + + /** + * Create a new LessRuleset + * + * @param string $selector Selector to use + * @param array<string, string> $properties CSS properties + * + * @return self + */ + public static function create(string $selector, array $properties): self + { + $ruleset = new static(); + $ruleset->selector = $selector; + $ruleset->exchangeArray($properties); + + return $ruleset; + } + + /** + * Get the selector + * + * @return ?string + */ + public function getSelector(): ?string + { + return $this->selector; + } + + /** + * Set the selector + * + * @param string $selector + * + * @return $this + */ + public function setSelector(string $selector): self + { + $this->selector = $selector; + + return $this; + } + + /** + * Get a property value + * + * @param string $property Name of the property + * + * @return string + */ + public function getProperty(string $property): string + { + return (string) $this[$property]; + } + + /** + * Set a property + * + * @param string $property Name to use + * @param string $value Value to set + * + * @return $this + */ + public function setProperty(string $property, string $value): self + { + $this[$property] = $value; + + return $this; + } + + /** + * Get all properties + * + * @return array<string, string> + */ + public function getProperties(): array + { + return $this->getArrayCopy(); + } + + /** + * Set properties + * + * @param array<string, string> $properties + * + * @return $this + */ + public function setProperties(array $properties): self + { + $this->exchangeArray($properties); + + return $this; + } + + /** + * Create and add a ruleset + * + * @param string $selector Selector to use + * @param array<string, string> $properties CSS properties + * + * @return $this + */ + public function add(string $selector, array $properties): self + { + $this->children[] = static::create($selector, $properties); + + return $this; + } + + /** + * Add a ruleset + * + * @param LessRuleset $ruleset + * + * @return $this + */ + public function addRuleset(LessRuleset $ruleset): self + { + $this->children[] = $ruleset; + + return $this; + } + + /** + * Compile the ruleset to CSS + * + * @return string + */ + public function renderCss(): string + { + $parser = new Less_Parser(['compress' => true]); + $parser->parse($this->renderLess()); + + return $parser->getCss(); + } + + /** + * Render the ruleset to LESS + * + * @return string + */ + protected function renderLess(): string + { + $less = []; + + foreach ($this as $property => $value) { + $less[] = "$property: $value;"; + } + + foreach ($this->children as $ruleset) { + $less[] = $ruleset->renderLess(); + } + + if ($this->selector !== null) { + array_unshift($less, "$this->selector {"); + $less[] = '}'; + } + + return implode("\n", $less); + } +} diff --git a/vendor/ipl/web/src/Style.php b/vendor/ipl/web/src/Style.php new file mode 100644 index 0000000..56479d0 --- /dev/null +++ b/vendor/ipl/web/src/Style.php @@ -0,0 +1,123 @@ +<?php + +namespace ipl\Web; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; +use Throwable; + +class Style extends LessRuleset implements ValidHtml +{ + /** @var ?string */ + protected $module; + + /** @var ?string */ + protected $nonce; + + /** + * Get the used CSP nonce + * + * @return ?string + */ + public function getNonce(): ?string + { + return $this->nonce; + } + + /** + * Set the CSP nonce to use + * + * @param ?string $nonce + * + * @return $this + */ + public function setNonce(?string $nonce): self + { + $this->nonce = $nonce; + + return $this; + } + + /** + * Get the Icinga module name the ruleset is scoped to + * + * @return ?string + */ + public function getModule(): ?string + { + return $this->module; + } + + /** + * Set the Icinga module name to use as scope for the ruleset + * + * @param ?string $name + * + * @return $this + */ + public function setModule(?string $name): self + { + $this->module = $name; + + return $this; + } + + /** + * Add CSS properties for the given element + * + * The created ruleset will be applied by an `#ID` selector. If the given + * element does not have an ID set yet, one is automatically set. + * + * @param BaseHtmlElement $element Element to apply the properties to + * @param array<string, string> $properties CSS properties + * + * @return $this + */ + public function addFor(BaseHtmlElement $element, array $properties): self + { + /** @var ?string $id */ + $id = $element->getAttribute('id')->getValue(); + + if ($id === null) { + $id = uniqid('csp-style', false); + $element->setAttribute('id', $id); + } + + return $this->add('#' . $id, $properties); + } + + public function render(): string + { + if ($this->module !== null) { + $ruleset = (new static()) + ->setSelector(".icinga-module.module-$this->module") + ->addRuleset($this); + } else { + $ruleset = $this; + } + + return (new HtmlElement( + 'style', + (new Attributes())->addAttribute(new Attribute('nonce', $this->getNonce())), + HtmlString::create($ruleset->renderCss()) + ))->render(); + } + + /** + * Render to HTML + * + * @return string + */ + public function __toString(): string + { + try { + return $this->render(); + } catch (Throwable $e) { + return sprintf('<!-- Failed to render style: %s -->', $e->getMessage()); + } + } +} diff --git a/vendor/ipl/web/src/Url.php b/vendor/ipl/web/src/Url.php new file mode 100644 index 0000000..adb96cd --- /dev/null +++ b/vendor/ipl/web/src/Url.php @@ -0,0 +1,71 @@ +<?php + +namespace ipl\Web; + +use Icinga\Web\UrlParams; +use ipl\Stdlib\Filter\Rule; +use ipl\Web\Filter\QueryString; + +/** + * @TODO(el): Don't depend on Icinga Web's Url + */ +class Url extends \Icinga\Web\Url +{ + /** @var ?Rule */ + private $filter; + + /** + * Set the filter + * + * @param ?Rule $filter + * + * @return $this + */ + public function setFilter(?Rule $filter): self + { + $this->filter = $filter; + + return $this; + } + + /** + * Get the filter + * + * @return ?Rule + */ + public function getFilter(): ?Rule + { + return $this->filter; + } + + /** + * Render and return the filter and parameters as query string + * + * @param ?string $separator + * + * @return string + */ + public function getQueryString($separator = null) + { + if ($this->filter === null) { + return parent::getQueryString($separator); + } + + $params = UrlParams::fromQueryString(QueryString::render($this->filter)); + foreach ($this->getParams()->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } + + $params->addEncoded($name, $value); + } + + return $params->toString($separator); + } + + public function __toString() + { + return $this->getAbsoluteUrl('&'); + } +} diff --git a/vendor/ipl/web/src/Widget/ActionBar.php b/vendor/ipl/web/src/Widget/ActionBar.php new file mode 100644 index 0000000..bf31845 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionBar.php @@ -0,0 +1,51 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Action bar element for displaying a list of links + */ +class ActionBar extends BaseHtmlElement +{ + use BaseTarget; + + protected $contentSeparator = ' '; + + protected $defaultAttributes = [ + 'class' => 'action-bar', + 'data-base-target' => '_self' + ]; + + protected $tag = 'div'; + + /** + * Create a action bar + * + * @param Attributes|array $attributes + */ + public function __construct($attributes = null) + { + $this->getAttributes()->add($attributes); + } + + /** + * Add a link to the action bar + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->add(new ActionLink($content, $url, $icon)); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/ActionLink.php b/vendor/ipl/web/src/Widget/ActionLink.php new file mode 100644 index 0000000..289d700 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionLink.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Web\Url; + +/** + * Link generally pointing to CRUD actions + */ +class ActionLink extends Link +{ + protected $defaultAttributes = ['class' => 'action-link']; + + /** + * Create a action link + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $icon = null, $attributes = null) + { + parent::__construct($content, $url, $attributes); + + if ($icon !== null) { + $this->prepend(new Icon($icon)); + } + } +} diff --git a/vendor/ipl/web/src/Widget/ButtonLink.php b/vendor/ipl/web/src/Widget/ButtonLink.php new file mode 100644 index 0000000..2da5dfd --- /dev/null +++ b/vendor/ipl/web/src/Widget/ButtonLink.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Widget; + +/** + * Button like link generally pointing to CRUD actions + */ +class ButtonLink extends ActionLink +{ + protected $defaultAttributes = [ + 'class' => 'button-link', + 'data-base-target' => '_main' + ]; +} diff --git a/vendor/ipl/web/src/Widget/ContinueWith.php b/vendor/ipl/web/src/Widget/ContinueWith.php new file mode 100644 index 0000000..1479e9a --- /dev/null +++ b/vendor/ipl/web/src/Widget/ContinueWith.php @@ -0,0 +1,72 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; + +class ContinueWith extends BaseHtmlElement +{ + use BaseTarget; + + protected $tag = 'span'; + + protected $defaultAttributes = ['class' => 'continue-with']; + + /** @var Url */ + protected $url; + + /** @var Filter\Rule|callable */ + protected $filter; + + /** @var string */ + protected $title; + + public function __construct(Url $url, $filter) + { + $this->url = $url; + $this->filter = $filter; + } + + /** + * Set title for the anchor + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + + return $this; + } + + public function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); /** @var Filter\Rule $filter */ + } + + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $this->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => ['control-button', 'disabled']]), + new Icon('share') + )); + } else { + $this->addHtml(new ActionLink( + null, + $this->url->setFilter($filter), + 'share', + ['class' => 'control-button', 'title' => $this->title] + )); + } + } +} diff --git a/vendor/ipl/web/src/Widget/CopyToClipboard.php b/vendor/ipl/web/src/Widget/CopyToClipboard.php new file mode 100644 index 0000000..28e9347 --- /dev/null +++ b/vendor/ipl/web/src/Widget/CopyToClipboard.php @@ -0,0 +1,64 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\I18n\Translation; + +/** + * Copy to clipboard button + */ +class CopyToClipboard extends BaseHtmlElement +{ + use Translation; + + protected $tag = 'button'; + + protected $defaultAttributes = ['type' => 'button']; + + /** + * Create a copy to clipboard button + * + * Creates a copy to clipboard button, which when clicked copies the text from the html element identified as + * clipboard source that the clipboard button attaches itself to. + */ + private function __construct() + { + $this->addAttributes( + [ + 'class' => 'copy-to-clipboard', + 'data-icinga-clipboard' => true, + 'tabindex' => -1, + 'data-copied-label' => $this->translate('Copied'), + 'title' => $this->translate('Copy to clipboard'), + ] + ); + } + + /** + * Attach the copy to clipboard button to the given Html source element + * + * @param BaseHtmlElement $source + * + * @return void + */ + public static function attachTo(BaseHtmlElement $source): void + { + $clipboardWrapper = new HtmlElement( + 'div', + Attributes::create(['class' => 'clipboard-wrapper']) + ); + + $clipboardWrapper->addHtml(new static()); + + $source->addAttributes(['data-clipboard-source' => true]); + $source->prependWrapper($clipboardWrapper); + } + + public function assemble(): void + { + $this->setHtmlContent(new Icon('clone')); + } +} diff --git a/vendor/ipl/web/src/Widget/Dropdown.php b/vendor/ipl/web/src/Widget/Dropdown.php new file mode 100644 index 0000000..b6eb20d --- /dev/null +++ b/vendor/ipl/web/src/Widget/Dropdown.php @@ -0,0 +1,63 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url; + +/** + * Toggleable overlay dropdown element for displaying a list of links + */ +class Dropdown extends BaseHtmlElement +{ + /** @var array */ + protected $links = []; + + protected $defaultAttributes = ['class' => 'dropdown']; + + protected $tag = 'div'; + + /** + * Create a dropdown element + * + * @param mixed $content + * @param Attributes|array $attributes + */ + public function __construct($content, $attributes = null) + { + $toggle = new ActionLink($content, '#', null, [ + 'aria-expanded' => false, + 'aria-haspopup' => true, + 'class' => 'dropdown-toggle', + 'role' => 'button' + ]); + + $this + ->setContent($toggle) + ->getAttributes() + ->add($attributes); + } + + /** + * Add a link to the dropdown + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->links[] = new ActionLink($content, $url, $icon, ['class' => 'dropdown-item']); + + return $this; + } + + protected function assemble() + { + $this->add(Html::tag('div', ['class' => 'dropdown-menu'], $this->links)); + } +} diff --git a/vendor/ipl/web/src/Widget/EmptyState.php b/vendor/ipl/web/src/Widget/EmptyState.php new file mode 100644 index 0000000..5a055ac --- /dev/null +++ b/vendor/ipl/web/src/Widget/EmptyState.php @@ -0,0 +1,30 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +class EmptyState extends BaseHtmlElement +{ + /** @var mixed Content */ + protected $content; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'empty-state']; + + /** + * Create an empty state + * + * @param mixed $content + */ + public function __construct($content) + { + $this->content = $content; + } + + protected function assemble(): void + { + $this->add($this->content); + } +} diff --git a/vendor/ipl/web/src/Widget/EmptyStateBar.php b/vendor/ipl/web/src/Widget/EmptyStateBar.php new file mode 100644 index 0000000..2d04837 --- /dev/null +++ b/vendor/ipl/web/src/Widget/EmptyStateBar.php @@ -0,0 +1,30 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +class EmptyStateBar extends BaseHtmlElement +{ + /** @var mixed Content */ + protected $content; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'empty-state-bar']; + + /** + * Create an empty list + * + * @param mixed $content + */ + public function __construct($content) + { + $this->content = $content; + } + + protected function assemble(): void + { + $this->add($this->content); + } +} diff --git a/vendor/ipl/web/src/Widget/HorizontalKeyValue.php b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php new file mode 100644 index 0000000..1d1195e --- /dev/null +++ b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class HorizontalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $defaultAttributes = ['class' => 'horizontal-key-value']; + + protected $tag = 'div'; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('div', ['class' => 'key'], $this->key), + Html::tag('div', ['class' => 'value'], $this->value) + ]); + } +} diff --git a/vendor/ipl/web/src/Widget/IcingaIcon.php b/vendor/ipl/web/src/Widget/IcingaIcon.php new file mode 100644 index 0000000..1161fc6 --- /dev/null +++ b/vendor/ipl/web/src/Widget/IcingaIcon.php @@ -0,0 +1,28 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; + +class IcingaIcon extends Icon +{ + protected $style = ''; + + /** + * Create an icon element + * + * Creates an icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'iicon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct(string $name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', "iicon-$name"]) + ->add($attributes); + } +} diff --git a/vendor/ipl/web/src/Widget/Icon.php b/vendor/ipl/web/src/Widget/Icon.php new file mode 100644 index 0000000..5c2617f --- /dev/null +++ b/vendor/ipl/web/src/Widget/Icon.php @@ -0,0 +1,67 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; + +/** + * Icon element + */ +class Icon extends BaseHtmlElement +{ + protected $tag = 'i'; + + /** @var string Icon style */ + protected $style; + + /** @var string Icon default style */ + protected $defaultStyle = 'fa'; + + /** + * Create an icon element + * + * Creates an icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'icon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct(string $name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', "fa-$name"]) + ->add($attributes); + } + + /** + * Get the icon style + * + * @return string + */ + public function getStyle(): string + { + return $this->style ?? $this->defaultStyle; + } + + /** + * Set the icon style + * + * @param string $style Style class with prefix + * + * @return $this + */ + public function setStyle(string $style): self + { + $this->style = $style; + + return $this; + } + + protected function assemble() + { + $this->addAttributes(['class' => $this->getStyle()]); + } +} diff --git a/vendor/ipl/web/src/Widget/Link.php b/vendor/ipl/web/src/Widget/Link.php new file mode 100644 index 0000000..cbae3b9 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Link.php @@ -0,0 +1,97 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Link element, i.e. <a href="... + */ +class Link extends BaseHtmlElement +{ + use BaseTarget; + + /** @var Url */ + protected $url; + + protected $tag = 'a'; + + /** + * Create a link element + * + * @param mixed $content + * @param Url|string $url + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $attributes = null) + { + $this + ->setContent($content) + ->setUrl($url) + ->getAttributes() + ->add($attributes) + ->registerAttributeCallback('href', [$this, 'createHrefAttribute']); + } + + /** + * Get the URL of the link + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the URL of the link + * + * @param Url|string $url + * + * @return $this + */ + public function setUrl($url) + { + if (! $url instanceof Url) { + try { + $url = Url::fromPath($url); + } catch (\Exception $e) { + $url = 'invalid'; + } + } + + $this->url = $url; + + return $this; + } + + /** + * Create and return the href attribute + * + * Used as attribute callback for the href attribute. + * + * @return Attribute + */ + public function createHrefAttribute() + { + return new Attribute('href', (string) $this->getUrl()); + } + + /** + * Open this link in a modal + * + * @return $this + */ + public function openInModal(): self + { + $this->getAttributes() + ->set('data-icinga-modal', true) + ->set('data-no-icinga-ajax', true); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/StateBadge.php b/vendor/ipl/web/src/Widget/StateBadge.php new file mode 100644 index 0000000..908a348 --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBadge.php @@ -0,0 +1,47 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +class StateBadge extends BaseHtmlElement +{ + protected $defaultAttributes = ['class' => 'state-badge']; + + /** @var mixed Badge content */ + protected $content; + + /** @var bool Whether the state is handled */ + protected $isHandled; + + /** @var string Textual representation of a state */ + protected $state; + + /** + * Create a new state badge + * + * @param mixed $content Content of the badge + * @param string $state Textual representation of a state + * @param bool $isHandled True if state is handled + */ + public function __construct($content, string $state, bool $isHandled = false) + { + $this->content = $content; + $this->isHandled = $isHandled; + $this->state = $state; + } + + protected function assemble() + { + $this->setTag('span'); + + $class = "state-{$this->state}"; + if ($this->isHandled) { + $class .= ' handled'; + } + + $this->addAttributes(['class' => $class]); + + $this->add($this->content); + } +} diff --git a/vendor/ipl/web/src/Widget/StateBall.php b/vendor/ipl/web/src/Widget/StateBall.php new file mode 100644 index 0000000..5a1216d --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBall.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +/** + * State ball element that supports different sizes and colors + */ +class StateBall extends BaseHtmlElement +{ + const SIZE_TINY = 'xs'; + const SIZE_SMALL = 's'; + const SIZE_MEDIUM = 'm'; + const SIZE_MEDIUM_LARGE = 'ml'; + const SIZE_BIG = 'l'; + const SIZE_LARGE = 'xl'; + + protected $tag = 'span'; + + /** + * Create a new state ball element + * + * @param string $state + * @param string $size + */ + public function __construct($state = 'none', $size = self::SIZE_SMALL) + { + $state = trim($state); + + if (empty($state)) { + $state = 'none'; + } + + $size = trim($size); + + if (empty($size)) { + $size = self::SIZE_MEDIUM; + } + + $this->defaultAttributes = ['class' => "state-ball state-$state ball-size-$size"]; + } +} diff --git a/vendor/ipl/web/src/Widget/Tabs.php b/vendor/ipl/web/src/Widget/Tabs.php new file mode 100644 index 0000000..32ba8e9 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Tabs.php @@ -0,0 +1,190 @@ +<?php + +namespace ipl\Web\Widget; + +use Exception; +use Icinga\Web\Widget\Tabextension\DashboardAction; +use Icinga\Web\Widget\Tabextension\MenuAction; +use Icinga\Web\Widget\Tabextension\OutputFormat; +use Icinga\Web\Widget\Tabextension\Tabextension; +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlString; +use ipl\Web\Url; + +/** + * @TODO(el): Don't depend on Icinga Web's Tabs + */ +class Tabs extends BaseHtmlElement +{ + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'tabs primary-nav nav']; + + /** @var \Icinga\Web\Widget\Tabs */ + protected $tabs; + + /** @var bool Whether data exports are enabled */ + protected $dataExportsEnabled = false; + + /** @var bool Whether the legacy extensions should be shown by default */ + protected $legacyExtensionsEnabled = true; + + /** @var Url */ + protected $refreshUrl; + + public function __construct() + { + $this->tabs = new \Icinga\Web\Widget\Tabs(); + } + + /** + * Don't show legacy extensions by default + */ + public function disableLegacyExtensions() + { + $this->legacyExtensionsEnabled = false; + } + + /** + * Show export actions for JSON and CSV + */ + public function enableDataExports() + { + $this->dataExportsEnabled = true; + } + + /** + * Set the url for the refresh button + * + * @param Url $url + * + * @return $this + */ + public function setRefreshUrl(Url $url) + { + $this->refreshUrl = $url; + + return $this; + } + + protected function assemble() + { + if ($this->legacyExtensionsEnabled) { + $this->tabs->extend(new OutputFormat( + $this->dataExportsEnabled + ? [] + : [OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON] + )) + ->extend(new DashboardAction()) + ->extend(new MenuAction()); + } + + $tabHtml = substr($this->tabs->render(), 34, -5); + if ($this->refreshUrl !== null) { + $tabHtml = preg_replace( + '/(?<=class="refresh-container-control spinner" href=")([^"]*)/', + $this->refreshUrl->getAbsoluteUrl(), + $tabHtml + ); + } + + parent::add(HtmlString::create($tabHtml)); + } + + /** + * Activate the tab with the given name + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function activate($name) + { + try { + $this->tabs->activate($name); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + return $this; + } + + /** + * Get active tab + * + * @return \Icinga\Web\Widget\Tab + */ + public function getActiveTab() + { + return $this->tabs->get($this->tabs->getActiveName()); + } + + /** + * Add the given tab + * + * @param string $name + * @param mixed $tab + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function add($name, $tab = null) + { + if ($tab === null) { + throw new InvalidArgumentException('Argument $tab is required'); + } + + try { + $this->tabs->add($name, $tab); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + if (is_array($tab) && isset($tab['active']) && $tab['active']) { + // Otherwise Tabs::getActiveName() returns null + $this->tabs->activate($name); + } + + return $this; + } + + /** + * Get a tab + * + * @param string $name + * + * @return \Icinga\Web\Widget\Tab|null + */ + public function get($name) + { + return $this->tabs->get($name); + } + + /** + * Count tabs + * + * @return int + */ + public function count(): int + { + return $this->tabs->count(); + } + + /** + * Apply a Tabextension on $this->tabs object not on this class + * + * @param Tabextension $extension + * + * @return $this + */ + public function extend(Tabextension $extension) + { + $this->tabs->extend($extension); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/TimeAgo.php b/vendor/ipl/web/src/Widget/TimeAgo.php new file mode 100644 index 0000000..cbd0dad --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeAgo.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeAgo extends BaseHtmlElement +{ + /** @var int */ + protected $ago; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-ago']; + + public function __construct($ago) + { + $this->ago = (int) $ago; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->ago); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeAgo($this->ago)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeSince.php b/vendor/ipl/web/src/Widget/TimeSince.php new file mode 100644 index 0000000..308e358 --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeSince.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeSince extends BaseHtmlElement +{ + /** @var int */ + protected $since; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-since']; + + public function __construct($since) + { + $this->since = (int) $since; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->since); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeSince($this->since)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeUntil.php b/vendor/ipl/web/src/Widget/TimeUntil.php new file mode 100644 index 0000000..f16731a --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeUntil.php @@ -0,0 +1,34 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeUntil extends BaseHtmlElement +{ + /** @var int */ + protected $until; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-until']; + + public function __construct($until) + { + $this->until = (int) $until; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->until); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime, + 'data-ago-label' => DateFormatter::timeAgo(time()) + ]); + + $this->add(DateFormatter::timeUntil($this->until)); + } +} diff --git a/vendor/ipl/web/src/Widget/VerticalKeyValue.php b/vendor/ipl/web/src/Widget/VerticalKeyValue.php new file mode 100644 index 0000000..388c740 --- /dev/null +++ b/vendor/ipl/web/src/Widget/VerticalKeyValue.php @@ -0,0 +1,32 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class VerticalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'vertical-key-value']; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('span', ['class' => 'value'], $this->value), + Html::tag('br'), + Html::tag('span', ['class' => 'key'], $this->key), + ]); + } +} |