summaryrefslogtreecommitdiffstats
path: root/vendor/ipl
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/html/LICENSE21
-rw-r--r--vendor/ipl/html/composer.json33
-rw-r--r--vendor/ipl/html/src/Attribute.php328
-rw-r--r--vendor/ipl/html/src/Attributes.php518
-rw-r--r--vendor/ipl/html/src/BaseHtmlElement.php406
-rw-r--r--vendor/ipl/html/src/Common/MultipleAttribute.php70
-rw-r--r--vendor/ipl/html/src/Contract/FormElement.php132
-rw-r--r--vendor/ipl/html/src/Contract/FormElementDecorator.php40
-rw-r--r--vendor/ipl/html/src/Contract/FormSubmitElement.php13
-rw-r--r--vendor/ipl/html/src/Contract/ValueCandidates.php22
-rw-r--r--vendor/ipl/html/src/Contract/Wrappable.php45
-rw-r--r--vendor/ipl/html/src/DeferredText.php114
-rw-r--r--vendor/ipl/html/src/Error.php114
-rw-r--r--vendor/ipl/html/src/Form.php402
-rw-r--r--vendor/ipl/html/src/FormDecorator/CallbackDecorator.php41
-rw-r--r--vendor/ipl/html/src/FormDecorator/DdDtDecorator.php140
-rw-r--r--vendor/ipl/html/src/FormDecorator/DecoratorInterface.php19
-rw-r--r--vendor/ipl/html/src/FormDecorator/DivDecorator.php156
-rw-r--r--vendor/ipl/html/src/FormElement/BaseFormElement.php390
-rw-r--r--vendor/ipl/html/src/FormElement/ButtonElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/CheckboxElement.php124
-rw-r--r--vendor/ipl/html/src/FormElement/ColorElement.php16
-rw-r--r--vendor/ipl/html/src/FormElement/DateElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/FieldsetElement.php122
-rw-r--r--vendor/ipl/html/src/FormElement/FileElement.php414
-rw-r--r--vendor/ipl/html/src/FormElement/FormElements.php509
-rw-r--r--vendor/ipl/html/src/FormElement/HiddenElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/InputElement.php49
-rw-r--r--vendor/ipl/html/src/FormElement/LocalDateTimeElement.php53
-rw-r--r--vendor/ipl/html/src/FormElement/NumberElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/PasswordElement.php55
-rw-r--r--vendor/ipl/html/src/FormElement/RadioElement.php177
-rw-r--r--vendor/ipl/html/src/FormElement/RadioOption.php148
-rw-r--r--vendor/ipl/html/src/FormElement/SelectElement.php238
-rw-r--r--vendor/ipl/html/src/FormElement/SelectOption.php79
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitButtonElement.php65
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitElement.php50
-rw-r--r--vendor/ipl/html/src/FormElement/TextElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/TextareaElement.php24
-rw-r--r--vendor/ipl/html/src/FormElement/TimeElement.php8
-rw-r--r--vendor/ipl/html/src/FormattedString.php101
-rw-r--r--vendor/ipl/html/src/Html.php241
-rw-r--r--vendor/ipl/html/src/HtmlDocument.php607
-rw-r--r--vendor/ipl/html/src/HtmlElement.php43
-rw-r--r--vendor/ipl/html/src/HtmlString.php13
-rw-r--r--vendor/ipl/html/src/Table.php226
-rw-r--r--vendor/ipl/html/src/TemplateString.php175
-rw-r--r--vendor/ipl/html/src/Text.php116
-rw-r--r--vendor/ipl/html/src/ValidHtml.php17
-rw-r--r--vendor/ipl/i18n/LICENSE21
-rw-r--r--vendor/ipl/i18n/composer.json28
-rw-r--r--vendor/ipl/i18n/src/GettextTranslator.php353
-rw-r--r--vendor/ipl/i18n/src/Locale.php127
-rw-r--r--vendor/ipl/i18n/src/NoopTranslator.php31
-rw-r--r--vendor/ipl/i18n/src/StaticTranslator.php14
-rw-r--r--vendor/ipl/i18n/src/Translation.php101
-rw-r--r--vendor/ipl/i18n/src/functions.php34
-rw-r--r--vendor/ipl/i18n/src/functions_include.php6
-rw-r--r--vendor/ipl/orm/LICENSE21
-rw-r--r--vendor/ipl/orm/composer.json34
-rw-r--r--vendor/ipl/orm/src/AliasedExpression.php36
-rw-r--r--vendor/ipl/orm/src/Behavior.php12
-rw-r--r--vendor/ipl/orm/src/Behavior/Binary.php101
-rw-r--r--vendor/ipl/orm/src/Behavior/BoolCast.php147
-rw-r--r--vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php41
-rw-r--r--vendor/ipl/orm/src/Behaviors.php238
-rw-r--r--vendor/ipl/orm/src/ColumnDefinition.php80
-rw-r--r--vendor/ipl/orm/src/Common/PropertiesWithDefaults.php31
-rw-r--r--vendor/ipl/orm/src/Common/SortUtil.php65
-rw-r--r--vendor/ipl/orm/src/Compat/FilterProcessor.php375
-rw-r--r--vendor/ipl/orm/src/Contract/PersistBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/PropertyBehavior.php102
-rw-r--r--vendor/ipl/orm/src/Contract/QueryAwareBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/RetrieveBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php39
-rw-r--r--vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php25
-rw-r--r--vendor/ipl/orm/src/Contract/RewritePathBehavior.php20
-rw-r--r--vendor/ipl/orm/src/Defaults.php52
-rw-r--r--vendor/ipl/orm/src/Exception/InvalidColumnException.php53
-rw-r--r--vendor/ipl/orm/src/Exception/InvalidRelationException.php53
-rw-r--r--vendor/ipl/orm/src/Exception/ValueConversionException.php12
-rw-r--r--vendor/ipl/orm/src/Hydrator.php197
-rw-r--r--vendor/ipl/orm/src/Model.php143
-rw-r--r--vendor/ipl/orm/src/Query.php846
-rw-r--r--vendor/ipl/orm/src/Relation.php336
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsTo.php13
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsToMany.php211
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsToOne.php13
-rw-r--r--vendor/ipl/orm/src/Relation/HasMany.php13
-rw-r--r--vendor/ipl/orm/src/Relation/HasOne.php12
-rw-r--r--vendor/ipl/orm/src/Relation/Junction.php43
-rw-r--r--vendor/ipl/orm/src/Relations.php235
-rw-r--r--vendor/ipl/orm/src/ResolvedExpression.php49
-rw-r--r--vendor/ipl/orm/src/Resolver.php803
-rw-r--r--vendor/ipl/orm/src/ResultSet.php146
-rw-r--r--vendor/ipl/orm/src/UnionModel.php29
-rw-r--r--vendor/ipl/orm/src/UnionQuery.php61
-rw-r--r--vendor/ipl/scheduler/composer.json39
-rw-r--r--vendor/ipl/scheduler/src/Common/Promises.php108
-rw-r--r--vendor/ipl/scheduler/src/Common/TaskProperties.php83
-rw-r--r--vendor/ipl/scheduler/src/Common/Timers.php60
-rw-r--r--vendor/ipl/scheduler/src/Contract/Frequency.php62
-rw-r--r--vendor/ipl/scheduler/src/Contract/Task.php39
-rw-r--r--vendor/ipl/scheduler/src/Cron.php203
-rw-r--r--vendor/ipl/scheduler/src/OneOff.php69
-rw-r--r--vendor/ipl/scheduler/src/RRule.php328
-rw-r--r--vendor/ipl/scheduler/src/Scheduler.php323
-rw-r--r--vendor/ipl/scheduler/src/register_cron_aliases.php11
-rw-r--r--vendor/ipl/sql/LICENSE21
-rw-r--r--vendor/ipl/sql/composer.json29
-rw-r--r--vendor/ipl/sql/src/Adapter/BaseAdapter.php120
-rw-r--r--vendor/ipl/sql/src/Adapter/Mssql.php80
-rw-r--r--vendor/ipl/sql/src/Adapter/Mysql.php57
-rw-r--r--vendor/ipl/sql/src/Adapter/Oracle.php39
-rw-r--r--vendor/ipl/sql/src/Adapter/Pgsql.php15
-rw-r--r--vendor/ipl/sql/src/Adapter/Sqlite.php13
-rw-r--r--vendor/ipl/sql/src/CommonTableExpression.php53
-rw-r--r--vendor/ipl/sql/src/CommonTableExpressionInterface.php39
-rw-r--r--vendor/ipl/sql/src/Compat/FilterProcessor.php127
-rw-r--r--vendor/ipl/sql/src/Config.php99
-rw-r--r--vendor/ipl/sql/src/Connection.php554
-rw-r--r--vendor/ipl/sql/src/Contract/Adapter.php46
-rw-r--r--vendor/ipl/sql/src/Contract/Quoter.php21
-rw-r--r--vendor/ipl/sql/src/Cursor.php106
-rw-r--r--vendor/ipl/sql/src/Delete.php52
-rw-r--r--vendor/ipl/sql/src/Expression.php54
-rw-r--r--vendor/ipl/sql/src/ExpressionInterface.php39
-rw-r--r--vendor/ipl/sql/src/Filter/Exists.php14
-rw-r--r--vendor/ipl/sql/src/Filter/In.php24
-rw-r--r--vendor/ipl/sql/src/Filter/InAndNotInUtils.php62
-rw-r--r--vendor/ipl/sql/src/Filter/NotExists.php14
-rw-r--r--vendor/ipl/sql/src/Filter/NotIn.php24
-rw-r--r--vendor/ipl/sql/src/Insert.php172
-rw-r--r--vendor/ipl/sql/src/LimitOffset.php89
-rw-r--r--vendor/ipl/sql/src/LimitOffsetInterface.php71
-rw-r--r--vendor/ipl/sql/src/OrderBy.php74
-rw-r--r--vendor/ipl/sql/src/OrderByInterface.php51
-rw-r--r--vendor/ipl/sql/src/QueryBuilder.php907
-rw-r--r--vendor/ipl/sql/src/Select.php562
-rw-r--r--vendor/ipl/sql/src/Sql.php70
-rw-r--r--vendor/ipl/sql/src/Update.php100
-rw-r--r--vendor/ipl/sql/src/Where.php158
-rw-r--r--vendor/ipl/sql/src/WhereInterface.php84
-rw-r--r--vendor/ipl/stdlib/LICENSE21
-rw-r--r--vendor/ipl/stdlib/composer.json22
-rw-r--r--vendor/ipl/stdlib/src/BaseFilter.php45
-rw-r--r--vendor/ipl/stdlib/src/Contract/Filterable.php63
-rw-r--r--vendor/ipl/stdlib/src/Contract/Paginatable.php56
-rw-r--r--vendor/ipl/stdlib/src/Contract/PaginationInterface.php8
-rw-r--r--vendor/ipl/stdlib/src/Contract/PluginLoader.php21
-rw-r--r--vendor/ipl/stdlib/src/Contract/Translator.php65
-rw-r--r--vendor/ipl/stdlib/src/Contract/Validator.php22
-rw-r--r--vendor/ipl/stdlib/src/Contract/ValidatorInterface.php8
-rw-r--r--vendor/ipl/stdlib/src/Data.php89
-rw-r--r--vendor/ipl/stdlib/src/EventEmitter.php9
-rw-r--r--vendor/ipl/stdlib/src/Events.php57
-rw-r--r--vendor/ipl/stdlib/src/Filter.php584
-rw-r--r--vendor/ipl/stdlib/src/Filter/All.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Any.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Chain.php179
-rw-r--r--vendor/ipl/stdlib/src/Filter/Condition.php84
-rw-r--r--vendor/ipl/stdlib/src/Filter/Equal.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/GreaterThan.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/LessThan.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Like.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/MetaData.php20
-rw-r--r--vendor/ipl/stdlib/src/Filter/MetaDataProvider.php15
-rw-r--r--vendor/ipl/stdlib/src/Filter/None.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Rule.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Unequal.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/Unlike.php31
-rw-r--r--vendor/ipl/stdlib/src/Filters.php58
-rw-r--r--vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php52
-rw-r--r--vendor/ipl/stdlib/src/MessageContainer.php9
-rw-r--r--vendor/ipl/stdlib/src/Messages.php92
-rw-r--r--vendor/ipl/stdlib/src/Plugins.php95
-rw-r--r--vendor/ipl/stdlib/src/PriorityQueue.php42
-rw-r--r--vendor/ipl/stdlib/src/Properties.php205
-rw-r--r--vendor/ipl/stdlib/src/Seq.php111
-rw-r--r--vendor/ipl/stdlib/src/Str.php93
-rw-r--r--vendor/ipl/stdlib/src/functions.php128
-rw-r--r--vendor/ipl/stdlib/src/functions_include.php6
-rw-r--r--vendor/ipl/validator/LICENSE21
-rw-r--r--vendor/ipl/validator/composer.json28
-rw-r--r--vendor/ipl/validator/src/BaseValidator.php11
-rw-r--r--vendor/ipl/validator/src/BetweenValidator.php159
-rw-r--r--vendor/ipl/validator/src/CallbackValidator.php45
-rw-r--r--vendor/ipl/validator/src/CidrValidator.php60
-rw-r--r--vendor/ipl/validator/src/DateTimeValidator.php65
-rw-r--r--vendor/ipl/validator/src/DeferredInArrayValidator.php55
-rw-r--r--vendor/ipl/validator/src/EmailAddressValidator.php341
-rw-r--r--vendor/ipl/validator/src/FileValidator.php248
-rw-r--r--vendor/ipl/validator/src/GreaterThanValidator.php69
-rw-r--r--vendor/ipl/validator/src/HexColorValidator.php37
-rw-r--r--vendor/ipl/validator/src/HostnameValidator.php37
-rw-r--r--vendor/ipl/validator/src/InArrayValidator.php128
-rw-r--r--vendor/ipl/validator/src/LessThanValidator.php69
-rw-r--r--vendor/ipl/validator/src/PrivateKeyValidator.php33
-rw-r--r--vendor/ipl/validator/src/StringLengthValidator.php179
-rw-r--r--vendor/ipl/validator/src/ValidatorChain.php284
-rw-r--r--vendor/ipl/validator/src/X509CertValidator.php33
-rw-r--r--vendor/ipl/web/LICENSE21
-rw-r--r--vendor/ipl/web/asset/static/font/icinga-icons/selection.json1
-rw-r--r--vendor/ipl/web/composer.json39
-rw-r--r--vendor/ipl/web/src/Common/BaseItemList.php73
-rw-r--r--vendor/ipl/web/src/Common/BaseItemTable.php88
-rw-r--r--vendor/ipl/web/src/Common/BaseListItem.php145
-rw-r--r--vendor/ipl/web/src/Common/BaseOrderedItemList.php31
-rw-r--r--vendor/ipl/web/src/Common/BaseOrderedListItem.php42
-rw-r--r--vendor/ipl/web/src/Common/BaseTableRowItem.php119
-rw-r--r--vendor/ipl/web/src/Common/BaseTarget.php36
-rw-r--r--vendor/ipl/web/src/Common/Card.php59
-rw-r--r--vendor/ipl/web/src/Common/CsrfCounterMeasure.php48
-rw-r--r--vendor/ipl/web/src/Common/FormUid.php59
-rw-r--r--vendor/ipl/web/src/Common/RedirectOption.php41
-rw-r--r--vendor/ipl/web/src/Common/StateBadges.php194
-rw-r--r--vendor/ipl/web/src/Compat/CompatController.php512
-rw-r--r--vendor/ipl/web/src/Compat/CompatDecorator.php14
-rw-r--r--vendor/ipl/web/src/Compat/CompatForm.php100
-rw-r--r--vendor/ipl/web/src/Compat/Multipart.php33
-rw-r--r--vendor/ipl/web/src/Compat/SearchControls.php260
-rw-r--r--vendor/ipl/web/src/Compat/StyleWithNonce.php25
-rw-r--r--vendor/ipl/web/src/Compat/ViewRenderer.php60
-rw-r--r--vendor/ipl/web/src/Control/LimitControl.php123
-rw-r--r--vendor/ipl/web/src/Control/PaginationControl.php523
-rw-r--r--vendor/ipl/web/src/Control/SearchBar.php541
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/SearchException.php9
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Suggestions.php451
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Terms.php255
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php44
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php80
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php196
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php41
-rw-r--r--vendor/ipl/web/src/Control/SearchEditor.php615
-rw-r--r--vendor/ipl/web/src/Control/SortControl.php293
-rw-r--r--vendor/ipl/web/src/Filter/ParseException.php36
-rw-r--r--vendor/ipl/web/src/Filter/Parser.php568
-rw-r--r--vendor/ipl/web/src/Filter/QueryString.php94
-rw-r--r--vendor/ipl/web/src/Filter/Renderer.php186
-rw-r--r--vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php123
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement.php636
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php133
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php41
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php243
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php58
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php191
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php89
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php151
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput.php450
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php144
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/Term.php89
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermContainer.php54
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php281
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php38
-rw-r--r--vendor/ipl/web/src/Layout/Content.php17
-rw-r--r--vendor/ipl/web/src/Layout/Controls.php59
-rw-r--r--vendor/ipl/web/src/Layout/Footer.php17
-rw-r--r--vendor/ipl/web/src/LessRuleset.php177
-rw-r--r--vendor/ipl/web/src/Style.php123
-rw-r--r--vendor/ipl/web/src/Url.php71
-rw-r--r--vendor/ipl/web/src/Widget/ActionBar.php51
-rw-r--r--vendor/ipl/web/src/Widget/ActionLink.php31
-rw-r--r--vendor/ipl/web/src/Widget/ButtonLink.php14
-rw-r--r--vendor/ipl/web/src/Widget/ContinueWith.php72
-rw-r--r--vendor/ipl/web/src/Widget/CopyToClipboard.php64
-rw-r--r--vendor/ipl/web/src/Widget/Dropdown.php63
-rw-r--r--vendor/ipl/web/src/Widget/EmptyState.php30
-rw-r--r--vendor/ipl/web/src/Widget/EmptyStateBar.php30
-rw-r--r--vendor/ipl/web/src/Widget/HorizontalKeyValue.php31
-rw-r--r--vendor/ipl/web/src/Widget/IcingaIcon.php28
-rw-r--r--vendor/ipl/web/src/Widget/Icon.php67
-rw-r--r--vendor/ipl/web/src/Widget/Link.php97
-rw-r--r--vendor/ipl/web/src/Widget/StateBadge.php47
-rw-r--r--vendor/ipl/web/src/Widget/StateBall.php43
-rw-r--r--vendor/ipl/web/src/Widget/Tabs.php190
-rw-r--r--vendor/ipl/web/src/Widget/TimeAgo.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeSince.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeUntil.php34
-rw-r--r--vendor/ipl/web/src/Widget/VerticalKeyValue.php32
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('"', '&quot;', $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('&', '&amp;', $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 &#013; (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 &lt; 2.12 and with CSP support in &gt;= 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('&nbsp')
+ );
+ }
+ }
+
+ 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),
+ ]);
+ }
+}