diff options
author | Julius Härtl <jus@bitgrid.net> | 2022-08-30 12:56:14 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-30 12:56:14 +0300 |
commit | d6a35b6d5759c08dd268618951f9e5b1c18aa939 (patch) | |
tree | b89067be52749a3d6294402188394ca087cb7415 | |
parent | 020d0d3892bd3b7296db8ed21448c834d33d5723 (diff) | |
parent | 0ddbaa9bc4faded2f5a580f7ba2022d66ce7bd17 (diff) |
Merge pull request #1131 from nextcloud/deps/opengraphv25.0.0beta4
100 files changed, 10159 insertions, 15 deletions
@@ -58,6 +58,20 @@ doctrine/inflector/tests doctrine/lexer/composer.json doctrine/lexer/LICENSE +fusonic/linq/examples/ +fusonic/linq/tests/ +fusonic/linq/composer.json +fusonic/linq/.gitignore +fusonic/linq/README.md + +fusonic/opengraph/examples/ +fusonic/opengraph/.gitattributes +fusonic/opengraph/.gitignore +fusonic/opengraph/.scrutinizer.yml +fusonic/opengraph/composer.json +fusonic/opengraph/phpunit.xml +fusonic/opengraph/README.md + giggsey/libphonenumber-for-php/METADATA-VERSION.txt giggsey/locale/CLDR-VERSION.txt diff --git a/composer.json b/composer.json index e291fb3a..0cc064d8 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "optimize-autoloader": true, "classmap-authoritative": true, "platform": { - "php": "7.3.0" + "php": "7.4.0" }, "sort-packages": true, "allow-plugins": { @@ -25,6 +25,7 @@ "deepdiver1975/tarstreamer": "v2.0.0", "doctrine/dbal": "3.1.4", "egulias/email-validator": "3.1.1", + "fusonic/opengraph": "^2.2", "giggsey/libphonenumber-for-php": "^8.12.37", "guzzlehttp/guzzle": "^7.4.0", "icewind/searchdav": "^3.0", diff --git a/composer.lock b/composer.lock index 2e4479b4..9a646905 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "62a9ea5f04f28df33dfdeea90ed8bcdb", + "content-hash": "2297310d6ca23ae439e3c5d2642b5331", "packages": [ { "name": "aws/aws-sdk-php", @@ -1090,6 +1090,110 @@ "time": "2021-04-24T19:01:55+00:00" }, { + "name": "fusonic/linq", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/fusonic/linq.git", + "reference": "63520ef1470ca771acbd26871efb945dd4a7a5d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fusonic/linq/zipball/63520ef1470ca771acbd26871efb945dd4a7a5d8", + "reference": "63520ef1470ca771acbd26871efb945dd4a7a5d8", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "Fusonic\\Linq": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fusonic", + "homepage": "http://www.fusonic.net" + } + ], + "description": "LINQ 2 objects class for PHP", + "homepage": "http://fusonic.github.io/fusonic-linq/", + "keywords": [ + "linq", + "linq2objects" + ], + "support": { + "issues": "https://github.com/fusonic/linq/issues", + "source": "https://github.com/fusonic/linq/tree/master" + }, + "time": "2015-02-26T22:49:17+00:00" + }, + { + "name": "fusonic/opengraph", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/fusonic/opengraph.git", + "reference": "a63b588fbe56c175ae06e158f1513642653ee3c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fusonic/opengraph/zipball/a63b588fbe56c175ae06e158f1513642653ee3c1", + "reference": "a63b588fbe56c175ae06e158f1513642653ee3c1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "fusonic/linq": "^1.0", + "php": "^7.4|^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/css-selector": "^3.0|^4.0|^5.0|^6.0", + "symfony/dom-crawler": "^3.0|^4.0|^5.0|^6.0" + }, + "require-dev": { + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^9.0", + "symfony/http-client": "^6.0" + }, + "suggest": { + "nyholm/psr7": "^1.2", + "symfony/http-client": "^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fusonic\\OpenGraph\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fusonic", + "homepage": "https://www.fusonic.net" + } + ], + "description": "PHP library for consuming and publishing Open Graph resources.", + "homepage": "https://github.com/fusonic/fusonic-opengraph", + "keywords": [ + "opengraph" + ], + "support": { + "issues": "https://github.com/fusonic/opengraph/issues", + "source": "https://github.com/fusonic/opengraph/tree/v2.2.0" + }, + "time": "2022-01-20T05:47:36+00:00" + }, + { "name": "giggsey/libphonenumber-for-php", "version": "8.12.38", "source": { @@ -4520,6 +4624,72 @@ "time": "2021-08-25T19:27:26+00:00" }, { + "name": "symfony/css-selector", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "c1681789f059ab756001052164726ae88512ae3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/c1681789f059ab756001052164726ae88512ae3d", + "reference": "c1681789f059ab756001052164726ae88512ae3d", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-27T16:58:25+00:00" + }, + { "name": "symfony/deprecation-contracts", "version": "v2.5.2", "source": { @@ -4587,6 +4757,81 @@ "time": "2022-01-02T09:53:40+00:00" }, { + "name": "symfony/dom-crawler", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "0b900ca5576ecd59e08c76127e616667cfe427a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0b900ca5576ecd59e08c76127e616667cfe427a7", + "reference": "0b900ca5576ecd59e08c76127e616667cfe427a7", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "require-dev": { + "masterminds/html5": "^2.6", + "symfony/css-selector": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-27T16:58:25+00:00" + }, + { "name": "symfony/event-dispatcher", "version": "v4.4.30", "source": { @@ -6316,7 +6561,7 @@ }, "platform-dev": [], "platform-overrides": { - "php": "7.3.0" + "php": "7.4.0" }, "plugin-api-version": "2.3.0" } diff --git a/composer/autoload_classmap.php b/composer/autoload_classmap.php index d67ffe5b..ad99ca64 100644 --- a/composer/autoload_classmap.php +++ b/composer/autoload_classmap.php @@ -1333,6 +1333,27 @@ return array( 'FG\\X509\\SAN\\DNSName' => $vendorDir . '/fgrosse/phpasn1/lib/X509/SAN/DNSName.php', 'FG\\X509\\SAN\\IPAddress' => $vendorDir . '/fgrosse/phpasn1/lib/X509/SAN/IPAddress.php', 'FG\\X509\\SAN\\SubjectAlternativeNames' => $vendorDir . '/fgrosse/phpasn1/lib/X509/SAN/SubjectAlternativeNames.php', + 'Fusonic\\Linq\\GroupedLinq' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/GroupedLinq.php', + 'Fusonic\\Linq\\Helper\\LinqHelper' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Helper/LinqHelper.php', + 'Fusonic\\Linq\\Iterator\\DistinctIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/DistinctIterator.php', + 'Fusonic\\Linq\\Iterator\\ExceptIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/ExceptIterator.php', + 'Fusonic\\Linq\\Iterator\\GroupIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/GroupIterator.php', + 'Fusonic\\Linq\\Iterator\\IntersectIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/IntersectIterator.php', + 'Fusonic\\Linq\\Iterator\\OfTypeIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/OfTypeIterator.php', + 'Fusonic\\Linq\\Iterator\\OrderIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/OrderIterator.php', + 'Fusonic\\Linq\\Iterator\\SelectIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/SelectIterator.php', + 'Fusonic\\Linq\\Iterator\\SelectManyIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/SelectManyIterator.php', + 'Fusonic\\Linq\\Iterator\\WhereIterator' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Iterator/WhereIterator.php', + 'Fusonic\\Linq\\Linq' => $vendorDir . '/fusonic/linq/src/Fusonic/Linq/Linq.php', + 'Fusonic\\OpenGraph\\Consumer' => $vendorDir . '/fusonic/opengraph/src/Consumer.php', + 'Fusonic\\OpenGraph\\Elements\\Audio' => $vendorDir . '/fusonic/opengraph/src/Elements/Audio.php', + 'Fusonic\\OpenGraph\\Elements\\ElementBase' => $vendorDir . '/fusonic/opengraph/src/Elements/ElementBase.php', + 'Fusonic\\OpenGraph\\Elements\\Image' => $vendorDir . '/fusonic/opengraph/src/Elements/Image.php', + 'Fusonic\\OpenGraph\\Elements\\Video' => $vendorDir . '/fusonic/opengraph/src/Elements/Video.php', + 'Fusonic\\OpenGraph\\Objects\\ObjectBase' => $vendorDir . '/fusonic/opengraph/src/Objects/ObjectBase.php', + 'Fusonic\\OpenGraph\\Objects\\Website' => $vendorDir . '/fusonic/opengraph/src/Objects/Website.php', + 'Fusonic\\OpenGraph\\Property' => $vendorDir . '/fusonic/opengraph/src/Property.php', + 'Fusonic\\OpenGraph\\Publisher' => $vendorDir . '/fusonic/opengraph/src/Publisher.php', 'Giggsey\\Locale\\Locale' => $vendorDir . '/giggsey/locale/src/Locale.php', 'GuzzleHttp\\BodySummarizer' => $vendorDir . '/guzzlehttp/guzzle/src/BodySummarizer.php', 'GuzzleHttp\\BodySummarizerInterface' => $vendorDir . '/guzzlehttp/guzzle/src/BodySummarizerInterface.php', @@ -2864,6 +2885,66 @@ return array( 'Symfony\\Component\\Console\\Tester\\ApplicationTester' => $vendorDir . '/symfony/console/Tester/ApplicationTester.php', 'Symfony\\Component\\Console\\Tester\\CommandTester' => $vendorDir . '/symfony/console/Tester/CommandTester.php', 'Symfony\\Component\\Console\\Tester\\TesterTrait' => $vendorDir . '/symfony/console/Tester/TesterTrait.php', + 'Symfony\\Component\\CssSelector\\CssSelectorConverter' => $vendorDir . '/symfony/css-selector/CssSelectorConverter.php', + 'Symfony\\Component\\CssSelector\\Exception\\ExceptionInterface' => $vendorDir . '/symfony/css-selector/Exception/ExceptionInterface.php', + 'Symfony\\Component\\CssSelector\\Exception\\ExpressionErrorException' => $vendorDir . '/symfony/css-selector/Exception/ExpressionErrorException.php', + 'Symfony\\Component\\CssSelector\\Exception\\InternalErrorException' => $vendorDir . '/symfony/css-selector/Exception/InternalErrorException.php', + 'Symfony\\Component\\CssSelector\\Exception\\ParseException' => $vendorDir . '/symfony/css-selector/Exception/ParseException.php', + 'Symfony\\Component\\CssSelector\\Exception\\SyntaxErrorException' => $vendorDir . '/symfony/css-selector/Exception/SyntaxErrorException.php', + 'Symfony\\Component\\CssSelector\\Node\\AbstractNode' => $vendorDir . '/symfony/css-selector/Node/AbstractNode.php', + 'Symfony\\Component\\CssSelector\\Node\\AttributeNode' => $vendorDir . '/symfony/css-selector/Node/AttributeNode.php', + 'Symfony\\Component\\CssSelector\\Node\\ClassNode' => $vendorDir . '/symfony/css-selector/Node/ClassNode.php', + 'Symfony\\Component\\CssSelector\\Node\\CombinedSelectorNode' => $vendorDir . '/symfony/css-selector/Node/CombinedSelectorNode.php', + 'Symfony\\Component\\CssSelector\\Node\\ElementNode' => $vendorDir . '/symfony/css-selector/Node/ElementNode.php', + 'Symfony\\Component\\CssSelector\\Node\\FunctionNode' => $vendorDir . '/symfony/css-selector/Node/FunctionNode.php', + 'Symfony\\Component\\CssSelector\\Node\\HashNode' => $vendorDir . '/symfony/css-selector/Node/HashNode.php', + 'Symfony\\Component\\CssSelector\\Node\\NegationNode' => $vendorDir . '/symfony/css-selector/Node/NegationNode.php', + 'Symfony\\Component\\CssSelector\\Node\\NodeInterface' => $vendorDir . '/symfony/css-selector/Node/NodeInterface.php', + 'Symfony\\Component\\CssSelector\\Node\\PseudoNode' => $vendorDir . '/symfony/css-selector/Node/PseudoNode.php', + 'Symfony\\Component\\CssSelector\\Node\\SelectorNode' => $vendorDir . '/symfony/css-selector/Node/SelectorNode.php', + 'Symfony\\Component\\CssSelector\\Node\\Specificity' => $vendorDir . '/symfony/css-selector/Node/Specificity.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\CommentHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/CommentHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HandlerInterface' => $vendorDir . '/symfony/css-selector/Parser/Handler/HandlerInterface.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HashHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/HashHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\IdentifierHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/IdentifierHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\NumberHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/NumberHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\StringHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/StringHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\WhitespaceHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/WhitespaceHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Parser' => $vendorDir . '/symfony/css-selector/Parser/Parser.php', + 'Symfony\\Component\\CssSelector\\Parser\\ParserInterface' => $vendorDir . '/symfony/css-selector/Parser/ParserInterface.php', + 'Symfony\\Component\\CssSelector\\Parser\\Reader' => $vendorDir . '/symfony/css-selector/Parser/Reader.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\ClassParser' => $vendorDir . '/symfony/css-selector/Parser/Shortcut/ClassParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\ElementParser' => $vendorDir . '/symfony/css-selector/Parser/Shortcut/ElementParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\EmptyStringParser' => $vendorDir . '/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\HashParser' => $vendorDir . '/symfony/css-selector/Parser/Shortcut/HashParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Token' => $vendorDir . '/symfony/css-selector/Parser/Token.php', + 'Symfony\\Component\\CssSelector\\Parser\\TokenStream' => $vendorDir . '/symfony/css-selector/Parser/TokenStream.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\Tokenizer' => $vendorDir . '/symfony/css-selector/Parser/Tokenizer/Tokenizer.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\TokenizerEscaping' => $vendorDir . '/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\TokenizerPatterns' => $vendorDir . '/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\AbstractExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/AbstractExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\AttributeMatchingExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\CombinationExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/CombinationExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\ExtensionInterface' => $vendorDir . '/symfony/css-selector/XPath/Extension/ExtensionInterface.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\FunctionExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/FunctionExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\HtmlExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/HtmlExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\NodeExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/NodeExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\PseudoClassExtension' => $vendorDir . '/symfony/css-selector/XPath/Extension/PseudoClassExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Translator' => $vendorDir . '/symfony/css-selector/XPath/Translator.php', + 'Symfony\\Component\\CssSelector\\XPath\\TranslatorInterface' => $vendorDir . '/symfony/css-selector/XPath/TranslatorInterface.php', + 'Symfony\\Component\\CssSelector\\XPath\\XPathExpr' => $vendorDir . '/symfony/css-selector/XPath/XPathExpr.php', + 'Symfony\\Component\\DomCrawler\\AbstractUriElement' => $vendorDir . '/symfony/dom-crawler/AbstractUriElement.php', + 'Symfony\\Component\\DomCrawler\\Crawler' => $vendorDir . '/symfony/dom-crawler/Crawler.php', + 'Symfony\\Component\\DomCrawler\\Field\\ChoiceFormField' => $vendorDir . '/symfony/dom-crawler/Field/ChoiceFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\FileFormField' => $vendorDir . '/symfony/dom-crawler/Field/FileFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\FormField' => $vendorDir . '/symfony/dom-crawler/Field/FormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\InputFormField' => $vendorDir . '/symfony/dom-crawler/Field/InputFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\TextareaFormField' => $vendorDir . '/symfony/dom-crawler/Field/TextareaFormField.php', + 'Symfony\\Component\\DomCrawler\\Form' => $vendorDir . '/symfony/dom-crawler/Form.php', + 'Symfony\\Component\\DomCrawler\\FormFieldRegistry' => $vendorDir . '/symfony/dom-crawler/FormFieldRegistry.php', + 'Symfony\\Component\\DomCrawler\\Image' => $vendorDir . '/symfony/dom-crawler/Image.php', + 'Symfony\\Component\\DomCrawler\\Link' => $vendorDir . '/symfony/dom-crawler/Link.php', + 'Symfony\\Component\\DomCrawler\\UriResolver' => $vendorDir . '/symfony/dom-crawler/UriResolver.php', 'Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher' => $vendorDir . '/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php', 'Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcherInterface' => $vendorDir . '/symfony/event-dispatcher/Debug/TraceableEventDispatcherInterface.php', 'Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener' => $vendorDir . '/symfony/event-dispatcher/Debug/WrappedListener.php', diff --git a/composer/autoload_files.php b/composer/autoload_files.php index d6a3e264..0b30e898 100644 --- a/composer/autoload_files.php +++ b/composer/autoload_files.php @@ -6,13 +6,13 @@ $vendorDir = dirname(__DIR__); $baseDir = $vendorDir; return array( - '383eaff206634a77a1be54e64e6459c7' => $vendorDir . '/sabre/uri/lib/functions.php', 'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php', - '7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php', + '383eaff206634a77a1be54e64e6459c7' => $vendorDir . '/sabre/uri/lib/functions.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php', + '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php', + '7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php', 'c964ee0ededf28c96ebd9db5099ef910' => $vendorDir . '/guzzlehttp/promises/src/functions_include.php', 'a0edc8309cc5e1d60e3047b5df6b7052' => $vendorDir . '/guzzlehttp/psr7/src/functions_include.php', - '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php', 'a4ecaeafb8cfb009ad0e052c90355e98' => $vendorDir . '/beberlei/assert/lib/Assert/functions.php', '37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php', '2b9d0f43f9552984cfa82fee95491826' => $vendorDir . '/sabre/event/lib/coroutine.php', @@ -20,11 +20,11 @@ return array( 'a1cce3d26cc15c00fcd0b3354bd72c88' => $vendorDir . '/sabre/event/lib/Promise/functions.php', '3569eecfeed3bcf0bad3c998a494ecb8' => $vendorDir . '/sabre/xml/lib/Deserializer/functions.php', '93aa591bc4ca510c520999e34229ee79' => $vendorDir . '/sabre/xml/lib/Serializer/functions.php', + '320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php', 'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php', '25072dd6e2470089de65ae7bf11d3109' => $vendorDir . '/symfony/polyfill-php72/bootstrap.php', 'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php', 'ebdb698ed4152ae445614b69b5e4bb6a' => $vendorDir . '/sabre/http/lib/functions.php', - '320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php', '0d59ee240a4cd96ddbb4ff164fccea4d' => $vendorDir . '/symfony/polyfill-php73/bootstrap.php', 'b067bc7112e384b61c701452d53a14a8' => $vendorDir . '/mtdowling/jmespath.php/src/JmesPath.php', 'e39a8b23c42d4e1452234d762b03835a' => $vendorDir . '/ramsey/uuid/src/functions.php', diff --git a/composer/autoload_namespaces.php b/composer/autoload_namespaces.php index 225f7eb6..c43ac488 100644 --- a/composer/autoload_namespaces.php +++ b/composer/autoload_namespaces.php @@ -7,6 +7,7 @@ $baseDir = $vendorDir; return array( 'Pimple' => array($vendorDir . '/pimple/pimple/src'), + 'Fusonic\\Linq' => array($vendorDir . '/fusonic/linq/src'), 'Console' => array($vendorDir . '/pear/console_getopt'), 'Archive_Tar' => array($vendorDir . '/pear/archive_tar'), '' => array($vendorDir . '/pear/pear-core-minimal/src'), diff --git a/composer/autoload_psr4.php b/composer/autoload_psr4.php index e7b84631..4b572bd1 100644 --- a/composer/autoload_psr4.php +++ b/composer/autoload_psr4.php @@ -31,6 +31,8 @@ return array( 'Symfony\\Component\\Process\\' => array($vendorDir . '/symfony/process'), 'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'), 'Symfony\\Component\\EventDispatcher\\' => array($vendorDir . '/symfony/event-dispatcher'), + 'Symfony\\Component\\DomCrawler\\' => array($vendorDir . '/symfony/dom-crawler'), + 'Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'), 'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'), 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\' => array($vendorDir . '/stecman/symfony-console-completion/src'), 'SearchDAV\\' => array($vendorDir . '/icewind/searchdav/src'), @@ -74,6 +76,7 @@ return array( 'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'), 'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'), 'Giggsey\\Locale\\' => array($vendorDir . '/giggsey/locale/src'), + 'Fusonic\\OpenGraph\\' => array($vendorDir . '/fusonic/opengraph/src'), 'FG\\' => array($vendorDir . '/fgrosse/phpasn1/lib'), 'Egulias\\EmailValidator\\' => array($vendorDir . '/egulias/email-validator/src'), 'Ds\\' => array($vendorDir . '/php-ds/php-ds/src'), diff --git a/composer/autoload_static.php b/composer/autoload_static.php index a195c71d..7efc962f 100644 --- a/composer/autoload_static.php +++ b/composer/autoload_static.php @@ -7,13 +7,13 @@ namespace Composer\Autoload; class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 { public static $files = array ( - '383eaff206634a77a1be54e64e6459c7' => __DIR__ . '/..' . '/sabre/uri/lib/functions.php', 'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php', - '7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php', + '383eaff206634a77a1be54e64e6459c7' => __DIR__ . '/..' . '/sabre/uri/lib/functions.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', + '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', + '7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php', 'c964ee0ededf28c96ebd9db5099ef910' => __DIR__ . '/..' . '/guzzlehttp/promises/src/functions_include.php', 'a0edc8309cc5e1d60e3047b5df6b7052' => __DIR__ . '/..' . '/guzzlehttp/psr7/src/functions_include.php', - '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', 'a4ecaeafb8cfb009ad0e052c90355e98' => __DIR__ . '/..' . '/beberlei/assert/lib/Assert/functions.php', '37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php', '2b9d0f43f9552984cfa82fee95491826' => __DIR__ . '/..' . '/sabre/event/lib/coroutine.php', @@ -21,11 +21,11 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'a1cce3d26cc15c00fcd0b3354bd72c88' => __DIR__ . '/..' . '/sabre/event/lib/Promise/functions.php', '3569eecfeed3bcf0bad3c998a494ecb8' => __DIR__ . '/..' . '/sabre/xml/lib/Deserializer/functions.php', '93aa591bc4ca510c520999e34229ee79' => __DIR__ . '/..' . '/sabre/xml/lib/Serializer/functions.php', + '320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php', 'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php', '25072dd6e2470089de65ae7bf11d3109' => __DIR__ . '/..' . '/symfony/polyfill-php72/bootstrap.php', 'f598d06aa772fa33d905e87be6398fb1' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/bootstrap.php', 'ebdb698ed4152ae445614b69b5e4bb6a' => __DIR__ . '/..' . '/sabre/http/lib/functions.php', - '320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php', '0d59ee240a4cd96ddbb4ff164fccea4d' => __DIR__ . '/..' . '/symfony/polyfill-php73/bootstrap.php', 'b067bc7112e384b61c701452d53a14a8' => __DIR__ . '/..' . '/mtdowling/jmespath.php/src/JmesPath.php', 'e39a8b23c42d4e1452234d762b03835a' => __DIR__ . '/..' . '/ramsey/uuid/src/functions.php', @@ -175,6 +175,8 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'Symfony\\Component\\Process\\' => 26, 'Symfony\\Component\\HttpFoundation\\' => 33, 'Symfony\\Component\\EventDispatcher\\' => 34, + 'Symfony\\Component\\DomCrawler\\' => 29, + 'Symfony\\Component\\CssSelector\\' => 30, 'Symfony\\Component\\Console\\' => 26, 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\' => 49, 'SearchDAV\\' => 10, @@ -251,6 +253,7 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 ), 'F' => array ( + 'Fusonic\\OpenGraph\\' => 18, 'FG\\' => 3, ), 'E' => @@ -384,6 +387,14 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 array ( 0 => __DIR__ . '/..' . '/symfony/event-dispatcher', ), + 'Symfony\\Component\\DomCrawler\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/dom-crawler', + ), + 'Symfony\\Component\\CssSelector\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/css-selector', + ), 'Symfony\\Component\\Console\\' => array ( 0 => __DIR__ . '/..' . '/symfony/console', @@ -560,6 +571,10 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 array ( 0 => __DIR__ . '/..' . '/giggsey/locale/src', ), + 'Fusonic\\OpenGraph\\' => + array ( + 0 => __DIR__ . '/..' . '/fusonic/opengraph/src', + ), 'FG\\' => array ( 0 => __DIR__ . '/..' . '/fgrosse/phpasn1/lib', @@ -626,6 +641,13 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 0 => __DIR__ . '/..' . '/pimple/pimple/src', ), ), + 'F' => + array ( + 'Fusonic\\Linq' => + array ( + 0 => __DIR__ . '/..' . '/fusonic/linq/src', + ), + ), 'C' => array ( 'Console' => @@ -1974,6 +1996,27 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'FG\\X509\\SAN\\DNSName' => __DIR__ . '/..' . '/fgrosse/phpasn1/lib/X509/SAN/DNSName.php', 'FG\\X509\\SAN\\IPAddress' => __DIR__ . '/..' . '/fgrosse/phpasn1/lib/X509/SAN/IPAddress.php', 'FG\\X509\\SAN\\SubjectAlternativeNames' => __DIR__ . '/..' . '/fgrosse/phpasn1/lib/X509/SAN/SubjectAlternativeNames.php', + 'Fusonic\\Linq\\GroupedLinq' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/GroupedLinq.php', + 'Fusonic\\Linq\\Helper\\LinqHelper' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Helper/LinqHelper.php', + 'Fusonic\\Linq\\Iterator\\DistinctIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/DistinctIterator.php', + 'Fusonic\\Linq\\Iterator\\ExceptIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/ExceptIterator.php', + 'Fusonic\\Linq\\Iterator\\GroupIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/GroupIterator.php', + 'Fusonic\\Linq\\Iterator\\IntersectIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/IntersectIterator.php', + 'Fusonic\\Linq\\Iterator\\OfTypeIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/OfTypeIterator.php', + 'Fusonic\\Linq\\Iterator\\OrderIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/OrderIterator.php', + 'Fusonic\\Linq\\Iterator\\SelectIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/SelectIterator.php', + 'Fusonic\\Linq\\Iterator\\SelectManyIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/SelectManyIterator.php', + 'Fusonic\\Linq\\Iterator\\WhereIterator' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Iterator/WhereIterator.php', + 'Fusonic\\Linq\\Linq' => __DIR__ . '/..' . '/fusonic/linq/src/Fusonic/Linq/Linq.php', + 'Fusonic\\OpenGraph\\Consumer' => __DIR__ . '/..' . '/fusonic/opengraph/src/Consumer.php', + 'Fusonic\\OpenGraph\\Elements\\Audio' => __DIR__ . '/..' . '/fusonic/opengraph/src/Elements/Audio.php', + 'Fusonic\\OpenGraph\\Elements\\ElementBase' => __DIR__ . '/..' . '/fusonic/opengraph/src/Elements/ElementBase.php', + 'Fusonic\\OpenGraph\\Elements\\Image' => __DIR__ . '/..' . '/fusonic/opengraph/src/Elements/Image.php', + 'Fusonic\\OpenGraph\\Elements\\Video' => __DIR__ . '/..' . '/fusonic/opengraph/src/Elements/Video.php', + 'Fusonic\\OpenGraph\\Objects\\ObjectBase' => __DIR__ . '/..' . '/fusonic/opengraph/src/Objects/ObjectBase.php', + 'Fusonic\\OpenGraph\\Objects\\Website' => __DIR__ . '/..' . '/fusonic/opengraph/src/Objects/Website.php', + 'Fusonic\\OpenGraph\\Property' => __DIR__ . '/..' . '/fusonic/opengraph/src/Property.php', + 'Fusonic\\OpenGraph\\Publisher' => __DIR__ . '/..' . '/fusonic/opengraph/src/Publisher.php', 'Giggsey\\Locale\\Locale' => __DIR__ . '/..' . '/giggsey/locale/src/Locale.php', 'GuzzleHttp\\BodySummarizer' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/BodySummarizer.php', 'GuzzleHttp\\BodySummarizerInterface' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/BodySummarizerInterface.php', @@ -3505,6 +3548,66 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'Symfony\\Component\\Console\\Tester\\ApplicationTester' => __DIR__ . '/..' . '/symfony/console/Tester/ApplicationTester.php', 'Symfony\\Component\\Console\\Tester\\CommandTester' => __DIR__ . '/..' . '/symfony/console/Tester/CommandTester.php', 'Symfony\\Component\\Console\\Tester\\TesterTrait' => __DIR__ . '/..' . '/symfony/console/Tester/TesterTrait.php', + 'Symfony\\Component\\CssSelector\\CssSelectorConverter' => __DIR__ . '/..' . '/symfony/css-selector/CssSelectorConverter.php', + 'Symfony\\Component\\CssSelector\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/symfony/css-selector/Exception/ExceptionInterface.php', + 'Symfony\\Component\\CssSelector\\Exception\\ExpressionErrorException' => __DIR__ . '/..' . '/symfony/css-selector/Exception/ExpressionErrorException.php', + 'Symfony\\Component\\CssSelector\\Exception\\InternalErrorException' => __DIR__ . '/..' . '/symfony/css-selector/Exception/InternalErrorException.php', + 'Symfony\\Component\\CssSelector\\Exception\\ParseException' => __DIR__ . '/..' . '/symfony/css-selector/Exception/ParseException.php', + 'Symfony\\Component\\CssSelector\\Exception\\SyntaxErrorException' => __DIR__ . '/..' . '/symfony/css-selector/Exception/SyntaxErrorException.php', + 'Symfony\\Component\\CssSelector\\Node\\AbstractNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/AbstractNode.php', + 'Symfony\\Component\\CssSelector\\Node\\AttributeNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/AttributeNode.php', + 'Symfony\\Component\\CssSelector\\Node\\ClassNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/ClassNode.php', + 'Symfony\\Component\\CssSelector\\Node\\CombinedSelectorNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/CombinedSelectorNode.php', + 'Symfony\\Component\\CssSelector\\Node\\ElementNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/ElementNode.php', + 'Symfony\\Component\\CssSelector\\Node\\FunctionNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/FunctionNode.php', + 'Symfony\\Component\\CssSelector\\Node\\HashNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/HashNode.php', + 'Symfony\\Component\\CssSelector\\Node\\NegationNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/NegationNode.php', + 'Symfony\\Component\\CssSelector\\Node\\NodeInterface' => __DIR__ . '/..' . '/symfony/css-selector/Node/NodeInterface.php', + 'Symfony\\Component\\CssSelector\\Node\\PseudoNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/PseudoNode.php', + 'Symfony\\Component\\CssSelector\\Node\\SelectorNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/SelectorNode.php', + 'Symfony\\Component\\CssSelector\\Node\\Specificity' => __DIR__ . '/..' . '/symfony/css-selector/Node/Specificity.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\CommentHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/CommentHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HandlerInterface' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/HandlerInterface.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HashHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/HashHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\IdentifierHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/IdentifierHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\NumberHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/NumberHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\StringHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/StringHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Handler\\WhitespaceHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/WhitespaceHandler.php', + 'Symfony\\Component\\CssSelector\\Parser\\Parser' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Parser.php', + 'Symfony\\Component\\CssSelector\\Parser\\ParserInterface' => __DIR__ . '/..' . '/symfony/css-selector/Parser/ParserInterface.php', + 'Symfony\\Component\\CssSelector\\Parser\\Reader' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Reader.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\ClassParser' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Shortcut/ClassParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\ElementParser' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Shortcut/ElementParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\EmptyStringParser' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Shortcut\\HashParser' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Shortcut/HashParser.php', + 'Symfony\\Component\\CssSelector\\Parser\\Token' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Token.php', + 'Symfony\\Component\\CssSelector\\Parser\\TokenStream' => __DIR__ . '/..' . '/symfony/css-selector/Parser/TokenStream.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\Tokenizer' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Tokenizer/Tokenizer.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\TokenizerEscaping' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php', + 'Symfony\\Component\\CssSelector\\Parser\\Tokenizer\\TokenizerPatterns' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\AbstractExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/AbstractExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\AttributeMatchingExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\CombinationExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/CombinationExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\ExtensionInterface' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/ExtensionInterface.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\FunctionExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/FunctionExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\HtmlExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/HtmlExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\NodeExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/NodeExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Extension\\PseudoClassExtension' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Extension/PseudoClassExtension.php', + 'Symfony\\Component\\CssSelector\\XPath\\Translator' => __DIR__ . '/..' . '/symfony/css-selector/XPath/Translator.php', + 'Symfony\\Component\\CssSelector\\XPath\\TranslatorInterface' => __DIR__ . '/..' . '/symfony/css-selector/XPath/TranslatorInterface.php', + 'Symfony\\Component\\CssSelector\\XPath\\XPathExpr' => __DIR__ . '/..' . '/symfony/css-selector/XPath/XPathExpr.php', + 'Symfony\\Component\\DomCrawler\\AbstractUriElement' => __DIR__ . '/..' . '/symfony/dom-crawler/AbstractUriElement.php', + 'Symfony\\Component\\DomCrawler\\Crawler' => __DIR__ . '/..' . '/symfony/dom-crawler/Crawler.php', + 'Symfony\\Component\\DomCrawler\\Field\\ChoiceFormField' => __DIR__ . '/..' . '/symfony/dom-crawler/Field/ChoiceFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\FileFormField' => __DIR__ . '/..' . '/symfony/dom-crawler/Field/FileFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\FormField' => __DIR__ . '/..' . '/symfony/dom-crawler/Field/FormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\InputFormField' => __DIR__ . '/..' . '/symfony/dom-crawler/Field/InputFormField.php', + 'Symfony\\Component\\DomCrawler\\Field\\TextareaFormField' => __DIR__ . '/..' . '/symfony/dom-crawler/Field/TextareaFormField.php', + 'Symfony\\Component\\DomCrawler\\Form' => __DIR__ . '/..' . '/symfony/dom-crawler/Form.php', + 'Symfony\\Component\\DomCrawler\\FormFieldRegistry' => __DIR__ . '/..' . '/symfony/dom-crawler/FormFieldRegistry.php', + 'Symfony\\Component\\DomCrawler\\Image' => __DIR__ . '/..' . '/symfony/dom-crawler/Image.php', + 'Symfony\\Component\\DomCrawler\\Link' => __DIR__ . '/..' . '/symfony/dom-crawler/Link.php', + 'Symfony\\Component\\DomCrawler\\UriResolver' => __DIR__ . '/..' . '/symfony/dom-crawler/UriResolver.php', 'Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher' => __DIR__ . '/..' . '/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php', 'Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcherInterface' => __DIR__ . '/..' . '/symfony/event-dispatcher/Debug/TraceableEventDispatcherInterface.php', 'Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener' => __DIR__ . '/..' . '/symfony/event-dispatcher/Debug/WrappedListener.php', diff --git a/composer/installed.json b/composer/installed.json index 95b1b4c8..1cb2e013 100644 --- a/composer/installed.json +++ b/composer/installed.json @@ -1132,6 +1132,116 @@ "install-path": "../fgrosse/phpasn1" }, { + "name": "fusonic/linq", + "version": "v1.1.0", + "version_normalized": "1.1.0.0", + "source": { + "type": "git", + "url": "https://github.com/fusonic/linq.git", + "reference": "63520ef1470ca771acbd26871efb945dd4a7a5d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fusonic/linq/zipball/63520ef1470ca771acbd26871efb945dd4a7a5d8", + "reference": "63520ef1470ca771acbd26871efb945dd4a7a5d8", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "time": "2015-02-26T22:49:17+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-0": { + "Fusonic\\Linq": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fusonic", + "homepage": "http://www.fusonic.net" + } + ], + "description": "LINQ 2 objects class for PHP", + "homepage": "http://fusonic.github.io/fusonic-linq/", + "keywords": [ + "linq", + "linq2objects" + ], + "support": { + "issues": "https://github.com/fusonic/linq/issues", + "source": "https://github.com/fusonic/linq/tree/master" + }, + "install-path": "../fusonic/linq" + }, + { + "name": "fusonic/opengraph", + "version": "v2.2.0", + "version_normalized": "2.2.0.0", + "source": { + "type": "git", + "url": "https://github.com/fusonic/opengraph.git", + "reference": "a63b588fbe56c175ae06e158f1513642653ee3c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fusonic/opengraph/zipball/a63b588fbe56c175ae06e158f1513642653ee3c1", + "reference": "a63b588fbe56c175ae06e158f1513642653ee3c1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "fusonic/linq": "^1.0", + "php": "^7.4|^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/css-selector": "^3.0|^4.0|^5.0|^6.0", + "symfony/dom-crawler": "^3.0|^4.0|^5.0|^6.0" + }, + "require-dev": { + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^9.0", + "symfony/http-client": "^6.0" + }, + "suggest": { + "nyholm/psr7": "^1.2", + "symfony/http-client": "^5.0" + }, + "time": "2022-01-20T05:47:36+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Fusonic\\OpenGraph\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fusonic", + "homepage": "https://www.fusonic.net" + } + ], + "description": "PHP library for consuming and publishing Open Graph resources.", + "homepage": "https://github.com/fusonic/fusonic-opengraph", + "keywords": [ + "opengraph" + ], + "support": { + "issues": "https://github.com/fusonic/opengraph/issues", + "source": "https://github.com/fusonic/opengraph/tree/v2.2.0" + }, + "install-path": "../fusonic/opengraph" + }, + { "name": "giggsey/libphonenumber-for-php", "version": "8.12.38", "version_normalized": "8.12.38.0", @@ -4718,6 +4828,75 @@ "install-path": "../symfony/console" }, { + "name": "symfony/css-selector", + "version": "v5.4.11", + "version_normalized": "5.4.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "c1681789f059ab756001052164726ae88512ae3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/c1681789f059ab756001052164726ae88512ae3d", + "reference": "c1681789f059ab756001052164726ae88512ae3d", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "time": "2022-06-27T16:58:25+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/css-selector" + }, + { "name": "symfony/deprecation-contracts", "version": "v2.5.2", "version_normalized": "2.5.2.0", @@ -4788,6 +4967,84 @@ "install-path": "../symfony/deprecation-contracts" }, { + "name": "symfony/dom-crawler", + "version": "v5.4.11", + "version_normalized": "5.4.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "0b900ca5576ecd59e08c76127e616667cfe427a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0b900ca5576ecd59e08c76127e616667cfe427a7", + "reference": "0b900ca5576ecd59e08c76127e616667cfe427a7", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "require-dev": { + "masterminds/html5": "^2.6", + "symfony/css-selector": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "time": "2022-06-27T16:58:25+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/dom-crawler" + }, + { "name": "symfony/event-dispatcher", "version": "v4.4.30", "version_normalized": "4.4.30.0", diff --git a/composer/installed.php b/composer/installed.php index 58c14fb1..832b77ba 100644 --- a/composer/installed.php +++ b/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'nextcloud/3rdparty', 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '2d3ffebfcfb22af6d999d575b53e8f41cdfb4e9f', + 'reference' => '143faae0eba0121682129d1261158dd9db74b2b2', 'type' => 'library', 'install_path' => __DIR__ . '/../', 'aliases' => array(), @@ -154,6 +154,24 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'fusonic/linq' => array( + 'pretty_version' => 'v1.1.0', + 'version' => '1.1.0.0', + 'reference' => '63520ef1470ca771acbd26871efb945dd4a7a5d8', + 'type' => 'library', + 'install_path' => __DIR__ . '/../fusonic/linq', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'fusonic/opengraph' => array( + 'pretty_version' => 'v2.2.0', + 'version' => '2.2.0.0', + 'reference' => 'a63b588fbe56c175ae06e158f1513642653ee3c1', + 'type' => 'library', + 'install_path' => __DIR__ . '/../fusonic/opengraph', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'giggsey/libphonenumber-for-php' => array( 'pretty_version' => '8.12.38', 'version' => '8.12.38.0', @@ -301,7 +319,7 @@ 'nextcloud/3rdparty' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '2d3ffebfcfb22af6d999d575b53e8f41cdfb4e9f', + 'reference' => '143faae0eba0121682129d1261158dd9db74b2b2', 'type' => 'library', 'install_path' => __DIR__ . '/../', 'aliases' => array(), @@ -697,6 +715,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'symfony/css-selector' => array( + 'pretty_version' => 'v5.4.11', + 'version' => '5.4.11.0', + 'reference' => 'c1681789f059ab756001052164726ae88512ae3d', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/css-selector', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'symfony/deprecation-contracts' => array( 'pretty_version' => 'v2.5.2', 'version' => '2.5.2.0', @@ -706,6 +733,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'symfony/dom-crawler' => array( + 'pretty_version' => 'v5.4.11', + 'version' => '5.4.11.0', + 'reference' => '0b900ca5576ecd59e08c76127e616667cfe427a7', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/dom-crawler', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'symfony/event-dispatcher' => array( 'pretty_version' => 'v4.4.30', 'version' => '4.4.30.0', diff --git a/composer/platform_check.php b/composer/platform_check.php index 92370c5a..580fa960 100644 --- a/composer/platform_check.php +++ b/composer/platform_check.php @@ -4,8 +4,8 @@ $issues = array(); -if (!(PHP_VERSION_ID >= 70300)) { - $issues[] = 'Your Composer dependencies require a PHP version ">= 7.3.0". You are running ' . PHP_VERSION . '.'; +if (!(PHP_VERSION_ID >= 70400)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 7.4.0". You are running ' . PHP_VERSION . '.'; } if ($issues) { diff --git a/fusonic/linq/LICENSE b/fusonic/linq/LICENSE new file mode 100644 index 00000000..1617dcba --- /dev/null +++ b/fusonic/linq/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2014 Fusonic GmbH (http://www.fusonic.net) + +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.
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/GroupedLinq.php b/fusonic/linq/src/Fusonic/Linq/GroupedLinq.php new file mode 100644 index 00000000..2a2bb510 --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/GroupedLinq.php @@ -0,0 +1,35 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq; + +use Fusonic\Linq\Linq; + +/** + * Class GroupedLinq + * Represents a Linq object that groups together other elements with a group key(). + * @package Fusonic\Linq + */ +class GroupedLinq extends Linq +{ + private $groupKey; + + public function __construct($groupKey, $dataSource) + { + parent::__construct($dataSource); + $this->groupKey = $groupKey; + } + + public function key() + { + return $this->groupKey; + } +}
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/Helper/LinqHelper.php b/fusonic/linq/src/Fusonic/Linq/Helper/LinqHelper.php new file mode 100644 index 00000000..f3e8c9ae --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Helper/LinqHelper.php @@ -0,0 +1,67 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Helper; + +use ArrayIterator; +use InvalidArgumentException; +use UnexpectedValueException; + +class LinqHelper +{ + const MODE_ASSERT = 'MODE_ASSERT'; + const MODE_NULL = 'MODE_NULL'; + + const LINQ_ORDER_ASC = 'asc'; + const LINQ_ORDER_DESC = 'desc'; + + const LINQ_ORDER_TYPE_NUMERIC = 1; + const LINQ_ORDER_TYPE_ALPHANUMERIC = 2; + const LINQ_ORDER_TYPE_DATETIME = 3; + + public static function getBoolOrThrowException($returned) + { + if (!is_bool($returned)) { + throw new UnexpectedValueException("Return type of filter func must be boolean."); + } + return $returned; + } + + public static function assertArgumentIsIterable($param, $argumentName) + { + if (!self::isIterable($param)) { + throw new InvalidArgumentException("Argument must be an array, or implement either the \IteratorAggregate or \Iterator interface. ArgumentName = " . $argumentName); + } + } + + public static function getIteratorOrThrow($value) + { + if (is_array($value)) { + return new ArrayIterator($value); + } + else if($value instanceof \IteratorAggregate) { + return $value->getIterator(); + } + else if($value instanceof \Iterator) { + return $value; + } + + throw new \UnexpectedValueException("Value must be an array, or implement either the \IteratorAggregate or \Iterator interface"); + } + + public static function isIterable($param) + { + return is_array($param) + || $param instanceof \IteratorAggregate + || $param instanceof \Iterator; + } +}
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/DistinctIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/DistinctIterator.php new file mode 100644 index 00000000..7651fbfd --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/DistinctIterator.php @@ -0,0 +1,62 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use Iterator; + +class DistinctIterator extends \IteratorIterator +{ + private $iterator; + private $distinct; + + public function __construct(Iterator $iterator) + { + $this->iterator = $iterator; + } + + public function current() + { + return $this->distinct->current(); + } + + public function next() + { + $this->distinct->next(); + } + + public function key() + { + return $this->distinct->key(); + } + + public function valid() + { + return $this->distinct->valid(); + } + + public function rewind() + { + if ($this->distinct === null) { + $this->getDistincts(); + } + + $this->distinct->rewind(); + } + + private function getDistincts() + { + $data = iterator_to_array($this->iterator); + $distinct = array_unique($data); + $this->distinct = new \ArrayIterator($distinct); + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/ExceptIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/ExceptIterator.php new file mode 100644 index 00000000..18d3c2ea --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/ExceptIterator.php @@ -0,0 +1,65 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use ArrayIterator; +use Iterator; + +class ExceptIterator implements Iterator +{ + private $first; + private $second; + private $result; + + public function __construct(Iterator $first, Iterator $second) + { + $this->first = $first; + $this->second = $second; + } + + public function current() + { + return $this->result->current(); + } + + public function next() + { + $this->result->next(); + } + + public function key() + { + return $this->result->key(); + } + + public function valid() + { + return $this->result->valid(); + } + + public function rewind() + { + if ($this->result === null) { + $this->getResult(); + } + + $this->result->rewind(); + } + + private function getResult() + { + $firstArray = iterator_to_array($this->first); + $secondArray = iterator_to_array($this->second); + $this->result = new ArrayIterator(array_diff($firstArray, $secondArray)); + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/GroupIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/GroupIterator.php new file mode 100644 index 00000000..0b250179 --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/GroupIterator.php @@ -0,0 +1,74 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use Iterator; +use ArrayIterator; +use Fusonic\Linq\GroupedLinq; + +class GroupIterator implements Iterator +{ + private $iterator; + private $grouped; + private $keySelector; + + public function __construct($iterator, $keySelector) + { + $this->iterator = $iterator; + $this->keySelector = $keySelector; + } + + public function current() + { + $current = $this->grouped->current(); + return new GroupedLinq($current['key'], new \ArrayIterator($current['values'])); + } + + public function next() + { + $this->grouped->next(); + } + + public function key() + { + return $this->grouped->key(); + } + + public function valid() + { + return $this->grouped->valid(); + } + + public function rewind() + { + if ($this->grouped === null) { + $this->doGroup(); + } + + $this->grouped->rewind(); + } + + private function doGroup() + { + $keySelector = $this->keySelector; + $this->grouped = new \ArrayIterator(array()); + foreach ($this->iterator as $value) { + $key = $keySelector($value); + if (!isset($this->grouped[$key])) { + $this->grouped[$key] = array('key' => $key, 'values'=> array()); + } + + $this->grouped[$key]['values'][] = $value; + } + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/IntersectIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/IntersectIterator.php new file mode 100644 index 00000000..d08c2efe --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/IntersectIterator.php @@ -0,0 +1,65 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use ArrayIterator; +use Iterator; + +class IntersectIterator implements Iterator +{ + private $first; + private $second; + private $intersections; + + public function __construct(Iterator $first, Iterator $second) + { + $this->first = $first; + $this->second = $second; + } + + public function current() + { + return $this->intersections->current(); + } + + public function next() + { + $this->intersections->next(); + } + + public function key() + { + return $this->intersections->key(); + } + + public function valid() + { + return $this->intersections->valid(); + } + + public function rewind() + { + if ($this->intersections === null) { + $this->calcIntersections(); + } + + $this->intersections->rewind(); + } + + private function calcIntersections() + { + $firstArray = iterator_to_array($this->first); + $secondArray = iterator_to_array($this->second); + $this->intersections = new ArrayIterator(array_intersect($firstArray, $secondArray)); + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/OfTypeIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/OfTypeIterator.php new file mode 100644 index 00000000..46a59482 --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/OfTypeIterator.php @@ -0,0 +1,94 @@ +<?php +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Burgy Benjamin <benjamin.burgy@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use FilterIterator; +use Fusonic\Linq\Helper; +use Iterator; + +/** + * Iterator for filtering the Linq query with a specified <b>type</b>. + * @package Fusonic\Linq\Iterator + */ +final class OfTypeIterator + extends + \FilterIterator +{ + /** + * @var callable $acceptCallback + */ + private $acceptCallback; + + /** + * Initializes an instance of <b>OfTypeIterator</b>. + * + * @param Iterator $iterator + * @param string $type + */ + public function __construct(Iterator $iterator, $type) + { + parent::__construct($iterator); + + switch (strtolower($type)) + { + case 'int': + case 'integer': + $this->acceptCallback = function ($current) + { + return is_int($current); + }; + break; + case 'float': + case 'double': + $this->acceptCallback = function ($current) + { + return is_float($current); + }; + break; + case 'string': + $this->acceptCallback = function ($current) + { + return is_string($current); + }; + break; + case 'bool': + case 'boolean': + $this->acceptCallback = function ($current) + { + return is_bool($current); + }; + break; + + default: + $this->acceptCallback = function ($current) use ($type) + { + return $current instanceof $type; + }; + } + } + + /** + * (PHP 5 >= 5.1.0)<br/> + * Check whether the current element of the iterator is acceptable + * @link http://php.net/manual/en/filteriterator.accept.php + * @return bool true if the current element is acceptable, otherwise false. + */ + public function accept() + { + /** @var mixed $current */ + $current = $this->current(); + /** @var callable $func */ + $func = $this->acceptCallback; + + return $func($current); + } +}
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/OrderIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/OrderIterator.php new file mode 100644 index 00000000..732e79eb --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/OrderIterator.php @@ -0,0 +1,113 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use Iterator; +use ArrayIterator; +use Fusonic\Linq; +use Fusonic\Linq\Helper; + +class OrderIterator implements Iterator +{ + private $iterator; + private $direction; + private $orderedIterator; + private $orderKeyFunc; + + public function __construct(Iterator $items, $orderKeyFunc, $direction) + { + $this->iterator = $items; + $this->direction = $direction; + $this->orderKeyFunc = $orderKeyFunc; + } + + public function current() + { + return $this->orderedIterator->current(); + } + + public function next() + { + $this->orderedIterator->next(); + } + + public function key() + { + return $this->orderedIterator->key(); + } + + public function valid() + { + return $this->orderedIterator->valid(); + } + + public function rewind() + { + if ($this->orderedIterator == null) { + $this->orderItems(); + } + $this->orderedIterator->rewind(); + } + + public function orderItems() + { + $orderKeyFunc = $this->orderKeyFunc; + $direction = $this->direction; + + $itemIterator = $this->iterator; + $itemIterator->rewind(); + if (!$itemIterator->valid()) { + $this->orderedIterator = new ArrayIterator(); + return; + } + + $firstOrderKey = $orderKeyFunc($itemIterator->current()); + + $sortType = Helper\LinqHelper::LINQ_ORDER_TYPE_NUMERIC; + + if ($firstOrderKey instanceof \DateTime) { + $sortType = Helper\LinqHelper::LINQ_ORDER_TYPE_DATETIME; + } elseif (!is_numeric($firstOrderKey)) { + $sortType = Helper\LinqHelper::LINQ_ORDER_TYPE_ALPHANUMERIC; + } + + $keyMap = array(); + $valueMap = array(); + + foreach ($itemIterator as $value) { + $orderKey = $orderKeyFunc != null ? $orderKeyFunc($value) : $value; + if ($sortType == Helper\LinqHelper::LINQ_ORDER_TYPE_DATETIME) { + $orderKey = $orderKey->getTimeStamp(); + } + $keyMap[] = $orderKey; + $valueMap[] = $value; + } + + if ($sortType == Helper\LinqHelper::LINQ_ORDER_TYPE_DATETIME) { + $sortType = Helper\LinqHelper::LINQ_ORDER_TYPE_NUMERIC; + } + + if ($direction == Helper\LinqHelper::LINQ_ORDER_ASC) { + asort($keyMap, $sortType == Helper\LinqHelper::LINQ_ORDER_TYPE_NUMERIC ? SORT_NUMERIC : SORT_LOCALE_STRING); + } else { + arsort($keyMap, $sortType == Helper\LinqHelper::LINQ_ORDER_TYPE_NUMERIC ? SORT_NUMERIC : SORT_LOCALE_STRING); + } + + $sorted = new ArrayIterator(array()); + foreach ($keyMap as $key => $value) { + $sorted[] = $valueMap[$key]; + } + + $this->orderedIterator = $sorted; + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/SelectIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/SelectIterator.php new file mode 100644 index 00000000..aa698b3d --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/SelectIterator.php @@ -0,0 +1,37 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use InvalidArgumentException; +use Iterator; + +class SelectIterator extends \IteratorIterator +{ + private $selector; + + public function __construct(Iterator $iterator, $selector) + { + parent::__construct($iterator); + if ($selector === null) { + throw new InvalidArgumentException("Selector must not be null."); + } + + $this->selector = $selector; + } + + public function current() + { + $selector = $this->selector; + return $selector(parent::current()); + } +} diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/SelectManyIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/SelectManyIterator.php new file mode 100644 index 00000000..310b8bdb --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/SelectManyIterator.php @@ -0,0 +1,84 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use Iterator; +use Fusonic\Linq\Helper; + +class SelectManyIterator implements Iterator +{ + private $iterator; + private $currentIterator; + private $key = 0; + + public function __construct(Iterator $iterator) + { + $this->iterator = $iterator; + } + + public function current() + { + if ($this->currentIterator != null) { + return $this->currentIterator->current(); + } + + return null; + } + + public function next() + { + if ($this->currentIterator != null) { + $this->currentIterator->next(); + + if (!$this->currentIterator->valid()) { + $this->iterator->next(); + if ($this->iterator->valid()) { + $this->currentIterator = Helper\LinqHelper::getIteratorOrThrow($this->iterator->current()); + if ($this->currentIterator != null) { + $this->currentIterator->rewind(); + $this->key++; + } + } + } else { + $this->key++; + } + } + } + + public function key() + { + return $this->key; + } + + public function valid() + { + $current = $this->currentIterator; + return $current != null && $current->valid(); + } + + public function rewind() + { + $this->iterator->rewind(); + if ($this->iterator->valid()) { + $current = $this->iterator->current(); + $this->currentIterator = Helper\LinqHelper::getIteratorOrThrow($current); + if ($this->currentIterator != null) { + $this->currentIterator->rewind(); + } + } else { + $this->currentIterator = null; + } + + $this->key = 0; + } +}
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/Iterator/WhereIterator.php b/fusonic/linq/src/Fusonic/Linq/Iterator/WhereIterator.php new file mode 100644 index 00000000..1a757e00 --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Iterator/WhereIterator.php @@ -0,0 +1,34 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * https://github.com/fusonic/fusonic-linq + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq\Iterator; + +use Iterator; +use Fusonic\Linq\Helper; + +class WhereIterator extends \FilterIterator +{ + private $func; + + public function __construct(Iterator $iterator, $func) + { + parent::__construct($iterator); + $this->func = $func; + } + + public function accept() + { + $func = $this->func; + $current = $this->current(); + return Helper\LinqHelper::getBoolOrThrowException($func($current)); + } +}
\ No newline at end of file diff --git a/fusonic/linq/src/Fusonic/Linq/Linq.php b/fusonic/linq/src/Fusonic/Linq/Linq.php new file mode 100644 index 00000000..f9a56810 --- /dev/null +++ b/fusonic/linq/src/Fusonic/Linq/Linq.php @@ -0,0 +1,732 @@ +<?php + +/* + * This file is part of Fusonic-linq. + * + * (c) Fusonic GmbH + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Fusonic\Linq; + +use Countable; +use Fusonic\Linq\Iterator\ExceptIterator; +use Fusonic\Linq\Iterator\DistinctIterator; +use Fusonic\Linq\Iterator\GroupIterator; +use Fusonic\Linq\Iterator\IntersectIterator; +use Fusonic\Linq\Iterator\OfTypeIterator; +use Fusonic\Linq\Iterator\OrderIterator; +use Fusonic\Linq\Iterator\SelectIterator; +use Fusonic\Linq\Iterator\SelectManyIterator; +use Fusonic\Linq\Iterator\WhereIterator; +use Fusonic\Linq\Helper\LinqHelper; +use IteratorAggregate; +use Traversable; +use UnexpectedValueException; +use InvalidArgumentException; +use OutOfRangeException; + +/** + * Linq is a simple, powerful and consistent library for querying, projecting and aggregating data in php. + * + * @author David Roth <david.roth@fusonic.net>. + */ +class Linq implements IteratorAggregate, Countable +{ + private $iterator; + + /** + * Creates a new Linq object using the provided dataSource. + * + * @param array|\Iterator|IteratorAggregate $dataSource A Traversable sequence as data source. + */ + public function __construct($dataSource) + { + LinqHelper::assertArgumentIsIterable($dataSource, "dataSource"); + $dataSource = LinqHelper::getIteratorOrThrow($dataSource); + + $this->iterator = $dataSource; + } + + /** + * Creates a new Linq object using the provided dataDataSource. + * This is the recommended way for getting a new Linq instance. + * + * @param array|\Iterator|IteratorAggregate $dataSource A Traversable sequence as data source. + * @return Linq + */ + public static function from($dataSource) + { + return new Linq($dataSource); + } + + /** + * Generates a sequence of integral numbers within a specified range. + * + * @param $start The value of the first integer in the sequence. + * @param $count The number of sequential integers to generate. + * @return Linq An sequence that contains a range of sequential int numbers. + * @throws \OutOfRangeException + */ + public static function range($start, $count) + { + if ($count < 0) { + throw new OutOfRangeException('$count must be not be negative.'); + } + + return new Linq(range($start, $start + $count - 1)); + } + + /** + * Filters the Linq object according to func return result. + * + * @param callback $func A func that returns boolean + * @return Linq Filtered results according to $func + */ + public function where($func) + { + return new Linq(new WhereIterator($this->iterator, $func)); + } + + /** + * Filters the Linq object according to type. + * + * @param string $type + * + * @return Linq Filtered results according to $func + */ + public function ofType($type) + { + return new Linq(new OfTypeIterator($this->iterator, $type)); + } + + /** + * Bypasses a specified number of elements and then returns the remaining elements. + * + * @param int $count The number of elements to skip before returning the remaining elements. + * @return Linq A Linq object that contains the elements that occur after the specified index. + */ + public function skip($count) + { + // If its an array iterator we must check the arrays bounds are greater than the skip count. + // This is because the LimitIterator will use the seek() method which will throw an exception if $count > array.bounds. + $innerIterator = $this->iterator; + if ($innerIterator instanceof \ArrayIterator) { + if ($count >= $innerIterator->count()) { + return new Linq(array()); + } + } + + return new Linq(new \LimitIterator($innerIterator, $count, -1)); + } + + /** + * Returns a specified number of contiguous elements from the start of a sequence + * + * @param int $count The number of elements to return. + * @return Linq A Linq object that contains the specified number of elements from the start. + */ + public function take($count) + { + if ($count == 0) { + return new Linq(array()); + } + + return new Linq(new \LimitIterator($this->iterator, 0, $count)); + } + + /** + * Applies an accumulator function over a sequence. + * The aggregate method makes it simple to perform a calculation over a sequence of values. + * This method works by calling $func one time for each element. + * The first element of source is used as the initial aggregate value if $seed parameter is not specified. + * If $seed is specified, this value will be used as the first value. + * + * @param callback $func An accumulator function to be invoked on each element. + * @param mixed $seed + * @throws \RuntimeException if the input sequence contains no elements. + * @return mixed Returns the final result of $func. + */ + public function aggregate($func, $seed = null) + { + $result = null; + $first = true; + + if ($seed !== null) { + $result = $seed; + $first = false; + } + + foreach ($this->iterator as $current) { + if (!$first) { + $result = $func($result, $current); + } else { + $result = $current; + $first = false; + } + } + if ($first) { + throw new \RuntimeException("The input sequence contains no elements."); + } + return $result; + } + + /** + * Splits the sequence in chunks according to $chunksize. + * + * @param $chunksize Specifies how many elements are grouped together per chunk. + * @throws \InvalidArgumentException + * @return Linq + */ + public function chunk($chunksize) + { + if ($chunksize < 1) { + throw new \InvalidArgumentException("chunksize", $chunksize); + } + + $i = -1; + return $this->select( + function ($x) use (&$i) { + $i++; + return array("index" => $i, "value" => $x); + } + ) + ->groupBy( + function ($pair) use ($chunksize) { + return $pair["index"] / $chunksize; + } + ) + ->select( + function (GroupedLinq $group) { + return $group->select( + function ($v) { + return $v["value"]; + } + ); + } + ); + } + + /** + * Determines whether all elements satisfy a condition. + * + * @param callback $func A function to test each element for a condition. + * @return bool True if every element passes the test in the specified func, or if the sequence is empty; otherwise, false. + */ + public function all($func) + { + foreach ($this->iterator as $current) { + $match = LinqHelper::getBoolOrThrowException($func($current)); + if (!$match) { + return false; + } + } + return true; + } + + /** + * Determines whether any element exists or satisfies a condition by invoking $func. + * + * @param callback $func A function to test each element for a condition or NULL to determine if any element exists. + * @return bool True if no $func given and the source sequence contains any elements or True if any elements passed the test in the specified func; otherwise, false. + */ + public function any($func = null) + { + foreach ($this->iterator as $current) { + if ($func === null) { + return true; + } + + $match = LinqHelper::getBoolOrThrowException($func($current)); + if ($match) { + return true; + } + } + return false; + } + + /** + * Counts the elements of this Linq sequence. + * @return int + */ + public function count() + { + if ($this->iterator instanceof Countable) { + return $this->iterator->count(); + } + + return iterator_count($this->iterator); + } + + /** + * Computes the average of all numeric values. Uses $func to obtain the value on each element. + * + * @param callback $func A func that returns any numeric type (int, float etc.) + * @throws \UnexpectedValueException if an item of the sequence is not a numeric value. + * @return double Average of items + */ + public function average($func = null) + { + $resultTotal = 0; + $itemCount = 0; + + $source = $this->getSelectIteratorOrInnerIterator($func); + + foreach ($source as $item) { + if (!is_numeric($item)) { + throw new UnexpectedValueException("Cannot calculate an average on a none numeric value"); + } + + $resultTotal += $item; + $itemCount++; + } + return $itemCount == 0 ? 0 : ($resultTotal / $itemCount); + } + + /** + * Sorts the elements in ascending order according to a key provided by $func. + * + * @param callback $func A function to extract a key from an element. + * @return Linq A new Linq instance whose elements are sorted ascending according to a key. + */ + public function orderBy($func) + { + return $this->order($func, LinqHelper::LINQ_ORDER_ASC); + } + + /** + * Sorts the elements in descending order according to a key provided by $func. + * + * @param callback $func A function to extract a key from an element. + * @return Linq A new Linq instance whose elements are sorted descending according to a key. + */ + public function orderByDescending($func) + { + return $this->order($func, LinqHelper::LINQ_ORDER_DESC); + } + + private function order($func, $direction = LinqHelper::LINQ_ORDER_ASC) + { + return new Linq(new OrderIterator($this->iterator, $func, $direction)); + } + + /** + * Gets the sum of all items or by invoking a transform function on each item to get a numeric value. + * + * @param callback $func A func that returns any numeric type (int, float etc.) from the given element, or NULL to use the element itself. + * @throws \UnexpectedValueException if any element is not a numeric value. + * @return double The sum of all items. + */ + public function sum($func = null) + { + $sum = 0; + $iterator = $this->getSelectIteratorOrInnerIterator($func); + foreach ($iterator as $value) { + if (!is_numeric($value)) { + throw new UnexpectedValueException("sum() only works on numeric values."); + } + + $sum += $value; + } + return $sum; + } + + /** + * Gets the minimum item value of all items or by invoking a transform function on each item to get a numeric value. + * + * @param callback $func A func that returns any numeric type (int, float etc.) from the given element, or NULL to use the element itself. + * @throws \RuntimeException if the sequence contains no elements + * @throws \UnexpectedValueException + * @return double Minimum item value + */ + public function min($func = null) + { + $min = null; + $iterator = $this->getSelectIteratorOrInnerIterator($func); + foreach ($iterator as $value) { + if (!is_numeric($value) && !is_string($value) && !($value instanceof \DateTime)) { + throw new UnexpectedValueException("min() only works on numeric values, strings and DateTime objects."); + } + + if (is_null($min)) { + $min = $value; + } elseif ($min > $value) { + $min = $value; + } + } + + if ($min === null) { + throw new \RuntimeException("Cannot calculate min() as the Linq sequence contains no elements."); + } + + return $min; + } + + /** + * Returns the maximum item value according to $func + * + * @param callback $func A func that returns any numeric type (int, float etc.) + * @throws \RuntimeException if the sequence contains no elements + * @throws \UnexpectedValueException if any element is not a numeric value or a string. + * @return double Maximum item value + */ + public function max($func = null) + { + $max = null; + $iterator = $this->getSelectIteratorOrInnerIterator($func); + foreach ($iterator as $value) { + if (!is_numeric($value) && !is_string($value) && !($value instanceof \DateTime)) { + throw new UnexpectedValueException("max() only works on numeric values, strings and DateTime objects."); + } + + if (is_null($max)) { + $max = $value; + } elseif ($max < $value) { + $max = $value; + } + } + + if ($max === null) { + throw new \RuntimeException("Cannot calculate max() as the Linq sequence contains no elements."); + } + + return $max; + } + + /** + * Projects each element into a new form by invoking the selector function. + * + * @param callback $func A transform function to apply to each element. + * @return Linq A new Linq object whose elements are the result of invoking the transform function on each element of the original Linq object. + */ + public function select($func) + { + return new Linq(new SelectIterator($this->iterator, $func)); + } + + /** + * Projects each element of a sequence to a new Linq and flattens the resulting sequences into one sequence. + * + * @param callback $func A func that returns a sequence (array, Linq, Iterator). + * @throws \UnexpectedValueException if an element is not a traversable sequence. + * @return Linq A new Linq object whose elements are the result of invoking the one-to-many transform function on each element of the input sequence. + */ + public function selectMany($func) + { + return new Linq(new SelectManyIterator(new SelectIterator($this->iterator, $func))); + } + + /** + * Performs the specified action on each element of the Linq sequence and returns the Linq sequence. + * @param callback $func A func that will be evaluated for each item in the linq sequence. + * @return Linq The original Linq sequence that was used to perform the foreach. + */ + public function each($func) + { + foreach ($this->iterator as $item) { + $func($item); + } + return $this; + } + + /** + * Determines whether a sequence contains a specified element. + * This function will use php strict comparison (===). If you need custom comparison use the Linq::any($func) method. + * + * @param mixed $value The value to locate in the sequence. + * @return bool True if $value is found within the sequence; otherwise false. + */ + public function contains($value) + { + return $this->any( + function ($x) use ($value) { + return $x === $value; + } + ); + } + + /** + * Concatenates this Linq object with the given sequence. + * + * @param array|\Iterator $second A sequence which will be concatenated with this Linq object. + * @throws InvalidArgumentException if the given sequence is not traversable. + * @return Linq A new Linq object that contains the concatenated elements of the input sequence and the original Linq sequence. + */ + public function concat($second) + { + LinqHelper::assertArgumentIsIterable($second, "second"); + + $allItems = new \ArrayIterator(array($this->iterator, $second)); + + return new Linq(new SelectManyIterator($allItems)); + } + + /** + * Returns distinct item values of this + * + * @param callback $func + * @return Linq Distinct item values of this + */ + public function distinct($func = null) + { + return new Linq(new DistinctIterator($this->getSelectIteratorOrInnerIterator($func))); + } + + /** + * Intersects the Linq sequence with second Iterable sequence. + * + * @param \Iterator|array An iterator to intersect with: + * @return Linq intersected items + */ + public function intersect($second) + { + LinqHelper::assertArgumentIsIterable($second, "second"); + return new Linq(new IntersectIterator($this->iterator, LinqHelper::getIteratorOrThrow($second))); + } + + /** + * Returns all elements except the ones of the given sequence. + * + * @param array|\Iterator $second + * @return Linq Returns all items of this not occuring in $second + */ + public function except($second) + { + LinqHelper::assertArgumentIsIterable($second, "second"); + return new Linq(new ExceptIterator($this->iterator, LinqHelper::getIteratorOrThrow($second))); + } + + /** + * Returns the element at a specified index. + * This method throws an exception if index is out of range. + * To instead return NULL when the specified index is out of range, use the elementAtOrNull method. + * + * @throws \OutOfRangeException if index is less than 0 or greater than or equal to the number of elements in the sequence. + * @param int $index + * @return mixed Item at $index + */ + public function elementAt($index) + { + return $this->getValueAt($index, true); + } + + /** + * Returns the element at a specified index or NULL if the index is out of range. + * + * @param $index + * @return mixed Item at $index + */ + public function elementAtOrNull($index) + { + return $this->getValueAt($index, false); + } + + private function getValueAt($index, $throwEx) + { + $i = 0; + foreach ($this->iterator as $value) { + if ($i == $index) { + return $value; + } + $i++; + } + + if ($throwEx) { + throw new OutOfRangeException("Index is less than 0 or greater than or equal to the number of elements in the sequence."); + } + + return null; + } + + /** + * Groups the object according to the $func generated key + * + * @param callback $keySelector a func that returns an item as key, item can be any type. + * @return GroupedLinq + */ + public function groupBy($keySelector) + { + return new Linq(new GroupIterator($this->iterator, $keySelector)); + } + + /** + * Returns the last element that satisfies a specified condition. + * @throws \RuntimeException if no element satisfies the condition in predicate or the source sequence is empty. + * + * @param callback $func a func that returns boolean. + * @return Object Last item in this + */ + public function last($func = null) + { + return $this->getLast($func, true); + } + + /** + * Returns the last element that satisfies a condition or NULL if no such element is found. + * + * @param callback $func a func that returns boolean. + * @return mixed + */ + public function lastOrNull($func = null) + { + return $this->getLast($func, false); + } + + /** + * Returns the first element that satisfies a specified condition + * @throws \RuntimeException if no element satisfies the condition in predicate -or- the source sequence is empty / does not match any elements. + * + * @param callback $func a func that returns boolean. + * @return mixed + */ + public function first($func = null) + { + return $this->getFirst($func, true); + } + + /** + * Returns the first element, or NULL if the sequence contains no elements. + * + * @param callback $func a func that returns boolean. + * @return mixed + */ + public function firstOrNull($func = null) + { + return $this->getFirst($func, false); + } + + /** + * Returns the only element that satisfies a specified condition. + * + * @throws \RuntimeException if no element exists or if more than one element exists. + * @param callback $func a func that returns boolean. + * @return mixed + */ + public function single($func = null) + { + return $this->getSingle($func, true); + } + + /** + * Returns the only element that satisfies a specified condition or NULL if no such element exists. + * + * @throws \RuntimeException if more than one element satisfies the condition. + * @param callback $func a func that returns boolean. + * @return mixed + */ + public function singleOrNull($func = null) + { + return $this->getSingle($func, false); + } + + + private function getWhereIteratorOrInnerIterator($func) + { + return $func === null ? $this->iterator : new WhereIterator($this->iterator, $func); + } + + private function getSelectIteratorOrInnerIterator($func) + { + return $func === null ? $this->iterator : new SelectIterator($this->iterator, $func); + } + + private function getSingle($func, $throw) + { + $source = $this->getWhereIteratorOrInnerIterator($func); + + $count = 0; + $single = null; + + foreach ($source as $stored) { + $count++; + + if ($count > 1) { + throw new \RuntimeException("The input sequence contains more than 1 elements."); + } + + $single = $stored; + } + + if ($count == 0 && $throw) { + throw new \RuntimeException("The input sequence contains no matching element."); + } + + return $single; + } + + private function getFirst($func, $throw) + { + $source = $this->getWhereIteratorOrInnerIterator($func); + + $count = 0; + $first = null; + + foreach ($source as $stored) { + $count++; + $first = $stored; + break; + } + + if ($count == 0 && $throw) { + throw new \RuntimeException("The input sequence contains no matching element."); + } + + return $first; + } + + private function getLast($func, $throw) + { + $source = $this->getWhereIteratorOrInnerIterator($func); + + $count = 0; + $last = null; + + foreach ($source as $stored) { + $count++; + $last = $stored; + } + + if ($count == 0 && $throw) { + throw new \RuntimeException("The input sequence contains no matching element."); + } + + return $last; + } + + /** + * Creates an Array from this Linq object with key/value selector(s). + * + * @param callback $keySelector a func that returns the array-key for each element. + * @param callback $valueSelector a func that returns the array-value for each element. + * + * @return Array An array with all values. + */ + public function toArray($keySelector = null, $valueSelector = null) + { + if ($keySelector === null && $valueSelector === null) { + return iterator_to_array($this, false); + } elseif ($keySelector == null) { + return iterator_to_array(new SelectIterator($this->getIterator(), $valueSelector), false); + } else { + $array = array(); + foreach ($this as $value) { + $key = $keySelector($value); + $array[$key] = $valueSelector == null ? $value : $valueSelector($value); + } + return $array; + } + } + + /** + * Retrieves the iterator of this Linq class. + * @link http://php.net/manual/en/iteratoraggregate.getiterator.php + * @return Traversable An instance of an object implementing <b>Iterator</b> or + * <b>Traversable</b> + */ + public function getIterator() + { + return $this->iterator; + } +}
\ No newline at end of file diff --git a/fusonic/opengraph/LICENSE b/fusonic/opengraph/LICENSE new file mode 100644 index 00000000..46c173a3 --- /dev/null +++ b/fusonic/opengraph/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2014 Fusonic GmbH (http://www.fusonic.net) + +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/fusonic/opengraph/src/Consumer.php b/fusonic/opengraph/src/Consumer.php new file mode 100644 index 00000000..25b03feb --- /dev/null +++ b/fusonic/opengraph/src/Consumer.php @@ -0,0 +1,156 @@ +<?php + +namespace Fusonic\OpenGraph; + +use DOMElement; +use Fusonic\Linq\Linq; +use Fusonic\OpenGraph\Objects\ObjectBase; +use Fusonic\OpenGraph\Objects\Website; +use LogicException; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Symfony\Component\DomCrawler\Crawler; + +/** + * Consumer that extracts Open Graph data from either a URL or a HTML string. + */ +class Consumer +{ + private ?ClientInterface $client; + private ?RequestFactoryInterface $requestFactory; + + /** + * When enabled, crawler will read content of title and meta description if no + * Open Graph data is provided by target page. + */ + public bool $useFallbackMode = false; + + /** + * When enabled, crawler will throw exceptions for some crawling errors like unexpected + * Open Graph elements. + */ + public bool $debug = false; + + /** + * @param ClientInterface|null $client A PSR-18 ClientInterface implementation. + * @param RequestFactoryInterface|null $requestFactory A PSR-17 RequestFactoryInterface implementation. + */ + public function __construct(?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null) + { + $this->client = $client; + $this->requestFactory = $requestFactory; + } + + /** + * Fetches HTML content from the given URL and then crawls it for Open Graph data. + * + * @param string $url URL to be crawled. + * + * @return ObjectBase + * + * @throws ClientExceptionInterface + */ + public function loadUrl(string $url): ObjectBase + { + if ($this->client === null) { + throw new LogicException( + "To use loadUrl() you must provide \$client and \$requestFactory when instantiating the consumer." + ); + } + + $request = $this->requestFactory->createRequest("GET", $url); + $response = $this->client->sendRequest($request); + + return $this->loadHtml($response->getBody()->getContents(), $url); + } + + /** + * Crawls the given HTML string for OpenGraph data. + * + * @param string $html HTML string, usually whole content of crawled web resource. + * @param string $fallbackUrl URL to use when fallback mode is enabled. + * + * @return ObjectBase + */ + public function loadHtml(string $html, string $fallbackUrl = null): ObjectBase + { + // Extract all data that can be found + $page = $this->extractOpenGraphData($html); + + // Use the user's URL as fallback + if ($this->useFallbackMode && $page->url === null) { + $page->url = $fallbackUrl; + } + + // Return result + return $page; + } + + private function extractOpenGraphData(string $content): ObjectBase + { + $crawler = new Crawler; + $crawler->addHTMLContent($content, 'UTF-8'); + + $properties = []; + foreach(['name', 'property'] as $t) + { + // Get all meta-tags starting with "og:" + $ogMetaTags = $crawler->filter("meta[{$t}^='og:']"); + // Create clean property array + $props = Linq::from($ogMetaTags) + ->select( + function (DOMElement $tag) use ($t) { + $name = strtolower(trim($tag->getAttribute($t))); + $value = trim($tag->getAttribute("content")); + return new Property($name, $value); + } + ) + ->toArray(); + $properties = array_merge($properties, $props); + + } + + // Create new object of the correct type + $typeProperty = Linq::from($properties) + ->firstOrNull( + function (Property $property) { + return $property->key === Property::TYPE; + } + ); + switch ($typeProperty !== null ? $typeProperty->value : null) { + default: + $object = new Website(); + break; + } + + // Assign all properties to the object + $object->assignProperties($properties, $this->debug); + + // Fallback for url + if ($this->useFallbackMode && !$object->url) { + $urlElement = $crawler->filter("link[rel='canonical']")->first(); + if ($urlElement->count() > 0) { + $object->url = trim($urlElement->attr("href")); + } + } + + // Fallback for title + if ($this->useFallbackMode && !$object->title) { + $titleElement = $crawler->filter("title")->first(); + if ($titleElement->count() > 0) { + $object->title = trim($titleElement->text()); + } + } + + // Fallback for description + if ($this->useFallbackMode && !$object->description) { + $descriptionElement = $crawler->filter("meta[property='description']")->first(); + if ($descriptionElement->count() > 0) { + $object->description = trim($descriptionElement->attr("content")); + } + } + + return $object; + } +} diff --git a/fusonic/opengraph/src/Elements/Audio.php b/fusonic/opengraph/src/Elements/Audio.php new file mode 100644 index 00000000..20df532d --- /dev/null +++ b/fusonic/opengraph/src/Elements/Audio.php @@ -0,0 +1,67 @@ +<?php + +namespace Fusonic\OpenGraph\Elements; + +use Fusonic\OpenGraph\Property; + +/** + * An Open Graph audio element. + */ +class Audio extends ElementBase +{ + /** + * The URL of an audio resource associated with the object. + * + * @var string + */ + public $url; + + /** + * An alternate URL to use if an audio resource requires HTTPS. + * + * @var string + */ + public $secureUrl; + + /** + * The MIME type of an audio resource associated with the object. + * + * @var type + */ + public $type; + + /** + * @param string $url URL to the audio file. + */ + public function __construct($url) + { + parent::__construct(); + + $this->url = $url; + } + + /** + * Gets all properties set on this element. + * + * @return array|Property[] + */ + public function getProperties() + { + $properties = []; + + // URL must precede all other properties + if ($this->url !== null) { + $properties[] = new Property(Property::AUDIO_URL, $this->url); + } + + if ($this->secureUrl !== null) { + $properties[] = new Property(Property::AUDIO_SECURE_URL, $this->secureUrl); + } + + if ($this->type !== null) { + $properties[] = new Property(Property::AUDIO_TYPE, $this->type); + } + + return $properties; + } +} diff --git a/fusonic/opengraph/src/Elements/ElementBase.php b/fusonic/opengraph/src/Elements/ElementBase.php new file mode 100644 index 00000000..dd4139dc --- /dev/null +++ b/fusonic/opengraph/src/Elements/ElementBase.php @@ -0,0 +1,20 @@ +<?php + +namespace Fusonic\OpenGraph\Elements; + +/** + * Abstract base class for all OpenGraph elements (e.g. images, videos etc.) + */ +abstract class ElementBase +{ + protected function __construct() + { + } + + /** + * Gets all properties set on this element. + * + * @return array|Property[] + */ + abstract public function getProperties(); +} diff --git a/fusonic/opengraph/src/Elements/Image.php b/fusonic/opengraph/src/Elements/Image.php new file mode 100644 index 00000000..831a0c4a --- /dev/null +++ b/fusonic/opengraph/src/Elements/Image.php @@ -0,0 +1,100 @@ +<?php + +namespace Fusonic\OpenGraph\Elements; + +use Fusonic\OpenGraph\Property; + +/** + * An Open Graph image element. + */ +class Image extends ElementBase +{ + /** + * The URL of an image resource associated with the object. + * + * @var string + */ + public $url; + + /** + * An alternate URL to use if an image resource requires HTTPS. + * + * @var string + */ + public $secureUrl; + + /** + * The MIME type of an image resource. + * + * @var type + */ + public $type; + + /** + * The width of an image resource in pixels. + * + * @var int + */ + public $width; + + /** + * The height of an image resource in pixels. + * + * @var int + */ + public $height; + + /** + * Whether the image is user-generated or not. + * + * @var bool + */ + public $userGenerated; + + /** + * @param string $url URL to the image file. + */ + public function __construct($url) + { + parent::__construct(); + + $this->url = $url; + } + + /** + * Gets all properties set on this element. + * + * @return array|Property[] + */ + public function getProperties() + { + $properties = []; + + // URL must precede all other properties + if ($this->url !== null) { + $properties[] = new Property(Property::IMAGE_URL, $this->url); + } + + if ($this->height !== null) { + $properties[] = new Property(Property::IMAGE_HEIGHT, $this->height); + } + + if ($this->secureUrl !== null) { + $properties[] = new Property(Property::IMAGE_SECURE_URL, $this->secureUrl); + } + + if ($this->type !== null) { + $properties[] = new Property(Property::IMAGE_TYPE, $this->type); + } + + if ($this->width !== null) { + $properties[] = new Property(Property::IMAGE_WIDTH, $this->width); + } + + if ($this->userGenerated !== null) { + $properties[] = new Property(Property::IMAGE_USER_GENERATED, $this->userGenerated); + } + + return $properties; + } +} diff --git a/fusonic/opengraph/src/Elements/Video.php b/fusonic/opengraph/src/Elements/Video.php new file mode 100644 index 00000000..ff5a3305 --- /dev/null +++ b/fusonic/opengraph/src/Elements/Video.php @@ -0,0 +1,89 @@ +<?php + +namespace Fusonic\OpenGraph\Elements; + +use Fusonic\OpenGraph\Property; + +/** + * An OpenGraph video element. + */ +class Video extends ElementBase +{ + /** + * The URL of a video resource associated with the object. + * + * @var string + */ + public $url; + + /** + * An alternate URL to use if a video resource requires HTTPS. + * + * @var string + */ + public $secureUrl; + + /** + * The MIME type of a video resource associated with the object. + * + * @var type + */ + public $type; + + /** + * The width of a video resource associated with the object in pixels. + * + * @var int + */ + public $width; + + /** + * The height of a video resource associated with the object in pixels. + * + * @var int + */ + public $height; + + /** + * @param string $url URL to the video. + */ + public function __construct($url) + { + parent::__construct(); + + $this->url = $url; + } + + /** + * Gets all properties set on this element. + * + * @return array|Property[] + */ + public function getProperties() + { + $properties = []; + + // URL must precede all other properties + if ($this->url !== null) { + $properties[] = new Property(Property::VIDEO_URL, $this->url); + } + + if ($this->height !== null) { + $properties[] = new Property(Property::VIDEO_HEIGHT, $this->height); + } + + if ($this->secureUrl !== null) { + $properties[] = new Property(Property::VIDEO_SECURE_URL, $this->secureUrl); + } + + if ($this->type !== null) { + $properties[] = new Property(Property::VIDEO_TYPE, $this->type); + } + + if ($this->width !== null) { + $properties[] = new Property(Property::VIDEO_WIDTH, $this->width); + } + + return $properties; + } +} diff --git a/fusonic/opengraph/src/Objects/ObjectBase.php b/fusonic/opengraph/src/Objects/ObjectBase.php new file mode 100644 index 00000000..33b9976d --- /dev/null +++ b/fusonic/opengraph/src/Objects/ObjectBase.php @@ -0,0 +1,358 @@ +<?php + +namespace Fusonic\OpenGraph\Objects; + +use DateTimeImmutable; +use Fusonic\OpenGraph\Elements\Audio; +use Fusonic\OpenGraph\Elements\Image; +use Fusonic\OpenGraph\Elements\Video; +use Fusonic\OpenGraph\Property; +use UnexpectedValueException; + +/** + * Abstract base class for all Open Graph objects (website, video, ...) + */ +abstract class ObjectBase +{ + /** + * An array of audio resources attached to the object. + * + * @var Audio[] + */ + public array $audios = []; + + /** + * A short description of the object. + */ + public ?string $description = null; + + /** + * The word that appears before the object's title in a sentence. This is an list of words from 'a', 'an', 'the', + * ' "" ', or 'auto'. If 'auto' is chosen, the consumer of the object will chose between 'a' or 'an'. The default is + * the blank, "". + */ + public ?string $determiner = null; + + /** + * An array of images attached to the object. + * + * @var Image[] + */ + public array $images = []; + + /** + * The locale that the object's tags are marked up in, in the format language_TERRITORY. + */ + public ?string $locale = null; + + /** + * An array of alternate locales in which the resource is available. + * + * @var string[] + */ + public array $localeAlternate = []; + + public ?bool $richAttachment = null; + + /** + * An array of URLs of related resources. + * + * @var string[] + */ + public array $seeAlso = []; + + /** + * The name of the web site upon which the object resides. + */ + public ?string $siteName = null; + + /** + * The title of the object as it should appear in the graph. + */ + public ?string $title = null; + + /** + * The type of the object, such as 'article'. + */ + public ?string $type = null; + + /** + * The time when the object was last updated. + */ + public ?DateTimeImmutable $updatedTime = null; + + /** + * The canonical URL of the object, used as its ID in the graph. + */ + public ?string $url = null; + + /** + * An array of videos attached to the object. + * + * @var Video[] + */ + public array $videos = []; + + public function __construct() + { + } + + /** + * Assigns all properties given to the this Object instance. + * + * @param array|Property[] $properties Array of all properties to assign. + * @param bool $debug Throw exceptions when parsing or not. + * + * @throws UnexpectedValueException + */ + public function assignProperties(array $properties, $debug = false): void + { + foreach ($properties as $property) { + $name = $property->key; + $value = $property->value; + + switch($name) { + case Property::AUDIO: + case Property::AUDIO_URL: + $this->audios[] = new Audio($value); + break; + case Property::AUDIO_SECURE_URL: + case Property::AUDIO_TYPE: + if (count($this->audios) > 0) { + $this->handleAudioAttribute($this->audios[count($this->audios) - 1], $name, $value); + } elseif ($debug) { + throw new UnexpectedValueException( + sprintf( + "Found '%s' property but no audio was found before.", + $name + ) + ); + } + break; + case Property::DESCRIPTION: + if ($this->description === null) { + $this->description = $value; + } + break; + case Property::DETERMINER: + if ($this->determiner === null) { + $this->determiner = $value; + } + break; + case Property::IMAGE: + case Property::IMAGE_URL: + $this->images[] = new Image($value); + break; + case Property::IMAGE_HEIGHT: + case Property::IMAGE_SECURE_URL: + case Property::IMAGE_TYPE: + case Property::IMAGE_WIDTH: + case Property::IMAGE_USER_GENERATED: + if (count($this->images) > 0) { + $this->handleImageAttribute($this->images[count($this->images) - 1], $name, $value); + } elseif ($debug) { + throw new UnexpectedValueException( + sprintf( + "Found '%s' property but no image was found before.", + $name + ) + ); + } + break; + case Property::LOCALE: + if ($this->locale === null) { + $this->locale = $value; + } + break; + case Property::LOCALE_ALTERNATE: + $this->localeAlternate[] = $value; + break; + case Property::RICH_ATTACHMENT: + $this->richAttachment = $this->convertToBoolean($value); + break; + case Property::SEE_ALSO: + $this->seeAlso[] = $value; + break; + case Property::SITE_NAME: + if ($this->siteName === null) { + $this->siteName = $value; + } + break; + case Property::TITLE: + if ($this->title === null) { + $this->title = $value; + } + break; + case Property::UPDATED_TIME: + if ($this->updatedTime === null) { + $this->updatedTime = $this->convertToDateTime($value); + } + break; + case Property::URL: + if ($this->url === null) { + $this->url = $value; + } + break; + case Property::VIDEO: + case Property::VIDEO_URL: + $this->videos[] = new Video($value); + break; + case Property::VIDEO_HEIGHT: + case Property::VIDEO_SECURE_URL: + case Property::VIDEO_TYPE: + case Property::VIDEO_WIDTH: + if (count($this->videos) > 0) { + $this->handleVideoAttribute($this->videos[count($this->videos) - 1], $name, $value); + } elseif ($debug) { + throw new UnexpectedValueException(sprintf( + "Found '%s' property but no video was found before.", + $name + )); + } + } + } + } + + private function handleImageAttribute(Image $element, string $name, string $value): void + { + switch($name) + { + case Property::IMAGE_HEIGHT: + $element->height = (int)$value; + break; + case Property::IMAGE_WIDTH: + $element->width = (int)$value; + break; + case Property::IMAGE_TYPE: + $element->type = $value; + break; + case Property::IMAGE_SECURE_URL: + $element->secureUrl = $value; + break; + case Property::IMAGE_USER_GENERATED: + $element->userGenerated = $this->convertToBoolean($value); + break; + } + } + + private function handleVideoAttribute(Video $element, string $name, string $value): void + { + switch($name) + { + case Property::VIDEO_HEIGHT: + $element->height = (int)$value; + break; + case Property::VIDEO_WIDTH: + $element->width = (int)$value; + break; + case Property::VIDEO_TYPE: + $element->type = $value; + break; + case Property::VIDEO_SECURE_URL: + $element->secureUrl = $value; + break; + } + } + + private function handleAudioAttribute(Audio $element, string $name, string $value): void + { + switch($name) + { + case Property::AUDIO_TYPE: + $element->type = $value; + break; + case Property::AUDIO_SECURE_URL: + $element->secureUrl = $value; + break; + } + } + + protected function convertToDateTime(string $value): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($value); + } catch (\Exception $e) { + return null; + } + } + + protected function convertToBoolean(string $value): bool + { + switch(strtolower($value)) + { + case "1": + case "true": + return true; + default: + return false; + } + } + + /** + * Gets all properties set on this object. + * + * @return Property[] + */ + public function getProperties(): array + { + $properties = []; + + foreach ($this->audios as $audio) { + $properties = array_merge($properties, $audio->getProperties()); + } + + if ($this->title !== null) { + $properties[] = new Property(Property::TITLE, $this->title); + } + + if ($this->description !== null) { + $properties[] = new Property(Property::DESCRIPTION, $this->description); + } + + if ($this->determiner !== null) { + $properties[] = new Property(Property::DETERMINER, $this->determiner); + } + + foreach ($this->images as $image) { + $properties = array_merge($properties, $image->getProperties()); + } + + if ($this->locale !== null) { + $properties[] = new Property(Property::LOCALE, $this->locale); + } + + foreach ($this->localeAlternate as $locale) { + $properties[] = new Property(Property::LOCALE_ALTERNATE, $locale); + } + + if ($this->richAttachment !== null) { + $properties[] = new Property(Property::RICH_ATTACHMENT, (int)$this->richAttachment); + } + + foreach ($this->seeAlso as $seeAlso) { + $properties[] = new Property(Property::SEE_ALSO, $seeAlso); + } + + if ($this->siteName !== null) { + $properties[] = new Property(Property::SITE_NAME, $this->siteName); + } + + if ($this->type !== null) { + $properties[] = new Property(Property::TYPE, $this->type); + } + + if ($this->updatedTime !== null) { + $properties[] = new Property(Property::UPDATED_TIME, $this->updatedTime->format("c")); + } + + if ($this->url !== null) { + $properties[] = new Property(Property::URL, $this->url); + } + + foreach ($this->videos as $video) { + $properties = array_merge($properties, $video->getProperties()); + } + + return $properties; + } +} diff --git a/fusonic/opengraph/src/Objects/Website.php b/fusonic/opengraph/src/Objects/Website.php new file mode 100644 index 00000000..f66428cc --- /dev/null +++ b/fusonic/opengraph/src/Objects/Website.php @@ -0,0 +1,21 @@ +<?php + +namespace Fusonic\OpenGraph\Objects; + +/** + * This object type represents a website. It is a simple object type and uses only common Open Graph properties. For + * specific pages within a website, the article object type should be used. + * + * https://developers.facebook.com/docs/reference/opengraph/object-type/website/ + */ +class Website extends ObjectBase +{ + const TYPE = "website"; + + public function __construct() + { + parent::__construct(); + + $this->type = self::TYPE; + } +} diff --git a/fusonic/opengraph/src/Property.php b/fusonic/opengraph/src/Property.php new file mode 100644 index 00000000..e94593bd --- /dev/null +++ b/fusonic/opengraph/src/Property.php @@ -0,0 +1,54 @@ +<?php + +namespace Fusonic\OpenGraph; + +/** + * Class holding data for a single Open Graph property on a web page. + */ +class Property +{ + const AUDIO = "og:audio"; + const AUDIO_SECURE_URL = "og:audio:secure_url"; + const AUDIO_TYPE = "og:audio:type"; + const AUDIO_URL = "og:audio:url"; + const DESCRIPTION = "og:description"; + const DETERMINER = "og:determiner"; + const IMAGE = "og:image"; + const IMAGE_HEIGHT = "og:image:height"; + const IMAGE_SECURE_URL = "og:image:secure_url"; + const IMAGE_TYPE = "og:image:type"; + const IMAGE_URL = "og:image:url"; + const IMAGE_WIDTH = "og:image:width"; + const IMAGE_USER_GENERATED = "og:image:user_generated"; + const LOCALE = "og:locale"; + const LOCALE_ALTERNATE = "og:locale:alternate"; + const RICH_ATTACHMENT = "og:rich_attachment"; + const SEE_ALSO = "og:see_also"; + const SITE_NAME = "og:site_name"; + const TITLE = "og:title"; + const TYPE = "og:type"; + const UPDATED_TIME = "og:updated_time"; + const URL = "og:url"; + const VIDEO = "og:video"; + const VIDEO_HEIGHT = "og:video:height"; + const VIDEO_SECURE_URL = "og:video:secure_url"; + const VIDEO_TYPE = "og:video:type"; + const VIDEO_URL = "og:video:url"; + const VIDEO_WIDTH = "og:video:width"; + + /** + * Key of the property without "og:" prefix. + */ + public string $key; + + /** + * Value of the property. + */ + public $value; + + public function __construct(string $key, $value) + { + $this->key = $key; + $this->value = $value; + } +} diff --git a/fusonic/opengraph/src/Publisher.php b/fusonic/opengraph/src/Publisher.php new file mode 100644 index 00000000..059363ab --- /dev/null +++ b/fusonic/opengraph/src/Publisher.php @@ -0,0 +1,65 @@ +<?php + +namespace Fusonic\OpenGraph; + +use DateTimeInterface; +use Fusonic\OpenGraph\Objects\ObjectBase; +use UnexpectedValueException; + +/** + * Class for generating Open Graph tags from objects. + */ +class Publisher +{ + const DOCTYPE_HTML5 = 1; + const DOCTYPE_XHTML = 2; + + /** + * Defines the style in which HTML tags should be written. Use one of Publisher::DOCTYPE_HTML5 or + * Publisher::DOCTYPE_XHTML. + */ + public int $doctype = self::DOCTYPE_HTML5; + + public function __construct() + { + } + + /** + * Generated HTML tags from the given object. + */ + public function generateHtml(ObjectBase $object): string + { + $html = ""; + $format = "<meta property=\"%s\" content=\"%s\"" . ($this->doctype == self::DOCTYPE_XHTML ? " />" : ">"); + + foreach ($object->getProperties() as $property) { + if ($html !== "") { + $html .= "\n"; + } + + if ($property->value === null) { + continue; + } elseif ($property->value instanceof DateTimeInterface) { + $value = $property->value->format("c"); + } elseif (is_object($property->value)) { + throw new UnexpectedValueException( + sprintf( + "Cannot handle value of type '%s' for property '%s'.", + get_class($property->value), + $property->key + ) + ); + } elseif ($property->value === true) { + $value = "1"; + } elseif ($property->value === false) { + $value = "0"; + } else { + $value = (string)$property->value; + } + + $html .= sprintf($format, $property->key, htmlspecialchars($value)); + } + + return $html; + } +} diff --git a/symfony/css-selector/CssSelectorConverter.php b/symfony/css-selector/CssSelectorConverter.php new file mode 100644 index 00000000..bbb6afe2 --- /dev/null +++ b/symfony/css-selector/CssSelectorConverter.php @@ -0,0 +1,69 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector; + +use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser; +use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser; +use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser; +use Symfony\Component\CssSelector\Parser\Shortcut\HashParser; +use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension; +use Symfony\Component\CssSelector\XPath\Translator; + +/** + * CssSelectorConverter is the main entry point of the component and can convert CSS + * selectors to XPath expressions. + * + * @author Christophe Coevoet <stof@notk.org> + */ +class CssSelectorConverter +{ + private $translator; + private $cache; + + private static $xmlCache = []; + private static $htmlCache = []; + + /** + * @param bool $html Whether HTML support should be enabled. Disable it for XML documents + */ + public function __construct(bool $html = true) + { + $this->translator = new Translator(); + + if ($html) { + $this->translator->registerExtension(new HtmlExtension($this->translator)); + $this->cache = &self::$htmlCache; + } else { + $this->cache = &self::$xmlCache; + } + + $this->translator + ->registerParserShortcut(new EmptyStringParser()) + ->registerParserShortcut(new ElementParser()) + ->registerParserShortcut(new ClassParser()) + ->registerParserShortcut(new HashParser()) + ; + } + + /** + * Translates a CSS expression to its XPath equivalent. + * + * Optionally, a prefix can be added to the resulting XPath + * expression with the $prefix parameter. + * + * @return string + */ + public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::') + { + return $this->cache[$prefix][$cssExpr] ?? $this->cache[$prefix][$cssExpr] = $this->translator->cssToXPath($cssExpr, $prefix); + } +} diff --git a/symfony/css-selector/Exception/ExceptionInterface.php b/symfony/css-selector/Exception/ExceptionInterface.php new file mode 100644 index 00000000..9e259006 --- /dev/null +++ b/symfony/css-selector/Exception/ExceptionInterface.php @@ -0,0 +1,24 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Exception; + +/** + * Interface for exceptions. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/symfony/css-selector/Exception/ExpressionErrorException.php b/symfony/css-selector/Exception/ExpressionErrorException.php new file mode 100644 index 00000000..fd5deeab --- /dev/null +++ b/symfony/css-selector/Exception/ExpressionErrorException.php @@ -0,0 +1,24 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Exception; + +/** + * ParseException is thrown when a CSS selector syntax is not valid. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + */ +class ExpressionErrorException extends ParseException +{ +} diff --git a/symfony/css-selector/Exception/InternalErrorException.php b/symfony/css-selector/Exception/InternalErrorException.php new file mode 100644 index 00000000..e60e5ed0 --- /dev/null +++ b/symfony/css-selector/Exception/InternalErrorException.php @@ -0,0 +1,24 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Exception; + +/** + * ParseException is thrown when a CSS selector syntax is not valid. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + */ +class InternalErrorException extends ParseException +{ +} diff --git a/symfony/css-selector/Exception/ParseException.php b/symfony/css-selector/Exception/ParseException.php new file mode 100644 index 00000000..3b0b0ee8 --- /dev/null +++ b/symfony/css-selector/Exception/ParseException.php @@ -0,0 +1,24 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Exception; + +/** + * ParseException is thrown when a CSS selector syntax is not valid. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class ParseException extends \Exception implements ExceptionInterface +{ +} diff --git a/symfony/css-selector/Exception/SyntaxErrorException.php b/symfony/css-selector/Exception/SyntaxErrorException.php new file mode 100644 index 00000000..7deacf9c --- /dev/null +++ b/symfony/css-selector/Exception/SyntaxErrorException.php @@ -0,0 +1,65 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Exception; + +use Symfony\Component\CssSelector\Parser\Token; + +/** + * ParseException is thrown when a CSS selector syntax is not valid. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + */ +class SyntaxErrorException extends ParseException +{ + /** + * @return self + */ + public static function unexpectedToken(string $expectedValue, Token $foundToken) + { + return new self(sprintf('Expected %s, but %s found.', $expectedValue, $foundToken)); + } + + /** + * @return self + */ + public static function pseudoElementFound(string $pseudoElement, string $unexpectedLocation) + { + return new self(sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation)); + } + + /** + * @return self + */ + public static function unclosedString(int $position) + { + return new self(sprintf('Unclosed/invalid string at %s.', $position)); + } + + /** + * @return self + */ + public static function nestedNot() + { + return new self('Got nested ::not().'); + } + + /** + * @return self + */ + public static function stringAsFunctionArgument() + { + return new self('String not allowed as function argument.'); + } +} diff --git a/symfony/css-selector/LICENSE b/symfony/css-selector/LICENSE new file mode 100644 index 00000000..88bf75bb --- /dev/null +++ b/symfony/css-selector/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2022 Fabien Potencier + +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/symfony/css-selector/Node/AbstractNode.php b/symfony/css-selector/Node/AbstractNode.php new file mode 100644 index 00000000..1306aeac --- /dev/null +++ b/symfony/css-selector/Node/AbstractNode.php @@ -0,0 +1,39 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Abstract base node class. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +abstract class AbstractNode implements NodeInterface +{ + /** + * @var string + */ + private $nodeName; + + public function getNodeName(): string + { + if (null === $this->nodeName) { + $this->nodeName = preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', static::class); + } + + return $this->nodeName; + } +} diff --git a/symfony/css-selector/Node/AttributeNode.php b/symfony/css-selector/Node/AttributeNode.php new file mode 100644 index 00000000..0b6e0ee0 --- /dev/null +++ b/symfony/css-selector/Node/AttributeNode.php @@ -0,0 +1,82 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>[<namespace>|<attribute> <operator> <value>]" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class AttributeNode extends AbstractNode +{ + private $selector; + private $namespace; + private $attribute; + private $operator; + private $value; + + public function __construct(NodeInterface $selector, ?string $namespace, string $attribute, string $operator, ?string $value) + { + $this->selector = $selector; + $this->namespace = $namespace; + $this->attribute = $attribute; + $this->operator = $operator; + $this->value = $value; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + public function getAttribute(): string + { + return $this->attribute; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): ?string + { + return $this->value; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + public function __toString(): string + { + $attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute; + + return 'exists' === $this->operator + ? sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute) + : sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value); + } +} diff --git a/symfony/css-selector/Node/ClassNode.php b/symfony/css-selector/Node/ClassNode.php new file mode 100644 index 00000000..1efca808 --- /dev/null +++ b/symfony/css-selector/Node/ClassNode.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>.<name>" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class ClassNode extends AbstractNode +{ + private $selector; + private $name; + + public function __construct(NodeInterface $selector, string $name) + { + $this->selector = $selector; + $this->name = $name; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getName(): string + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + public function __toString(): string + { + return sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name); + } +} diff --git a/symfony/css-selector/Node/CombinedSelectorNode.php b/symfony/css-selector/Node/CombinedSelectorNode.php new file mode 100644 index 00000000..a217a45e --- /dev/null +++ b/symfony/css-selector/Node/CombinedSelectorNode.php @@ -0,0 +1,66 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a combined node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class CombinedSelectorNode extends AbstractNode +{ + private $selector; + private $combinator; + private $subSelector; + + public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector) + { + $this->selector = $selector; + $this->combinator = $combinator; + $this->subSelector = $subSelector; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getCombinator(): string + { + return $this->combinator; + } + + public function getSubSelector(): NodeInterface + { + return $this->subSelector; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); + } + + public function __toString(): string + { + $combinator = ' ' === $this->combinator ? '<followed>' : $this->combinator; + + return sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector); + } +} diff --git a/symfony/css-selector/Node/ElementNode.php b/symfony/css-selector/Node/ElementNode.php new file mode 100644 index 00000000..fbf8ea0f --- /dev/null +++ b/symfony/css-selector/Node/ElementNode.php @@ -0,0 +1,59 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<namespace>|<element>" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class ElementNode extends AbstractNode +{ + private $namespace; + private $element; + + public function __construct(string $namespace = null, string $element = null) + { + $this->namespace = $namespace; + $this->element = $element; + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + public function getElement(): ?string + { + return $this->element; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return new Specificity(0, 0, $this->element ? 1 : 0); + } + + public function __toString(): string + { + $element = $this->element ?: '*'; + + return sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element); + } +} diff --git a/symfony/css-selector/Node/FunctionNode.php b/symfony/css-selector/Node/FunctionNode.php new file mode 100644 index 00000000..c464cf7c --- /dev/null +++ b/symfony/css-selector/Node/FunctionNode.php @@ -0,0 +1,76 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +use Symfony\Component\CssSelector\Parser\Token; + +/** + * Represents a "<selector>:<name>(<arguments>)" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class FunctionNode extends AbstractNode +{ + private $selector; + private $name; + private $arguments; + + /** + * @param Token[] $arguments + */ + public function __construct(NodeInterface $selector, string $name, array $arguments = []) + { + $this->selector = $selector; + $this->name = strtolower($name); + $this->arguments = $arguments; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return Token[] + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + public function __toString(): string + { + $arguments = implode(', ', array_map(function (Token $token) { + return "'".$token->getValue()."'"; + }, $this->arguments)); + + return sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : ''); + } +} diff --git a/symfony/css-selector/Node/HashNode.php b/symfony/css-selector/Node/HashNode.php new file mode 100644 index 00000000..94114c09 --- /dev/null +++ b/symfony/css-selector/Node/HashNode.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>#<id>" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class HashNode extends AbstractNode +{ + private $selector; + private $id; + + public function __construct(NodeInterface $selector, string $id) + { + $this->selector = $selector; + $this->id = $id; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getId(): string + { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0)); + } + + public function __toString(): string + { + return sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id); + } +} diff --git a/symfony/css-selector/Node/NegationNode.php b/symfony/css-selector/Node/NegationNode.php new file mode 100644 index 00000000..f00522fb --- /dev/null +++ b/symfony/css-selector/Node/NegationNode.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>:not(<identifier>)" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class NegationNode extends AbstractNode +{ + private $selector; + private $subSelector; + + public function __construct(NodeInterface $selector, NodeInterface $subSelector) + { + $this->selector = $selector; + $this->subSelector = $subSelector; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getSubSelector(): NodeInterface + { + return $this->subSelector; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); + } + + public function __toString(): string + { + return sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); + } +} diff --git a/symfony/css-selector/Node/NodeInterface.php b/symfony/css-selector/Node/NodeInterface.php new file mode 100644 index 00000000..b078d26d --- /dev/null +++ b/symfony/css-selector/Node/NodeInterface.php @@ -0,0 +1,31 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Interface for nodes. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +interface NodeInterface +{ + public function getNodeName(): string; + + public function getSpecificity(): Specificity; + + public function __toString(): string; +} diff --git a/symfony/css-selector/Node/PseudoNode.php b/symfony/css-selector/Node/PseudoNode.php new file mode 100644 index 00000000..12b7bd26 --- /dev/null +++ b/symfony/css-selector/Node/PseudoNode.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>:<identifier>" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class PseudoNode extends AbstractNode +{ + private $selector; + private $identifier; + + public function __construct(NodeInterface $selector, string $identifier) + { + $this->selector = $selector; + $this->identifier = strtolower($identifier); + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + public function __toString(): string + { + return sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier); + } +} diff --git a/symfony/css-selector/Node/SelectorNode.php b/symfony/css-selector/Node/SelectorNode.php new file mode 100644 index 00000000..6e52b2fa --- /dev/null +++ b/symfony/css-selector/Node/SelectorNode.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a "<selector>(::|:)<pseudoElement>" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class SelectorNode extends AbstractNode +{ + private $tree; + private $pseudoElement; + + public function __construct(NodeInterface $tree, string $pseudoElement = null) + { + $this->tree = $tree; + $this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null; + } + + public function getTree(): NodeInterface + { + return $this->tree; + } + + public function getPseudoElement(): ?string + { + return $this->pseudoElement; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity(): Specificity + { + return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0)); + } + + public function __toString(): string + { + return sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : ''); + } +} diff --git a/symfony/css-selector/Node/Specificity.php b/symfony/css-selector/Node/Specificity.php new file mode 100644 index 00000000..b00f6d28 --- /dev/null +++ b/symfony/css-selector/Node/Specificity.php @@ -0,0 +1,73 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a node specificity. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @see http://www.w3.org/TR/selectors/#specificity + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Specificity +{ + public const A_FACTOR = 100; + public const B_FACTOR = 10; + public const C_FACTOR = 1; + + private $a; + private $b; + private $c; + + public function __construct(int $a, int $b, int $c) + { + $this->a = $a; + $this->b = $b; + $this->c = $c; + } + + public function plus(self $specificity): self + { + return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c); + } + + public function getValue(): int + { + return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR; + } + + /** + * Returns -1 if the object specificity is lower than the argument, + * 0 if they are equal, and 1 if the argument is lower. + */ + public function compareTo(self $specificity): int + { + if ($this->a !== $specificity->a) { + return $this->a > $specificity->a ? 1 : -1; + } + + if ($this->b !== $specificity->b) { + return $this->b > $specificity->b ? 1 : -1; + } + + if ($this->c !== $specificity->c) { + return $this->c > $specificity->c ? 1 : -1; + } + + return 0; + } +} diff --git a/symfony/css-selector/Parser/Handler/CommentHandler.php b/symfony/css-selector/Parser/Handler/CommentHandler.php new file mode 100644 index 00000000..93f31884 --- /dev/null +++ b/symfony/css-selector/Parser/Handler/CommentHandler.php @@ -0,0 +1,48 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class CommentHandler implements HandlerInterface +{ + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + if ('/*' !== $reader->getSubstring(2)) { + return false; + } + + $offset = $reader->getOffset('*/'); + + if (false === $offset) { + $reader->moveToEnd(); + } else { + $reader->moveForward($offset + 2); + } + + return true; + } +} diff --git a/symfony/css-selector/Parser/Handler/HandlerInterface.php b/symfony/css-selector/Parser/Handler/HandlerInterface.php new file mode 100644 index 00000000..9ec714d5 --- /dev/null +++ b/symfony/css-selector/Parser/Handler/HandlerInterface.php @@ -0,0 +1,30 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector handler interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +interface HandlerInterface +{ + public function handle(Reader $reader, TokenStream $stream): bool; +} diff --git a/symfony/css-selector/Parser/Handler/HashHandler.php b/symfony/css-selector/Parser/Handler/HashHandler.php new file mode 100644 index 00000000..7ae9b438 --- /dev/null +++ b/symfony/css-selector/Parser/Handler/HashHandler.php @@ -0,0 +1,58 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class HashHandler implements HandlerInterface +{ + private $patterns; + private $escaping; + + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + $match = $reader->findPattern($this->patterns->getHashPattern()); + + if (!$match) { + return false; + } + + $value = $this->escaping->escapeUnicode($match[1]); + $stream->push(new Token(Token::TYPE_HASH, $value, $reader->getPosition())); + $reader->moveForward(\strlen($match[0])); + + return true; + } +} diff --git a/symfony/css-selector/Parser/Handler/IdentifierHandler.php b/symfony/css-selector/Parser/Handler/IdentifierHandler.php new file mode 100644 index 00000000..7b2a14e2 --- /dev/null +++ b/symfony/css-selector/Parser/Handler/IdentifierHandler.php @@ -0,0 +1,58 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class IdentifierHandler implements HandlerInterface +{ + private $patterns; + private $escaping; + + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + $match = $reader->findPattern($this->patterns->getIdentifierPattern()); + + if (!$match) { + return false; + } + + $value = $this->escaping->escapeUnicode($match[0]); + $stream->push(new Token(Token::TYPE_IDENTIFIER, $value, $reader->getPosition())); + $reader->moveForward(\strlen($match[0])); + + return true; + } +} diff --git a/symfony/css-selector/Parser/Handler/NumberHandler.php b/symfony/css-selector/Parser/Handler/NumberHandler.php new file mode 100644 index 00000000..8291a68d --- /dev/null +++ b/symfony/css-selector/Parser/Handler/NumberHandler.php @@ -0,0 +1,54 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class NumberHandler implements HandlerInterface +{ + private $patterns; + + public function __construct(TokenizerPatterns $patterns) + { + $this->patterns = $patterns; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + $match = $reader->findPattern($this->patterns->getNumberPattern()); + + if (!$match) { + return false; + } + + $stream->push(new Token(Token::TYPE_NUMBER, $match[0], $reader->getPosition())); + $reader->moveForward(\strlen($match[0])); + + return true; + } +} diff --git a/symfony/css-selector/Parser/Handler/StringHandler.php b/symfony/css-selector/Parser/Handler/StringHandler.php new file mode 100644 index 00000000..6ce83cdc --- /dev/null +++ b/symfony/css-selector/Parser/Handler/StringHandler.php @@ -0,0 +1,77 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Exception\InternalErrorException; +use Symfony\Component\CssSelector\Exception\SyntaxErrorException; +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class StringHandler implements HandlerInterface +{ + private $patterns; + private $escaping; + + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + $quote = $reader->getSubstring(1); + + if (!\in_array($quote, ["'", '"'])) { + return false; + } + + $reader->moveForward(1); + $match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote)); + + if (!$match) { + throw new InternalErrorException(sprintf('Should have found at least an empty match at %d.', $reader->getPosition())); + } + + // check unclosed strings + if (\strlen($match[0]) === $reader->getRemainingLength()) { + throw SyntaxErrorException::unclosedString($reader->getPosition() - 1); + } + + // check quotes pairs validity + if ($quote !== $reader->getSubstring(1, \strlen($match[0]))) { + throw SyntaxErrorException::unclosedString($reader->getPosition() - 1); + } + + $string = $this->escaping->escapeUnicodeAndNewLine($match[0]); + $stream->push(new Token(Token::TYPE_STRING, $string, $reader->getPosition())); + $reader->moveForward(\strlen($match[0]) + 1); + + return true; + } +} diff --git a/symfony/css-selector/Parser/Handler/WhitespaceHandler.php b/symfony/css-selector/Parser/Handler/WhitespaceHandler.php new file mode 100644 index 00000000..21345e32 --- /dev/null +++ b/symfony/css-selector/Parser/Handler/WhitespaceHandler.php @@ -0,0 +1,46 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Handler; + +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector whitespace handler. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class WhitespaceHandler implements HandlerInterface +{ + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream): bool + { + $match = $reader->findPattern('~^[ \t\r\n\f]+~'); + + if (false === $match) { + return false; + } + + $stream->push(new Token(Token::TYPE_WHITESPACE, $match[0], $reader->getPosition())); + $reader->moveForward(\strlen($match[0])); + + return true; + } +} diff --git a/symfony/css-selector/Parser/Parser.php b/symfony/css-selector/Parser/Parser.php new file mode 100644 index 00000000..d73489ed --- /dev/null +++ b/symfony/css-selector/Parser/Parser.php @@ -0,0 +1,353 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser; + +use Symfony\Component\CssSelector\Exception\SyntaxErrorException; +use Symfony\Component\CssSelector\Node; +use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; + +/** + * CSS selector parser. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Parser implements ParserInterface +{ + private $tokenizer; + + public function __construct(Tokenizer $tokenizer = null) + { + $this->tokenizer = $tokenizer ?? new Tokenizer(); + } + + /** + * {@inheritdoc} + */ + public function parse(string $source): array + { + $reader = new Reader($source); + $stream = $this->tokenizer->tokenize($reader); + + return $this->parseSelectorList($stream); + } + + /** + * Parses the arguments for ":nth-child()" and friends. + * + * @param Token[] $tokens + * + * @throws SyntaxErrorException + */ + public static function parseSeries(array $tokens): array + { + foreach ($tokens as $token) { + if ($token->isString()) { + throw SyntaxErrorException::stringAsFunctionArgument(); + } + } + + $joined = trim(implode('', array_map(function (Token $token) { + return $token->getValue(); + }, $tokens))); + + $int = function ($string) { + if (!is_numeric($string)) { + throw SyntaxErrorException::stringAsFunctionArgument(); + } + + return (int) $string; + }; + + switch (true) { + case 'odd' === $joined: + return [2, 1]; + case 'even' === $joined: + return [2, 0]; + case 'n' === $joined: + return [1, 0]; + case !str_contains($joined, 'n'): + return [0, $int($joined)]; + } + + $split = explode('n', $joined); + $first = $split[0] ?? null; + + return [ + $first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1, + isset($split[1]) && $split[1] ? $int($split[1]) : 0, + ]; + } + + private function parseSelectorList(TokenStream $stream): array + { + $stream->skipWhitespace(); + $selectors = []; + + while (true) { + $selectors[] = $this->parserSelectorNode($stream); + + if ($stream->getPeek()->isDelimiter([','])) { + $stream->getNext(); + $stream->skipWhitespace(); + } else { + break; + } + } + + return $selectors; + } + + private function parserSelectorNode(TokenStream $stream): Node\SelectorNode + { + [$result, $pseudoElement] = $this->parseSimpleSelector($stream); + + while (true) { + $stream->skipWhitespace(); + $peek = $stream->getPeek(); + + if ($peek->isFileEnd() || $peek->isDelimiter([','])) { + break; + } + + if (null !== $pseudoElement) { + throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); + } + + if ($peek->isDelimiter(['+', '>', '~'])) { + $combinator = $stream->getNext()->getValue(); + $stream->skipWhitespace(); + } else { + $combinator = ' '; + } + + [$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream); + $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); + } + + return new Node\SelectorNode($result, $pseudoElement); + } + + /** + * Parses next simple node (hash, class, pseudo, negation). + * + * @throws SyntaxErrorException + */ + private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array + { + $stream->skipWhitespace(); + + $selectorStart = \count($stream->getUsed()); + $result = $this->parseElementNode($stream); + $pseudoElement = null; + + while (true) { + $peek = $stream->getPeek(); + if ($peek->isWhitespace() + || $peek->isFileEnd() + || $peek->isDelimiter([',', '+', '>', '~']) + || ($insideNegation && $peek->isDelimiter([')'])) + ) { + break; + } + + if (null !== $pseudoElement) { + throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); + } + + if ($peek->isHash()) { + $result = new Node\HashNode($result, $stream->getNext()->getValue()); + } elseif ($peek->isDelimiter(['.'])) { + $stream->getNext(); + $result = new Node\ClassNode($result, $stream->getNextIdentifier()); + } elseif ($peek->isDelimiter(['['])) { + $stream->getNext(); + $result = $this->parseAttributeNode($result, $stream); + } elseif ($peek->isDelimiter([':'])) { + $stream->getNext(); + + if ($stream->getPeek()->isDelimiter([':'])) { + $stream->getNext(); + $pseudoElement = $stream->getNextIdentifier(); + + continue; + } + + $identifier = $stream->getNextIdentifier(); + if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'])) { + // Special case: CSS 2.1 pseudo-elements can have a single ':'. + // Any new pseudo-element must have two. + $pseudoElement = $identifier; + + continue; + } + + if (!$stream->getPeek()->isDelimiter(['('])) { + $result = new Node\PseudoNode($result, $identifier); + + continue; + } + + $stream->getNext(); + $stream->skipWhitespace(); + + if ('not' === strtolower($identifier)) { + if ($insideNegation) { + throw SyntaxErrorException::nestedNot(); + } + + [$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true); + $next = $stream->getNext(); + + if (null !== $argumentPseudoElement) { + throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()'); + } + + if (!$next->isDelimiter([')'])) { + throw SyntaxErrorException::unexpectedToken('")"', $next); + } + + $result = new Node\NegationNode($result, $argument); + } else { + $arguments = []; + $next = null; + + while (true) { + $stream->skipWhitespace(); + $next = $stream->getNext(); + + if ($next->isIdentifier() + || $next->isString() + || $next->isNumber() + || $next->isDelimiter(['+', '-']) + ) { + $arguments[] = $next; + } elseif ($next->isDelimiter([')'])) { + break; + } else { + throw SyntaxErrorException::unexpectedToken('an argument', $next); + } + } + + if (empty($arguments)) { + throw SyntaxErrorException::unexpectedToken('at least one argument', $next); + } + + $result = new Node\FunctionNode($result, $identifier, $arguments); + } + } else { + throw SyntaxErrorException::unexpectedToken('selector', $peek); + } + } + + if (\count($stream->getUsed()) === $selectorStart) { + throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek()); + } + + return [$result, $pseudoElement]; + } + + private function parseElementNode(TokenStream $stream): Node\ElementNode + { + $peek = $stream->getPeek(); + + if ($peek->isIdentifier() || $peek->isDelimiter(['*'])) { + if ($peek->isIdentifier()) { + $namespace = $stream->getNext()->getValue(); + } else { + $stream->getNext(); + $namespace = null; + } + + if ($stream->getPeek()->isDelimiter(['|'])) { + $stream->getNext(); + $element = $stream->getNextIdentifierOrStar(); + } else { + $element = $namespace; + $namespace = null; + } + } else { + $element = $namespace = null; + } + + return new Node\ElementNode($namespace, $element); + } + + private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream): Node\AttributeNode + { + $stream->skipWhitespace(); + $attribute = $stream->getNextIdentifierOrStar(); + + if (null === $attribute && !$stream->getPeek()->isDelimiter(['|'])) { + throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek()); + } + + if ($stream->getPeek()->isDelimiter(['|'])) { + $stream->getNext(); + + if ($stream->getPeek()->isDelimiter(['='])) { + $namespace = null; + $stream->getNext(); + $operator = '|='; + } else { + $namespace = $attribute; + $attribute = $stream->getNextIdentifier(); + $operator = null; + } + } else { + $namespace = $operator = null; + } + + if (null === $operator) { + $stream->skipWhitespace(); + $next = $stream->getNext(); + + if ($next->isDelimiter([']'])) { + return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null); + } elseif ($next->isDelimiter(['='])) { + $operator = '='; + } elseif ($next->isDelimiter(['^', '$', '*', '~', '|', '!']) + && $stream->getPeek()->isDelimiter(['=']) + ) { + $operator = $next->getValue().'='; + $stream->getNext(); + } else { + throw SyntaxErrorException::unexpectedToken('operator', $next); + } + } + + $stream->skipWhitespace(); + $value = $stream->getNext(); + + if ($value->isNumber()) { + // if the value is a number, it's casted into a string + $value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition()); + } + + if (!($value->isIdentifier() || $value->isString())) { + throw SyntaxErrorException::unexpectedToken('string or identifier', $value); + } + + $stream->skipWhitespace(); + $next = $stream->getNext(); + + if (!$next->isDelimiter([']'])) { + throw SyntaxErrorException::unexpectedToken('"]"', $next); + } + + return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue()); + } +} diff --git a/symfony/css-selector/Parser/ParserInterface.php b/symfony/css-selector/Parser/ParserInterface.php new file mode 100644 index 00000000..51c3d935 --- /dev/null +++ b/symfony/css-selector/Parser/ParserInterface.php @@ -0,0 +1,34 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser; + +use Symfony\Component\CssSelector\Node\SelectorNode; + +/** + * CSS selector parser interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +interface ParserInterface +{ + /** + * Parses given selector source into an array of tokens. + * + * @return SelectorNode[] + */ + public function parse(string $source): array; +} diff --git a/symfony/css-selector/Parser/Reader.php b/symfony/css-selector/Parser/Reader.php new file mode 100644 index 00000000..4b43effe --- /dev/null +++ b/symfony/css-selector/Parser/Reader.php @@ -0,0 +1,86 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser; + +/** + * CSS selector reader. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Reader +{ + private $source; + private $length; + private $position = 0; + + public function __construct(string $source) + { + $this->source = $source; + $this->length = \strlen($source); + } + + public function isEOF(): bool + { + return $this->position >= $this->length; + } + + public function getPosition(): int + { + return $this->position; + } + + public function getRemainingLength(): int + { + return $this->length - $this->position; + } + + public function getSubstring(int $length, int $offset = 0): string + { + return substr($this->source, $this->position + $offset, $length); + } + + public function getOffset(string $string) + { + $position = strpos($this->source, $string, $this->position); + + return false === $position ? false : $position - $this->position; + } + + /** + * @return array|false + */ + public function findPattern(string $pattern) + { + $source = substr($this->source, $this->position); + + if (preg_match($pattern, $source, $matches)) { + return $matches; + } + + return false; + } + + public function moveForward(int $length) + { + $this->position += $length; + } + + public function moveToEnd() + { + $this->position = $this->length; + } +} diff --git a/symfony/css-selector/Parser/Shortcut/ClassParser.php b/symfony/css-selector/Parser/Shortcut/ClassParser.php new file mode 100644 index 00000000..17fa8c27 --- /dev/null +++ b/symfony/css-selector/Parser/Shortcut/ClassParser.php @@ -0,0 +1,51 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Shortcut; + +use Symfony\Component\CssSelector\Node\ClassNode; +use Symfony\Component\CssSelector\Node\ElementNode; +use Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\ParserInterface; + +/** + * CSS selector class parser shortcut. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class ClassParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse(string $source): array + { + // Matches an optional namespace, optional element, and required class + // $source = 'test|input.ab6bd_field'; + // $matches = array (size=4) + // 0 => string 'test|input.ab6bd_field' (length=22) + // 1 => string 'test' (length=4) + // 2 => string 'input' (length=5) + // 3 => string 'ab6bd_field' (length=11) + if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+\.([\w-]++)$/i', trim($source), $matches)) { + return [ + new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])), + ]; + } + + return []; + } +} diff --git a/symfony/css-selector/Parser/Shortcut/ElementParser.php b/symfony/css-selector/Parser/Shortcut/ElementParser.php new file mode 100644 index 00000000..8b9a8638 --- /dev/null +++ b/symfony/css-selector/Parser/Shortcut/ElementParser.php @@ -0,0 +1,47 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Shortcut; + +use Symfony\Component\CssSelector\Node\ElementNode; +use Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\ParserInterface; + +/** + * CSS selector element parser shortcut. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class ElementParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse(string $source): array + { + // Matches an optional namespace, required element or `*` + // $source = 'testns|testel'; + // $matches = array (size=3) + // 0 => string 'testns|testel' (length=13) + // 1 => string 'testns' (length=6) + // 2 => string 'testel' (length=6) + if (preg_match('/^(?:([a-z]++)\|)?([\w-]++|\*)$/i', trim($source), $matches)) { + return [new SelectorNode(new ElementNode($matches[1] ?: null, $matches[2]))]; + } + + return []; + } +} diff --git a/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php b/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php new file mode 100644 index 00000000..222df5cd --- /dev/null +++ b/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php @@ -0,0 +1,46 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Shortcut; + +use Symfony\Component\CssSelector\Node\ElementNode; +use Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\ParserInterface; + +/** + * CSS selector class parser shortcut. + * + * This shortcut ensure compatibility with previous version. + * - The parser fails to parse an empty string. + * - In the previous version, an empty string matches each tags. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class EmptyStringParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse(string $source): array + { + // Matches an empty string + if ('' == $source) { + return [new SelectorNode(new ElementNode(null, '*'))]; + } + + return []; + } +} diff --git a/symfony/css-selector/Parser/Shortcut/HashParser.php b/symfony/css-selector/Parser/Shortcut/HashParser.php new file mode 100644 index 00000000..fb07ee6c --- /dev/null +++ b/symfony/css-selector/Parser/Shortcut/HashParser.php @@ -0,0 +1,51 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Shortcut; + +use Symfony\Component\CssSelector\Node\ElementNode; +use Symfony\Component\CssSelector\Node\HashNode; +use Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\ParserInterface; + +/** + * CSS selector hash parser shortcut. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class HashParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse(string $source): array + { + // Matches an optional namespace, optional element, and required id + // $source = 'test|input#ab6bd_field'; + // $matches = array (size=4) + // 0 => string 'test|input#ab6bd_field' (length=22) + // 1 => string 'test' (length=4) + // 2 => string 'input' (length=5) + // 3 => string 'ab6bd_field' (length=11) + if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+#([\w-]++)$/i', trim($source), $matches)) { + return [ + new SelectorNode(new HashNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])), + ]; + } + + return []; + } +} diff --git a/symfony/css-selector/Parser/Token.php b/symfony/css-selector/Parser/Token.php new file mode 100644 index 00000000..a053203c --- /dev/null +++ b/symfony/css-selector/Parser/Token.php @@ -0,0 +1,111 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser; + +/** + * CSS selector token. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Token +{ + public const TYPE_FILE_END = 'eof'; + public const TYPE_DELIMITER = 'delimiter'; + public const TYPE_WHITESPACE = 'whitespace'; + public const TYPE_IDENTIFIER = 'identifier'; + public const TYPE_HASH = 'hash'; + public const TYPE_NUMBER = 'number'; + public const TYPE_STRING = 'string'; + + private $type; + private $value; + private $position; + + public function __construct(?string $type, ?string $value, ?int $position) + { + $this->type = $type; + $this->value = $value; + $this->position = $position; + } + + public function getType(): ?int + { + return $this->type; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function getPosition(): ?int + { + return $this->position; + } + + public function isFileEnd(): bool + { + return self::TYPE_FILE_END === $this->type; + } + + public function isDelimiter(array $values = []): bool + { + if (self::TYPE_DELIMITER !== $this->type) { + return false; + } + + if (empty($values)) { + return true; + } + + return \in_array($this->value, $values); + } + + public function isWhitespace(): bool + { + return self::TYPE_WHITESPACE === $this->type; + } + + public function isIdentifier(): bool + { + return self::TYPE_IDENTIFIER === $this->type; + } + + public function isHash(): bool + { + return self::TYPE_HASH === $this->type; + } + + public function isNumber(): bool + { + return self::TYPE_NUMBER === $this->type; + } + + public function isString(): bool + { + return self::TYPE_STRING === $this->type; + } + + public function __toString(): string + { + if ($this->value) { + return sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position); + } + + return sprintf('<%s at %s>', $this->type, $this->position); + } +} diff --git a/symfony/css-selector/Parser/TokenStream.php b/symfony/css-selector/Parser/TokenStream.php new file mode 100644 index 00000000..2085f2dd --- /dev/null +++ b/symfony/css-selector/Parser/TokenStream.php @@ -0,0 +1,167 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser; + +use Symfony\Component\CssSelector\Exception\InternalErrorException; +use Symfony\Component\CssSelector\Exception\SyntaxErrorException; + +/** + * CSS selector token stream. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class TokenStream +{ + /** + * @var Token[] + */ + private $tokens = []; + + /** + * @var Token[] + */ + private $used = []; + + /** + * @var int + */ + private $cursor = 0; + + /** + * @var Token|null + */ + private $peeked; + + /** + * @var bool + */ + private $peeking = false; + + /** + * Pushes a token. + * + * @return $this + */ + public function push(Token $token): self + { + $this->tokens[] = $token; + + return $this; + } + + /** + * Freezes stream. + * + * @return $this + */ + public function freeze(): self + { + return $this; + } + + /** + * Returns next token. + * + * @throws InternalErrorException If there is no more token + */ + public function getNext(): Token + { + if ($this->peeking) { + $this->peeking = false; + $this->used[] = $this->peeked; + + return $this->peeked; + } + + if (!isset($this->tokens[$this->cursor])) { + throw new InternalErrorException('Unexpected token stream end.'); + } + + return $this->tokens[$this->cursor++]; + } + + /** + * Returns peeked token. + */ + public function getPeek(): Token + { + if (!$this->peeking) { + $this->peeked = $this->getNext(); + $this->peeking = true; + } + + return $this->peeked; + } + + /** + * Returns used tokens. + * + * @return Token[] + */ + public function getUsed(): array + { + return $this->used; + } + + /** + * Returns next identifier token. + * + * @throws SyntaxErrorException If next token is not an identifier + */ + public function getNextIdentifier(): string + { + $next = $this->getNext(); + + if (!$next->isIdentifier()) { + throw SyntaxErrorException::unexpectedToken('identifier', $next); + } + + return $next->getValue(); + } + + /** + * Returns next identifier or null if star delimiter token is found. + * + * @throws SyntaxErrorException If next token is not an identifier or a star delimiter + */ + public function getNextIdentifierOrStar(): ?string + { + $next = $this->getNext(); + + if ($next->isIdentifier()) { + return $next->getValue(); + } + + if ($next->isDelimiter(['*'])) { + return null; + } + + throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next); + } + + /** + * Skips next whitespace if any. + */ + public function skipWhitespace() + { + $peek = $this->getPeek(); + + if ($peek->isWhitespace()) { + $this->getNext(); + } + } +} diff --git a/symfony/css-selector/Parser/Tokenizer/Tokenizer.php b/symfony/css-selector/Parser/Tokenizer/Tokenizer.php new file mode 100644 index 00000000..e0dcc5bf --- /dev/null +++ b/symfony/css-selector/Parser/Tokenizer/Tokenizer.php @@ -0,0 +1,73 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Tokenizer; + +use Symfony\Component\CssSelector\Parser\Handler; +use Symfony\Component\CssSelector\Parser\Reader; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\TokenStream; + +/** + * CSS selector tokenizer. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Tokenizer +{ + /** + * @var Handler\HandlerInterface[] + */ + private $handlers; + + public function __construct() + { + $patterns = new TokenizerPatterns(); + $escaping = new TokenizerEscaping($patterns); + + $this->handlers = [ + new Handler\WhitespaceHandler(), + new Handler\IdentifierHandler($patterns, $escaping), + new Handler\HashHandler($patterns, $escaping), + new Handler\StringHandler($patterns, $escaping), + new Handler\NumberHandler($patterns), + new Handler\CommentHandler(), + ]; + } + + /** + * Tokenize selector source code. + */ + public function tokenize(Reader $reader): TokenStream + { + $stream = new TokenStream(); + + while (!$reader->isEOF()) { + foreach ($this->handlers as $handler) { + if ($handler->handle($reader, $stream)) { + continue 2; + } + } + + $stream->push(new Token(Token::TYPE_DELIMITER, $reader->getSubstring(1), $reader->getPosition())); + $reader->moveForward(1); + } + + return $stream + ->push(new Token(Token::TYPE_FILE_END, null, $reader->getPosition())) + ->freeze(); + } +} diff --git a/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php b/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php new file mode 100644 index 00000000..013e827d --- /dev/null +++ b/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php @@ -0,0 +1,65 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Tokenizer; + +/** + * CSS selector tokenizer escaping applier. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class TokenizerEscaping +{ + private $patterns; + + public function __construct(TokenizerPatterns $patterns) + { + $this->patterns = $patterns; + } + + public function escapeUnicode(string $value): string + { + $value = $this->replaceUnicodeSequences($value); + + return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value); + } + + public function escapeUnicodeAndNewLine(string $value): string + { + $value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value); + + return $this->escapeUnicode($value); + } + + private function replaceUnicodeSequences(string $value): string + { + return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function ($match) { + $c = hexdec($match[1]); + + if (0x80 > $c %= 0x200000) { + return \chr($c); + } + if (0x800 > $c) { + return \chr(0xC0 | $c >> 6).\chr(0x80 | $c & 0x3F); + } + if (0x10000 > $c) { + return \chr(0xE0 | $c >> 12).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F); + } + + return ''; + }, $value); + } +} diff --git a/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php b/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php new file mode 100644 index 00000000..5f16ac48 --- /dev/null +++ b/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php @@ -0,0 +1,89 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Parser\Tokenizer; + +/** + * CSS selector tokenizer patterns builder. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class TokenizerPatterns +{ + private $unicodeEscapePattern; + private $simpleEscapePattern; + private $newLineEscapePattern; + private $escapePattern; + private $stringEscapePattern; + private $nonAsciiPattern; + private $nmCharPattern; + private $nmStartPattern; + private $identifierPattern; + private $hashPattern; + private $numberPattern; + private $quotedStringPattern; + + public function __construct() + { + $this->unicodeEscapePattern = '\\\\([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?'; + $this->simpleEscapePattern = '\\\\(.)'; + $this->newLineEscapePattern = '\\\\(?:\n|\r\n|\r|\f)'; + $this->escapePattern = $this->unicodeEscapePattern.'|\\\\[^\n\r\f0-9a-f]'; + $this->stringEscapePattern = $this->newLineEscapePattern.'|'.$this->escapePattern; + $this->nonAsciiPattern = '[^\x00-\x7F]'; + $this->nmCharPattern = '[_a-z0-9-]|'.$this->escapePattern.'|'.$this->nonAsciiPattern; + $this->nmStartPattern = '[_a-z]|'.$this->escapePattern.'|'.$this->nonAsciiPattern; + $this->identifierPattern = '-?(?:'.$this->nmStartPattern.')(?:'.$this->nmCharPattern.')*'; + $this->hashPattern = '#((?:'.$this->nmCharPattern.')+)'; + $this->numberPattern = '[+-]?(?:[0-9]*\.[0-9]+|[0-9]+)'; + $this->quotedStringPattern = '([^\n\r\f%s]|'.$this->stringEscapePattern.')*'; + } + + public function getNewLineEscapePattern(): string + { + return '~^'.$this->newLineEscapePattern.'~'; + } + + public function getSimpleEscapePattern(): string + { + return '~^'.$this->simpleEscapePattern.'~'; + } + + public function getUnicodeEscapePattern(): string + { + return '~^'.$this->unicodeEscapePattern.'~i'; + } + + public function getIdentifierPattern(): string + { + return '~^'.$this->identifierPattern.'~i'; + } + + public function getHashPattern(): string + { + return '~^'.$this->hashPattern.'~i'; + } + + public function getNumberPattern(): string + { + return '~^'.$this->numberPattern.'~'; + } + + public function getQuotedStringPattern(string $quote): string + { + return '~^'.sprintf($this->quotedStringPattern, $quote).'~i'; + } +} diff --git a/symfony/css-selector/README.md b/symfony/css-selector/README.md new file mode 100644 index 00000000..ede4a3ac --- /dev/null +++ b/symfony/css-selector/README.md @@ -0,0 +1,20 @@ +CssSelector Component +===================== + +The CssSelector component converts CSS selectors to XPath expressions. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/css_selector.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) + +Credits +------- + +This component is a port of the Python cssselect library +[v0.7.1](https://github.com/SimonSapin/cssselect/releases/tag/v0.7.1), +which is distributed under the BSD license. diff --git a/symfony/css-selector/XPath/Extension/AbstractExtension.php b/symfony/css-selector/XPath/Extension/AbstractExtension.php new file mode 100644 index 00000000..44e0035a --- /dev/null +++ b/symfony/css-selector/XPath/Extension/AbstractExtension.php @@ -0,0 +1,65 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +/** + * XPath expression translator abstract extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +abstract class AbstractExtension implements ExtensionInterface +{ + /** + * {@inheritdoc} + */ + public function getNodeTranslators(): array + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getCombinationTranslators(): array + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getFunctionTranslators(): array + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators(): array + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAttributeMatchingTranslators(): array + { + return []; + } +} diff --git a/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php b/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php new file mode 100644 index 00000000..a9879f1b --- /dev/null +++ b/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php @@ -0,0 +1,119 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\XPath\Translator; +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator attribute extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class AttributeMatchingExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getAttributeMatchingTranslators(): array + { + return [ + 'exists' => [$this, 'translateExists'], + '=' => [$this, 'translateEquals'], + '~=' => [$this, 'translateIncludes'], + '|=' => [$this, 'translateDashMatch'], + '^=' => [$this, 'translatePrefixMatch'], + '$=' => [$this, 'translateSuffixMatch'], + '*=' => [$this, 'translateSubstringMatch'], + '!=' => [$this, 'translateDifferent'], + ]; + } + + public function translateExists(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition($attribute); + } + + public function translateEquals(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition(sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value))); + } + + public function translateIncludes(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition($value ? sprintf( + '%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)', + $attribute, + Translator::getXpathLiteral(' '.$value.' ') + ) : '0'); + } + + public function translateDashMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition(sprintf( + '%1$s and (%1$s = %2$s or starts-with(%1$s, %3$s))', + $attribute, + Translator::getXpathLiteral($value), + Translator::getXpathLiteral($value.'-') + )); + } + + public function translatePrefixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition($value ? sprintf( + '%1$s and starts-with(%1$s, %2$s)', + $attribute, + Translator::getXpathLiteral($value) + ) : '0'); + } + + public function translateSuffixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition($value ? sprintf( + '%1$s and substring(%1$s, string-length(%1$s)-%2$s) = %3$s', + $attribute, + \strlen($value) - 1, + Translator::getXpathLiteral($value) + ) : '0'); + } + + public function translateSubstringMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition($value ? sprintf( + '%1$s and contains(%1$s, %2$s)', + $attribute, + Translator::getXpathLiteral($value) + ) : '0'); + } + + public function translateDifferent(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr + { + return $xpath->addCondition(sprintf( + $value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s', + $attribute, + Translator::getXpathLiteral($value) + )); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'attribute-matching'; + } +} diff --git a/symfony/css-selector/XPath/Extension/CombinationExtension.php b/symfony/css-selector/XPath/Extension/CombinationExtension.php new file mode 100644 index 00000000..aee976e9 --- /dev/null +++ b/symfony/css-selector/XPath/Extension/CombinationExtension.php @@ -0,0 +1,71 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator combination extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class CombinationExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getCombinationTranslators(): array + { + return [ + ' ' => [$this, 'translateDescendant'], + '>' => [$this, 'translateChild'], + '+' => [$this, 'translateDirectAdjacent'], + '~' => [$this, 'translateIndirectAdjacent'], + ]; + } + + public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('/descendant-or-self::*/', $combinedXpath); + } + + public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('/', $combinedXpath); + } + + public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath + ->join('/following-sibling::', $combinedXpath) + ->addNameTest() + ->addCondition('position() = 1'); + } + + public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('/following-sibling::', $combinedXpath); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'combination'; + } +} diff --git a/symfony/css-selector/XPath/Extension/ExtensionInterface.php b/symfony/css-selector/XPath/Extension/ExtensionInterface.php new file mode 100644 index 00000000..1a74b90a --- /dev/null +++ b/symfony/css-selector/XPath/Extension/ExtensionInterface.php @@ -0,0 +1,67 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +/** + * XPath expression translator extension interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +interface ExtensionInterface +{ + /** + * Returns node translators. + * + * These callables will receive the node as first argument and the translator as second argument. + * + * @return callable[] + */ + public function getNodeTranslators(): array; + + /** + * Returns combination translators. + * + * @return callable[] + */ + public function getCombinationTranslators(): array; + + /** + * Returns function translators. + * + * @return callable[] + */ + public function getFunctionTranslators(): array; + + /** + * Returns pseudo-class translators. + * + * @return callable[] + */ + public function getPseudoClassTranslators(): array; + + /** + * Returns attribute operation translators. + * + * @return callable[] + */ + public function getAttributeMatchingTranslators(): array; + + /** + * Returns extension name. + */ + public function getName(): string; +} diff --git a/symfony/css-selector/XPath/Extension/FunctionExtension.php b/symfony/css-selector/XPath/Extension/FunctionExtension.php new file mode 100644 index 00000000..d3f7222a --- /dev/null +++ b/symfony/css-selector/XPath/Extension/FunctionExtension.php @@ -0,0 +1,171 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\Exception\ExpressionErrorException; +use Symfony\Component\CssSelector\Exception\SyntaxErrorException; +use Symfony\Component\CssSelector\Node\FunctionNode; +use Symfony\Component\CssSelector\Parser\Parser; +use Symfony\Component\CssSelector\XPath\Translator; +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator function extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class FunctionExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getFunctionTranslators(): array + { + return [ + 'nth-child' => [$this, 'translateNthChild'], + 'nth-last-child' => [$this, 'translateNthLastChild'], + 'nth-of-type' => [$this, 'translateNthOfType'], + 'nth-last-of-type' => [$this, 'translateNthLastOfType'], + 'contains' => [$this, 'translateContains'], + 'lang' => [$this, 'translateLang'], + ]; + } + + /** + * @throws ExpressionErrorException + */ + public function translateNthChild(XPathExpr $xpath, FunctionNode $function, bool $last = false, bool $addNameTest = true): XPathExpr + { + try { + [$a, $b] = Parser::parseSeries($function->getArguments()); + } catch (SyntaxErrorException $e) { + throw new ExpressionErrorException(sprintf('Invalid series: "%s".', implode('", "', $function->getArguments())), 0, $e); + } + + $xpath->addStarPrefix(); + if ($addNameTest) { + $xpath->addNameTest(); + } + + if (0 === $a) { + return $xpath->addCondition('position() = '.($last ? 'last() - '.($b - 1) : $b)); + } + + if ($a < 0) { + if ($b < 1) { + return $xpath->addCondition('false()'); + } + + $sign = '<='; + } else { + $sign = '>='; + } + + $expr = 'position()'; + + if ($last) { + $expr = 'last() - '.$expr; + --$b; + } + + if (0 !== $b) { + $expr .= ' - '.$b; + } + + $conditions = [sprintf('%s %s 0', $expr, $sign)]; + + if (1 !== $a && -1 !== $a) { + $conditions[] = sprintf('(%s) mod %d = 0', $expr, $a); + } + + return $xpath->addCondition(implode(' and ', $conditions)); + + // todo: handle an+b, odd, even + // an+b means every-a, plus b, e.g., 2n+1 means odd + // 0n+b means b + // n+0 means a=1, i.e., all elements + // an means every a elements, i.e., 2n means even + // -n means -1n + // -1n+6 means elements 6 and previous + } + + public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + return $this->translateNthChild($xpath, $function, true); + } + + public function translateNthOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + return $this->translateNthChild($xpath, $function, false, false); + } + + /** + * @throws ExpressionErrorException + */ + public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.'); + } + + return $this->translateNthChild($xpath, $function, true, false); + } + + /** + * @throws ExpressionErrorException + */ + public function translateContains(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + $arguments = $function->getArguments(); + foreach ($arguments as $token) { + if (!($token->isString() || $token->isIdentifier())) { + throw new ExpressionErrorException('Expected a single string or identifier for :contains(), got '.implode(', ', $arguments)); + } + } + + return $xpath->addCondition(sprintf( + 'contains(string(.), %s)', + Translator::getXpathLiteral($arguments[0]->getValue()) + )); + } + + /** + * @throws ExpressionErrorException + */ + public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + $arguments = $function->getArguments(); + foreach ($arguments as $token) { + if (!($token->isString() || $token->isIdentifier())) { + throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments)); + } + } + + return $xpath->addCondition(sprintf( + 'lang(%s)', + Translator::getXpathLiteral($arguments[0]->getValue()) + )); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'function'; + } +} diff --git a/symfony/css-selector/XPath/Extension/HtmlExtension.php b/symfony/css-selector/XPath/Extension/HtmlExtension.php new file mode 100644 index 00000000..6edc0858 --- /dev/null +++ b/symfony/css-selector/XPath/Extension/HtmlExtension.php @@ -0,0 +1,187 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\Exception\ExpressionErrorException; +use Symfony\Component\CssSelector\Node\FunctionNode; +use Symfony\Component\CssSelector\XPath\Translator; +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator HTML extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class HtmlExtension extends AbstractExtension +{ + public function __construct(Translator $translator) + { + $translator + ->getExtension('node') + ->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true) + ->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true); + } + + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators(): array + { + return [ + 'checked' => [$this, 'translateChecked'], + 'link' => [$this, 'translateLink'], + 'disabled' => [$this, 'translateDisabled'], + 'enabled' => [$this, 'translateEnabled'], + 'selected' => [$this, 'translateSelected'], + 'invalid' => [$this, 'translateInvalid'], + 'hover' => [$this, 'translateHover'], + 'visited' => [$this, 'translateVisited'], + ]; + } + + /** + * {@inheritdoc} + */ + public function getFunctionTranslators(): array + { + return [ + 'lang' => [$this, 'translateLang'], + ]; + } + + public function translateChecked(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition( + '(@checked ' + ."and (name(.) = 'input' or name(.) = 'command')" + ."and (@type = 'checkbox' or @type = 'radio'))" + ); + } + + public function translateLink(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')"); + } + + public function translateDisabled(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition( + '(' + .'@disabled and' + .'(' + ."(name(.) = 'input' and @type != 'hidden')" + ." or name(.) = 'button'" + ." or name(.) = 'select'" + ." or name(.) = 'textarea'" + ." or name(.) = 'command'" + ." or name(.) = 'fieldset'" + ." or name(.) = 'optgroup'" + ." or name(.) = 'option'" + .')' + .') or (' + ."(name(.) = 'input' and @type != 'hidden')" + ." or name(.) = 'button'" + ." or name(.) = 'select'" + ." or name(.) = 'textarea'" + .')' + .' and ancestor::fieldset[@disabled]' + ); + // todo: in the second half, add "and is not a descendant of that fieldset element's first legend element child, if any." + } + + public function translateEnabled(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition( + '(' + .'@href and (' + ."name(.) = 'a'" + ." or name(.) = 'link'" + ." or name(.) = 'area'" + .')' + .') or (' + .'(' + ."name(.) = 'command'" + ." or name(.) = 'fieldset'" + ." or name(.) = 'optgroup'" + .')' + .' and not(@disabled)' + .') or (' + .'(' + ."(name(.) = 'input' and @type != 'hidden')" + ." or name(.) = 'button'" + ." or name(.) = 'select'" + ." or name(.) = 'textarea'" + ." or name(.) = 'keygen'" + .')' + .' and not (@disabled or ancestor::fieldset[@disabled])' + .') or (' + ."name(.) = 'option' and not(" + .'@disabled or ancestor::optgroup[@disabled]' + .')' + .')' + ); + } + + /** + * @throws ExpressionErrorException + */ + public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + $arguments = $function->getArguments(); + foreach ($arguments as $token) { + if (!($token->isString() || $token->isIdentifier())) { + throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments)); + } + } + + return $xpath->addCondition(sprintf( + 'ancestor-or-self::*[@lang][1][starts-with(concat(' + ."translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-')" + .', %s)]', + 'lang', + Translator::getXpathLiteral(strtolower($arguments[0]->getValue()).'-') + )); + } + + public function translateSelected(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition("(@selected and name(.) = 'option')"); + } + + public function translateInvalid(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition('0'); + } + + public function translateHover(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition('0'); + } + + public function translateVisited(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition('0'); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'html'; + } +} diff --git a/symfony/css-selector/XPath/Extension/NodeExtension.php b/symfony/css-selector/XPath/Extension/NodeExtension.php new file mode 100644 index 00000000..aa6f3f70 --- /dev/null +++ b/symfony/css-selector/XPath/Extension/NodeExtension.php @@ -0,0 +1,197 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\Node; +use Symfony\Component\CssSelector\XPath\Translator; +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator node extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class NodeExtension extends AbstractExtension +{ + public const ELEMENT_NAME_IN_LOWER_CASE = 1; + public const ATTRIBUTE_NAME_IN_LOWER_CASE = 2; + public const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4; + + private $flags; + + public function __construct(int $flags = 0) + { + $this->flags = $flags; + } + + /** + * @return $this + */ + public function setFlag(int $flag, bool $on): self + { + if ($on && !$this->hasFlag($flag)) { + $this->flags += $flag; + } + + if (!$on && $this->hasFlag($flag)) { + $this->flags -= $flag; + } + + return $this; + } + + public function hasFlag(int $flag): bool + { + return (bool) ($this->flags & $flag); + } + + /** + * {@inheritdoc} + */ + public function getNodeTranslators(): array + { + return [ + 'Selector' => [$this, 'translateSelector'], + 'CombinedSelector' => [$this, 'translateCombinedSelector'], + 'Negation' => [$this, 'translateNegation'], + 'Function' => [$this, 'translateFunction'], + 'Pseudo' => [$this, 'translatePseudo'], + 'Attribute' => [$this, 'translateAttribute'], + 'Class' => [$this, 'translateClass'], + 'Hash' => [$this, 'translateHash'], + 'Element' => [$this, 'translateElement'], + ]; + } + + public function translateSelector(Node\SelectorNode $node, Translator $translator): XPathExpr + { + return $translator->nodeToXPath($node->getTree()); + } + + public function translateCombinedSelector(Node\CombinedSelectorNode $node, Translator $translator): XPathExpr + { + return $translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector()); + } + + public function translateNegation(Node\NegationNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->getSelector()); + $subXpath = $translator->nodeToXPath($node->getSubSelector()); + $subXpath->addNameTest(); + + if ($subXpath->getCondition()) { + return $xpath->addCondition(sprintf('not(%s)', $subXpath->getCondition())); + } + + return $xpath->addCondition('0'); + } + + public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->getSelector()); + + return $translator->addFunction($xpath, $node); + } + + public function translatePseudo(Node\PseudoNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->getSelector()); + + return $translator->addPseudoClass($xpath, $node->getIdentifier()); + } + + public function translateAttribute(Node\AttributeNode $node, Translator $translator): XPathExpr + { + $name = $node->getAttribute(); + $safe = $this->isSafeName($name); + + if ($this->hasFlag(self::ATTRIBUTE_NAME_IN_LOWER_CASE)) { + $name = strtolower($name); + } + + if ($node->getNamespace()) { + $name = sprintf('%s:%s', $node->getNamespace(), $name); + $safe = $safe && $this->isSafeName($node->getNamespace()); + } + + $attribute = $safe ? '@'.$name : sprintf('attribute::*[name() = %s]', Translator::getXpathLiteral($name)); + $value = $node->getValue(); + $xpath = $translator->nodeToXPath($node->getSelector()); + + if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) { + $value = strtolower($value); + } + + return $translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value); + } + + public function translateClass(Node\ClassNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->getSelector()); + + return $translator->addAttributeMatching($xpath, '~=', '@class', $node->getName()); + } + + public function translateHash(Node\HashNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->getSelector()); + + return $translator->addAttributeMatching($xpath, '=', '@id', $node->getId()); + } + + public function translateElement(Node\ElementNode $node): XPathExpr + { + $element = $node->getElement(); + + if ($element && $this->hasFlag(self::ELEMENT_NAME_IN_LOWER_CASE)) { + $element = strtolower($element); + } + + if ($element) { + $safe = $this->isSafeName($element); + } else { + $element = '*'; + $safe = true; + } + + if ($node->getNamespace()) { + $element = sprintf('%s:%s', $node->getNamespace(), $element); + $safe = $safe && $this->isSafeName($node->getNamespace()); + } + + $xpath = new XPathExpr('', $element); + + if (!$safe) { + $xpath->addNameTest(); + } + + return $xpath; + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'node'; + } + + private function isSafeName(string $name): bool + { + return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name); + } +} diff --git a/symfony/css-selector/XPath/Extension/PseudoClassExtension.php b/symfony/css-selector/XPath/Extension/PseudoClassExtension.php new file mode 100644 index 00000000..a50b0486 --- /dev/null +++ b/symfony/css-selector/XPath/Extension/PseudoClassExtension.php @@ -0,0 +1,122 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\Exception\ExpressionErrorException; +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator pseudo-class extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class PseudoClassExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators(): array + { + return [ + 'root' => [$this, 'translateRoot'], + 'first-child' => [$this, 'translateFirstChild'], + 'last-child' => [$this, 'translateLastChild'], + 'first-of-type' => [$this, 'translateFirstOfType'], + 'last-of-type' => [$this, 'translateLastOfType'], + 'only-child' => [$this, 'translateOnlyChild'], + 'only-of-type' => [$this, 'translateOnlyOfType'], + 'empty' => [$this, 'translateEmpty'], + ]; + } + + public function translateRoot(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition('not(parent::*)'); + } + + public function translateFirstChild(XPathExpr $xpath): XPathExpr + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('position() = 1'); + } + + public function translateLastChild(XPathExpr $xpath): XPathExpr + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('position() = last()'); + } + + /** + * @throws ExpressionErrorException + */ + public function translateFirstOfType(XPathExpr $xpath): XPathExpr + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:first-of-type" is not implemented.'); + } + + return $xpath + ->addStarPrefix() + ->addCondition('position() = 1'); + } + + /** + * @throws ExpressionErrorException + */ + public function translateLastOfType(XPathExpr $xpath): XPathExpr + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:last-of-type" is not implemented.'); + } + + return $xpath + ->addStarPrefix() + ->addCondition('position() = last()'); + } + + public function translateOnlyChild(XPathExpr $xpath): XPathExpr + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('last() = 1'); + } + + public function translateOnlyOfType(XPathExpr $xpath): XPathExpr + { + $element = $xpath->getElement(); + + return $xpath->addCondition(sprintf('count(preceding-sibling::%s)=0 and count(following-sibling::%s)=0', $element, $element)); + } + + public function translateEmpty(XPathExpr $xpath): XPathExpr + { + return $xpath->addCondition('not(*) and not(string-length())'); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'pseudo-class'; + } +} diff --git a/symfony/css-selector/XPath/Translator.php b/symfony/css-selector/XPath/Translator.php new file mode 100644 index 00000000..8ce47303 --- /dev/null +++ b/symfony/css-selector/XPath/Translator.php @@ -0,0 +1,230 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath; + +use Symfony\Component\CssSelector\Exception\ExpressionErrorException; +use Symfony\Component\CssSelector\Node\FunctionNode; +use Symfony\Component\CssSelector\Node\NodeInterface; +use Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\Parser; +use Symfony\Component\CssSelector\Parser\ParserInterface; + +/** + * XPath expression translator interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class Translator implements TranslatorInterface +{ + private $mainParser; + + /** + * @var ParserInterface[] + */ + private $shortcutParsers = []; + + /** + * @var Extension\ExtensionInterface[] + */ + private $extensions = []; + + private $nodeTranslators = []; + private $combinationTranslators = []; + private $functionTranslators = []; + private $pseudoClassTranslators = []; + private $attributeMatchingTranslators = []; + + public function __construct(ParserInterface $parser = null) + { + $this->mainParser = $parser ?? new Parser(); + + $this + ->registerExtension(new Extension\NodeExtension()) + ->registerExtension(new Extension\CombinationExtension()) + ->registerExtension(new Extension\FunctionExtension()) + ->registerExtension(new Extension\PseudoClassExtension()) + ->registerExtension(new Extension\AttributeMatchingExtension()) + ; + } + + public static function getXpathLiteral(string $element): string + { + if (!str_contains($element, "'")) { + return "'".$element."'"; + } + + if (!str_contains($element, '"')) { + return '"'.$element.'"'; + } + + $string = $element; + $parts = []; + while (true) { + if (false !== $pos = strpos($string, "'")) { + $parts[] = sprintf("'%s'", substr($string, 0, $pos)); + $parts[] = "\"'\""; + $string = substr($string, $pos + 1); + } else { + $parts[] = "'$string'"; + break; + } + } + + return sprintf('concat(%s)', implode(', ', $parts)); + } + + /** + * {@inheritdoc} + */ + public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string + { + $selectors = $this->parseSelectors($cssExpr); + + /** @var SelectorNode $selector */ + foreach ($selectors as $index => $selector) { + if (null !== $selector->getPseudoElement()) { + throw new ExpressionErrorException('Pseudo-elements are not supported.'); + } + + $selectors[$index] = $this->selectorToXPath($selector, $prefix); + } + + return implode(' | ', $selectors); + } + + /** + * {@inheritdoc} + */ + public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string + { + return ($prefix ?: '').$this->nodeToXPath($selector); + } + + /** + * @return $this + */ + public function registerExtension(Extension\ExtensionInterface $extension): self + { + $this->extensions[$extension->getName()] = $extension; + + $this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators()); + $this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators()); + $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators()); + $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators()); + $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators()); + + return $this; + } + + /** + * @throws ExpressionErrorException + */ + public function getExtension(string $name): Extension\ExtensionInterface + { + if (!isset($this->extensions[$name])) { + throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name)); + } + + return $this->extensions[$name]; + } + + /** + * @return $this + */ + public function registerParserShortcut(ParserInterface $shortcut): self + { + $this->shortcutParsers[] = $shortcut; + + return $this; + } + + /** + * @throws ExpressionErrorException + */ + public function nodeToXPath(NodeInterface $node): XPathExpr + { + if (!isset($this->nodeTranslators[$node->getNodeName()])) { + throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName())); + } + + return $this->nodeTranslators[$node->getNodeName()]($node, $this); + } + + /** + * @throws ExpressionErrorException + */ + public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr + { + if (!isset($this->combinationTranslators[$combiner])) { + throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner)); + } + + return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); + } + + /** + * @throws ExpressionErrorException + */ + public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr + { + if (!isset($this->functionTranslators[$function->getName()])) { + throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName())); + } + + return $this->functionTranslators[$function->getName()]($xpath, $function); + } + + /** + * @throws ExpressionErrorException + */ + public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr + { + if (!isset($this->pseudoClassTranslators[$pseudoClass])) { + throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass)); + } + + return $this->pseudoClassTranslators[$pseudoClass]($xpath); + } + + /** + * @throws ExpressionErrorException + */ + public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, ?string $value): XPathExpr + { + if (!isset($this->attributeMatchingTranslators[$operator])) { + throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator)); + } + + return $this->attributeMatchingTranslators[$operator]($xpath, $attribute, $value); + } + + /** + * @return SelectorNode[] + */ + private function parseSelectors(string $css): array + { + foreach ($this->shortcutParsers as $shortcut) { + $tokens = $shortcut->parse($css); + + if (!empty($tokens)) { + return $tokens; + } + } + + return $this->mainParser->parse($css); + } +} diff --git a/symfony/css-selector/XPath/TranslatorInterface.php b/symfony/css-selector/XPath/TranslatorInterface.php new file mode 100644 index 00000000..c19eefb9 --- /dev/null +++ b/symfony/css-selector/XPath/TranslatorInterface.php @@ -0,0 +1,37 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath; + +use Symfony\Component\CssSelector\Node\SelectorNode; + +/** + * XPath expression translator interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +interface TranslatorInterface +{ + /** + * Translates a CSS selector to an XPath expression. + */ + public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string; + + /** + * Translates a parsed selector node to an XPath expression. + */ + public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string; +} diff --git a/symfony/css-selector/XPath/XPathExpr.php b/symfony/css-selector/XPath/XPathExpr.php new file mode 100644 index 00000000..e45ce7d8 --- /dev/null +++ b/symfony/css-selector/XPath/XPathExpr.php @@ -0,0 +1,111 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath; + +/** + * XPath expression translator interface. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> + * + * @internal + */ +class XPathExpr +{ + private $path; + private $element; + private $condition; + + public function __construct(string $path = '', string $element = '*', string $condition = '', bool $starPrefix = false) + { + $this->path = $path; + $this->element = $element; + $this->condition = $condition; + + if ($starPrefix) { + $this->addStarPrefix(); + } + } + + public function getElement(): string + { + return $this->element; + } + + /** + * @return $this + */ + public function addCondition(string $condition): self + { + $this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition; + + return $this; + } + + public function getCondition(): string + { + return $this->condition; + } + + /** + * @return $this + */ + public function addNameTest(): self + { + if ('*' !== $this->element) { + $this->addCondition('name() = '.Translator::getXpathLiteral($this->element)); + $this->element = '*'; + } + + return $this; + } + + /** + * @return $this + */ + public function addStarPrefix(): self + { + $this->path .= '*/'; + + return $this; + } + + /** + * Joins another XPathExpr with a combiner. + * + * @return $this + */ + public function join(string $combiner, self $expr): self + { + $path = $this->__toString().$combiner; + + if ('*/' !== $expr->path) { + $path .= $expr->path; + } + + $this->path = $path; + $this->element = $expr->element; + $this->condition = $expr->condition; + + return $this; + } + + public function __toString(): string + { + $path = $this->path.$this->element; + $condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']'; + + return $path.$condition; + } +} diff --git a/symfony/css-selector/composer.json b/symfony/css-selector/composer.json new file mode 100644 index 00000000..f0b71249 --- /dev/null +++ b/symfony/css-selector/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/css-selector", + "type": "library", + "description": "Converts CSS selectors to XPath expressions", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\CssSelector\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/symfony/dom-crawler/AbstractUriElement.php b/symfony/dom-crawler/AbstractUriElement.php new file mode 100644 index 00000000..8ff0b992 --- /dev/null +++ b/symfony/dom-crawler/AbstractUriElement.php @@ -0,0 +1,131 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +/** + * Any HTML element that can link to an URI. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +abstract class AbstractUriElement +{ + /** + * @var \DOMElement + */ + protected $node; + + /** + * @var string|null The method to use for the element + */ + protected $method; + + /** + * @var string The URI of the page where the element is embedded (or the base href) + */ + protected $currentUri; + + /** + * @param \DOMElement $node A \DOMElement instance + * @param string|null $currentUri The URI of the page where the link is embedded (or the base href) + * @param string|null $method The method to use for the link (GET by default) + * + * @throws \InvalidArgumentException if the node is not a link + */ + public function __construct(\DOMElement $node, string $currentUri = null, ?string $method = 'GET') + { + $this->setNode($node); + $this->method = $method ? strtoupper($method) : null; + $this->currentUri = $currentUri; + + $elementUriIsRelative = null === parse_url(trim($this->getRawUri()), \PHP_URL_SCHEME); + $baseUriIsAbsolute = null !== $this->currentUri && \in_array(strtolower(substr($this->currentUri, 0, 4)), ['http', 'file']); + if ($elementUriIsRelative && !$baseUriIsAbsolute) { + throw new \InvalidArgumentException(sprintf('The URL of the element is relative, so you must define its base URI passing an absolute URL to the constructor of the "%s" class ("%s" was passed).', __CLASS__, $this->currentUri)); + } + } + + /** + * Gets the node associated with this link. + * + * @return \DOMElement + */ + public function getNode() + { + return $this->node; + } + + /** + * Gets the method associated with this link. + * + * @return string + */ + public function getMethod() + { + return $this->method ?? 'GET'; + } + + /** + * Gets the URI associated with this link. + * + * @return string + */ + public function getUri() + { + return UriResolver::resolve($this->getRawUri(), $this->currentUri); + } + + /** + * Returns raw URI data. + * + * @return string + */ + abstract protected function getRawUri(); + + /** + * Returns the canonicalized URI path (see RFC 3986, section 5.2.4). + * + * @param string $path URI path + * + * @return string + */ + protected function canonicalizePath(string $path) + { + if ('' === $path || '/' === $path) { + return $path; + } + + if (str_ends_with($path, '.')) { + $path .= '/'; + } + + $output = []; + + foreach (explode('/', $path) as $segment) { + if ('..' === $segment) { + array_pop($output); + } elseif ('.' !== $segment) { + $output[] = $segment; + } + } + + return implode('/', $output); + } + + /** + * Sets current \DOMElement instance. + * + * @param \DOMElement $node A \DOMElement instance + * + * @throws \LogicException If given node is not an anchor + */ + abstract protected function setNode(\DOMElement $node); +} diff --git a/symfony/dom-crawler/Crawler.php b/symfony/dom-crawler/Crawler.php new file mode 100644 index 00000000..a32bc1dd --- /dev/null +++ b/symfony/dom-crawler/Crawler.php @@ -0,0 +1,1309 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +use Masterminds\HTML5; +use Symfony\Component\CssSelector\CssSelectorConverter; + +/** + * Crawler eases navigation of a list of \DOMNode objects. + * + * @author Fabien Potencier <fabien@symfony.com> + * + * @implements \IteratorAggregate<int, \DOMNode> + */ +class Crawler implements \Countable, \IteratorAggregate +{ + /** + * @var string|null + */ + protected $uri; + + /** + * The default namespace prefix to be used with XPath and CSS expressions. + * + * @var string + */ + private $defaultNamespacePrefix = 'default'; + + /** + * A map of manually registered namespaces. + * + * @var array<string, string> + */ + private $namespaces = []; + + /** + * A map of cached namespaces. + * + * @var \ArrayObject + */ + private $cachedNamespaces; + + /** + * The base href value. + * + * @var string|null + */ + private $baseHref; + + /** + * @var \DOMDocument|null + */ + private $document; + + /** + * @var list<\DOMNode> + */ + private $nodes = []; + + /** + * Whether the Crawler contains HTML or XML content (used when converting CSS to XPath). + * + * @var bool + */ + private $isHtml = true; + + /** + * @var HTML5|null + */ + private $html5Parser; + + /** + * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $node A Node to use as the base for the crawling + */ + public function __construct($node = null, string $uri = null, string $baseHref = null) + { + $this->uri = $uri; + $this->baseHref = $baseHref ?: $uri; + $this->html5Parser = class_exists(HTML5::class) ? new HTML5(['disable_html_ns' => true]) : null; + $this->cachedNamespaces = new \ArrayObject(); + + $this->add($node); + } + + /** + * Returns the current URI. + * + * @return string|null + */ + public function getUri() + { + return $this->uri; + } + + /** + * Returns base href. + * + * @return string|null + */ + public function getBaseHref() + { + return $this->baseHref; + } + + /** + * Removes all the nodes. + */ + public function clear() + { + $this->nodes = []; + $this->document = null; + $this->cachedNamespaces = new \ArrayObject(); + } + + /** + * Adds a node to the current list of nodes. + * + * This method uses the appropriate specialized add*() method based + * on the type of the argument. + * + * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $node A node + * + * @throws \InvalidArgumentException when node is not the expected type + */ + public function add($node) + { + if ($node instanceof \DOMNodeList) { + $this->addNodeList($node); + } elseif ($node instanceof \DOMNode) { + $this->addNode($node); + } elseif (\is_array($node)) { + $this->addNodes($node); + } elseif (\is_string($node)) { + $this->addContent($node); + } elseif (null !== $node) { + throw new \InvalidArgumentException(sprintf('Expecting a DOMNodeList or DOMNode instance, an array, a string, or null, but got "%s".', get_debug_type($node))); + } + } + + /** + * Adds HTML/XML content. + * + * If the charset is not set via the content type, it is assumed to be UTF-8, + * or ISO-8859-1 as a fallback, which is the default charset defined by the + * HTTP 1.1 specification. + */ + public function addContent(string $content, string $type = null) + { + if (empty($type)) { + $type = str_starts_with($content, '<?xml') ? 'application/xml' : 'text/html'; + } + + // DOM only for HTML/XML content + if (!preg_match('/(x|ht)ml/i', $type, $xmlMatches)) { + return; + } + + $charset = preg_match('//u', $content) ? 'UTF-8' : 'ISO-8859-1'; + + // http://www.w3.org/TR/encoding/#encodings + // http://www.w3.org/TR/REC-xml/#NT-EncName + $content = preg_replace_callback('/(charset *= *["\']?)([a-zA-Z\-0-9_:.]+)/i', function ($m) use (&$charset) { + if ('charset=' === $this->convertToHtmlEntities('charset=', $m[2])) { + $charset = $m[2]; + } + + return $m[1].$charset; + }, $content, 1); + + if ('x' === $xmlMatches[1]) { + $this->addXmlContent($content, $charset); + } else { + $this->addHtmlContent($content, $charset); + } + } + + /** + * Adds an HTML content to the list of nodes. + * + * The libxml errors are disabled when the content is parsed. + * + * If you want to get parsing errors, be sure to enable + * internal errors via libxml_use_internal_errors(true) + * and then, get the errors via libxml_get_errors(). Be + * sure to clear errors with libxml_clear_errors() afterward. + */ + public function addHtmlContent(string $content, string $charset = 'UTF-8') + { + $dom = $this->parseHtmlString($content, $charset); + $this->addDocument($dom); + + $base = $this->filterRelativeXPath('descendant-or-self::base')->extract(['href']); + + $baseHref = current($base); + if (\count($base) && !empty($baseHref)) { + if ($this->baseHref) { + $linkNode = $dom->createElement('a'); + $linkNode->setAttribute('href', $baseHref); + $link = new Link($linkNode, $this->baseHref); + $this->baseHref = $link->getUri(); + } else { + $this->baseHref = $baseHref; + } + } + } + + /** + * Adds an XML content to the list of nodes. + * + * The libxml errors are disabled when the content is parsed. + * + * If you want to get parsing errors, be sure to enable + * internal errors via libxml_use_internal_errors(true) + * and then, get the errors via libxml_get_errors(). Be + * sure to clear errors with libxml_clear_errors() afterward. + * + * @param int $options Bitwise OR of the libxml option constants + * LIBXML_PARSEHUGE is dangerous, see + * http://symfony.com/blog/security-release-symfony-2-0-17-released + */ + public function addXmlContent(string $content, string $charset = 'UTF-8', int $options = \LIBXML_NONET) + { + // remove the default namespace if it's the only namespace to make XPath expressions simpler + if (!preg_match('/xmlns:/', $content)) { + $content = str_replace('xmlns', 'ns', $content); + } + + $internalErrors = libxml_use_internal_errors(true); + if (\LIBXML_VERSION < 20900) { + $disableEntities = libxml_disable_entity_loader(true); + } + + $dom = new \DOMDocument('1.0', $charset); + $dom->validateOnParse = true; + + if ('' !== trim($content)) { + @$dom->loadXML($content, $options); + } + + libxml_use_internal_errors($internalErrors); + if (\LIBXML_VERSION < 20900) { + libxml_disable_entity_loader($disableEntities); + } + + $this->addDocument($dom); + + $this->isHtml = false; + } + + /** + * Adds a \DOMDocument to the list of nodes. + * + * @param \DOMDocument $dom A \DOMDocument instance + */ + public function addDocument(\DOMDocument $dom) + { + if ($dom->documentElement) { + $this->addNode($dom->documentElement); + } + } + + /** + * Adds a \DOMNodeList to the list of nodes. + * + * @param \DOMNodeList $nodes A \DOMNodeList instance + */ + public function addNodeList(\DOMNodeList $nodes) + { + foreach ($nodes as $node) { + if ($node instanceof \DOMNode) { + $this->addNode($node); + } + } + } + + /** + * Adds an array of \DOMNode instances to the list of nodes. + * + * @param \DOMNode[] $nodes An array of \DOMNode instances + */ + public function addNodes(array $nodes) + { + foreach ($nodes as $node) { + $this->add($node); + } + } + + /** + * Adds a \DOMNode instance to the list of nodes. + * + * @param \DOMNode $node A \DOMNode instance + */ + public function addNode(\DOMNode $node) + { + if ($node instanceof \DOMDocument) { + $node = $node->documentElement; + } + + if (null !== $this->document && $this->document !== $node->ownerDocument) { + throw new \InvalidArgumentException('Attaching DOM nodes from multiple documents in the same crawler is forbidden.'); + } + + if (null === $this->document) { + $this->document = $node->ownerDocument; + } + + // Don't add duplicate nodes in the Crawler + if (\in_array($node, $this->nodes, true)) { + return; + } + + $this->nodes[] = $node; + } + + /** + * Returns a node given its position in the node list. + * + * @return static + */ + public function eq(int $position) + { + if (isset($this->nodes[$position])) { + return $this->createSubCrawler($this->nodes[$position]); + } + + return $this->createSubCrawler(null); + } + + /** + * Calls an anonymous function on each node of the list. + * + * The anonymous function receives the position and the node wrapped + * in a Crawler instance as arguments. + * + * Example: + * + * $crawler->filter('h1')->each(function ($node, $i) { + * return $node->text(); + * }); + * + * @param \Closure $closure An anonymous function + * + * @return array An array of values returned by the anonymous function + */ + public function each(\Closure $closure) + { + $data = []; + foreach ($this->nodes as $i => $node) { + $data[] = $closure($this->createSubCrawler($node), $i); + } + + return $data; + } + + /** + * Slices the list of nodes by $offset and $length. + * + * @return static + */ + public function slice(int $offset = 0, int $length = null) + { + return $this->createSubCrawler(\array_slice($this->nodes, $offset, $length)); + } + + /** + * Reduces the list of nodes by calling an anonymous function. + * + * To remove a node from the list, the anonymous function must return false. + * + * @param \Closure $closure An anonymous function + * + * @return static + */ + public function reduce(\Closure $closure) + { + $nodes = []; + foreach ($this->nodes as $i => $node) { + if (false !== $closure($this->createSubCrawler($node), $i)) { + $nodes[] = $node; + } + } + + return $this->createSubCrawler($nodes); + } + + /** + * Returns the first node of the current selection. + * + * @return static + */ + public function first() + { + return $this->eq(0); + } + + /** + * Returns the last node of the current selection. + * + * @return static + */ + public function last() + { + return $this->eq(\count($this->nodes) - 1); + } + + /** + * Returns the siblings nodes of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When current node is empty + */ + public function siblings() + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0)->parentNode->firstChild)); + } + + public function matches(string $selector): bool + { + if (!$this->nodes) { + return false; + } + + $converter = $this->createCssSelectorConverter(); + $xpath = $converter->toXPath($selector, 'self::'); + + return 0 !== $this->filterRelativeXPath($xpath)->count(); + } + + /** + * Return first parents (heading toward the document root) of the Element that matches the provided selector. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill + * + * @throws \InvalidArgumentException When current node is empty + */ + public function closest(string $selector): ?self + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $domNode = $this->getNode(0); + + while (\XML_ELEMENT_NODE === $domNode->nodeType) { + $node = $this->createSubCrawler($domNode); + if ($node->matches($selector)) { + return $node; + } + + $domNode = $node->getNode(0)->parentNode; + } + + return null; + } + + /** + * Returns the next siblings nodes of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When current node is empty + */ + public function nextAll() + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0))); + } + + /** + * Returns the previous sibling nodes of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException + */ + public function previousAll() + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->createSubCrawler($this->sibling($this->getNode(0), 'previousSibling')); + } + + /** + * Returns the parent nodes of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When current node is empty + */ + public function parents() + { + trigger_deprecation('symfony/dom-crawler', '5.3', 'The %s() method is deprecated, use ancestors() instead.', __METHOD__); + + return $this->ancestors(); + } + + /** + * Returns the ancestors of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When the current node is empty + */ + public function ancestors() + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $nodes = []; + + while ($node = $node->parentNode) { + if (\XML_ELEMENT_NODE === $node->nodeType) { + $nodes[] = $node; + } + } + + return $this->createSubCrawler($nodes); + } + + /** + * Returns the children nodes of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When current node is empty + * @throws \RuntimeException If the CssSelector Component is not available and $selector is provided + */ + public function children(string $selector = null) + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + if (null !== $selector) { + $converter = $this->createCssSelectorConverter(); + $xpath = $converter->toXPath($selector, 'child::'); + + return $this->filterRelativeXPath($xpath); + } + + $node = $this->getNode(0)->firstChild; + + return $this->createSubCrawler($node ? $this->sibling($node) : []); + } + + /** + * Returns the attribute value of the first node of the list. + * + * @return string|null + * + * @throws \InvalidArgumentException When current node is empty + */ + public function attr(string $attribute) + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + return $node->hasAttribute($attribute) ? $node->getAttribute($attribute) : null; + } + + /** + * Returns the node name of the first node of the list. + * + * @return string + * + * @throws \InvalidArgumentException When current node is empty + */ + public function nodeName() + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + return $this->getNode(0)->nodeName; + } + + /** + * Returns the text of the first node of the list. + * + * Pass true as the second argument to normalize whitespaces. + * + * @param string|null $default When not null: the value to return when the current node is empty + * @param bool $normalizeWhitespace Whether whitespaces should be trimmed and normalized to single spaces + * + * @return string + * + * @throws \InvalidArgumentException When current node is empty + */ + public function text(string $default = null, bool $normalizeWhitespace = true) + { + if (!$this->nodes) { + if (null !== $default) { + return $default; + } + + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $text = $this->getNode(0)->nodeValue; + + if ($normalizeWhitespace) { + return trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $text)); + } + + return $text; + } + + /** + * Returns only the inner text that is the direct descendent of the current node, excluding any child nodes. + */ + public function innerText(): string + { + return $this->filterXPath('.//text()')->text(); + } + + /** + * Returns the first node of the list as HTML. + * + * @param string|null $default When not null: the value to return when the current node is empty + * + * @return string + * + * @throws \InvalidArgumentException When current node is empty + */ + public function html(string $default = null) + { + if (!$this->nodes) { + if (null !== $default) { + return $default; + } + + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $owner = $node->ownerDocument; + + if (null !== $this->html5Parser && '<!DOCTYPE html>' === $owner->saveXML($owner->childNodes[0])) { + $owner = $this->html5Parser; + } + + $html = ''; + foreach ($node->childNodes as $child) { + $html .= $owner->saveHTML($child); + } + + return $html; + } + + public function outerHtml(): string + { + if (!\count($this)) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + $owner = $node->ownerDocument; + + if (null !== $this->html5Parser && '<!DOCTYPE html>' === $owner->saveXML($owner->childNodes[0])) { + $owner = $this->html5Parser; + } + + return $owner->saveHTML($node); + } + + /** + * Evaluates an XPath expression. + * + * Since an XPath expression might evaluate to either a simple type or a \DOMNodeList, + * this method will return either an array of simple types or a new Crawler instance. + * + * @return array|Crawler + */ + public function evaluate(string $xpath) + { + if (null === $this->document) { + throw new \LogicException('Cannot evaluate the expression on an uninitialized crawler.'); + } + + $data = []; + $domxpath = $this->createDOMXPath($this->document, $this->findNamespacePrefixes($xpath)); + + foreach ($this->nodes as $node) { + $data[] = $domxpath->evaluate($xpath, $node); + } + + if (isset($data[0]) && $data[0] instanceof \DOMNodeList) { + return $this->createSubCrawler($data); + } + + return $data; + } + + /** + * Extracts information from the list of nodes. + * + * You can extract attributes or/and the node value (_text). + * + * Example: + * + * $crawler->filter('h1 a')->extract(['_text', 'href']); + * + * @return array + */ + public function extract(array $attributes) + { + $count = \count($attributes); + + $data = []; + foreach ($this->nodes as $node) { + $elements = []; + foreach ($attributes as $attribute) { + if ('_text' === $attribute) { + $elements[] = $node->nodeValue; + } elseif ('_name' === $attribute) { + $elements[] = $node->nodeName; + } else { + $elements[] = $node->getAttribute($attribute); + } + } + + $data[] = 1 === $count ? $elements[0] : $elements; + } + + return $data; + } + + /** + * Filters the list of nodes with an XPath expression. + * + * The XPath expression is evaluated in the context of the crawler, which + * is considered as a fake parent of the elements inside it. + * This means that a child selector "div" or "./div" will match only + * the div elements of the current crawler, not their children. + * + * @return static + */ + public function filterXPath(string $xpath) + { + $xpath = $this->relativize($xpath); + + // If we dropped all expressions in the XPath while preparing it, there would be no match + if ('' === $xpath) { + return $this->createSubCrawler(null); + } + + return $this->filterRelativeXPath($xpath); + } + + /** + * Filters the list of nodes with a CSS selector. + * + * This method only works if you have installed the CssSelector Symfony Component. + * + * @return static + * + * @throws \RuntimeException if the CssSelector Component is not available + */ + public function filter(string $selector) + { + $converter = $this->createCssSelectorConverter(); + + // The CssSelector already prefixes the selector with descendant-or-self:: + return $this->filterRelativeXPath($converter->toXPath($selector)); + } + + /** + * Selects links by name or alt value for clickable images. + * + * @return static + */ + public function selectLink(string $value) + { + return $this->filterRelativeXPath( + sprintf('descendant-or-self::a[contains(concat(\' \', normalize-space(string(.)), \' \'), %1$s) or ./img[contains(concat(\' \', normalize-space(string(@alt)), \' \'), %1$s)]]', static::xpathLiteral(' '.$value.' ')) + ); + } + + /** + * Selects images by alt value. + * + * @return static + */ + public function selectImage(string $value) + { + $xpath = sprintf('descendant-or-self::img[contains(normalize-space(string(@alt)), %s)]', static::xpathLiteral($value)); + + return $this->filterRelativeXPath($xpath); + } + + /** + * Selects a button by name or alt value for images. + * + * @return static + */ + public function selectButton(string $value) + { + return $this->filterRelativeXPath( + sprintf('descendant-or-self::input[((contains(%1$s, "submit") or contains(%1$s, "button")) and contains(concat(\' \', normalize-space(string(@value)), \' \'), %2$s)) or (contains(%1$s, "image") and contains(concat(\' \', normalize-space(string(@alt)), \' \'), %2$s)) or @id=%3$s or @name=%3$s] | descendant-or-self::button[contains(concat(\' \', normalize-space(string(.)), \' \'), %2$s) or @id=%3$s or @name=%3$s]', 'translate(@type, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")', static::xpathLiteral(' '.$value.' '), static::xpathLiteral($value)) + ); + } + + /** + * Returns a Link object for the first node in the list. + * + * @return Link + * + * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement + */ + public function link(string $method = 'get') + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOMElement) { + throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".', get_debug_type($node))); + } + + return new Link($node, $this->baseHref, $method); + } + + /** + * Returns an array of Link objects for the nodes in the list. + * + * @return Link[] + * + * @throws \InvalidArgumentException If the current node list contains non-DOMElement instances + */ + public function links() + { + $links = []; + foreach ($this->nodes as $node) { + if (!$node instanceof \DOMElement) { + throw new \InvalidArgumentException(sprintf('The current node list should contain only DOMElement instances, "%s" found.', get_debug_type($node))); + } + + $links[] = new Link($node, $this->baseHref, 'get'); + } + + return $links; + } + + /** + * Returns an Image object for the first node in the list. + * + * @return Image + * + * @throws \InvalidArgumentException If the current node list is empty + */ + public function image() + { + if (!\count($this)) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOMElement) { + throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".', get_debug_type($node))); + } + + return new Image($node, $this->baseHref); + } + + /** + * Returns an array of Image objects for the nodes in the list. + * + * @return Image[] + */ + public function images() + { + $images = []; + foreach ($this as $node) { + if (!$node instanceof \DOMElement) { + throw new \InvalidArgumentException(sprintf('The current node list should contain only DOMElement instances, "%s" found.', get_debug_type($node))); + } + + $images[] = new Image($node, $this->baseHref); + } + + return $images; + } + + /** + * Returns a Form object for the first node in the list. + * + * @return Form + * + * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement + */ + public function form(array $values = null, string $method = null) + { + if (!$this->nodes) { + throw new \InvalidArgumentException('The current node list is empty.'); + } + + $node = $this->getNode(0); + + if (!$node instanceof \DOMElement) { + throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".', get_debug_type($node))); + } + + $form = new Form($node, $this->uri, $method, $this->baseHref); + + if (null !== $values) { + $form->setValues($values); + } + + return $form; + } + + /** + * Overloads a default namespace prefix to be used with XPath and CSS expressions. + */ + public function setDefaultNamespacePrefix(string $prefix) + { + $this->defaultNamespacePrefix = $prefix; + } + + public function registerNamespace(string $prefix, string $namespace) + { + $this->namespaces[$prefix] = $namespace; + } + + /** + * Converts string for XPath expressions. + * + * Escaped characters are: quotes (") and apostrophe ('). + * + * Examples: + * + * echo Crawler::xpathLiteral('foo " bar'); + * //prints 'foo " bar' + * + * echo Crawler::xpathLiteral("foo ' bar"); + * //prints "foo ' bar" + * + * echo Crawler::xpathLiteral('a\'b"c'); + * //prints concat('a', "'", 'b"c') + * + * @return string + */ + public static function xpathLiteral(string $s) + { + if (!str_contains($s, "'")) { + return sprintf("'%s'", $s); + } + + if (!str_contains($s, '"')) { + return sprintf('"%s"', $s); + } + + $string = $s; + $parts = []; + while (true) { + if (false !== $pos = strpos($string, "'")) { + $parts[] = sprintf("'%s'", substr($string, 0, $pos)); + $parts[] = "\"'\""; + $string = substr($string, $pos + 1); + } else { + $parts[] = "'$string'"; + break; + } + } + + return sprintf('concat(%s)', implode(', ', $parts)); + } + + /** + * Filters the list of nodes with an XPath expression. + * + * The XPath expression should already be processed to apply it in the context of each node. + * + * @return static + */ + private function filterRelativeXPath(string $xpath): object + { + $crawler = $this->createSubCrawler(null); + if (null === $this->document) { + return $crawler; + } + + $domxpath = $this->createDOMXPath($this->document, $this->findNamespacePrefixes($xpath)); + + foreach ($this->nodes as $node) { + $crawler->add($domxpath->query($xpath, $node)); + } + + return $crawler; + } + + /** + * Make the XPath relative to the current context. + * + * The returned XPath will match elements matching the XPath inside the current crawler + * when running in the context of a node of the crawler. + */ + private function relativize(string $xpath): string + { + $expressions = []; + + // An expression which will never match to replace expressions which cannot match in the crawler + // We cannot drop + $nonMatchingExpression = 'a[name() = "b"]'; + + $xpathLen = \strlen($xpath); + $openedBrackets = 0; + $startPosition = strspn($xpath, " \t\n\r\0\x0B"); + + for ($i = $startPosition; $i <= $xpathLen; ++$i) { + $i += strcspn($xpath, '"\'[]|', $i); + + if ($i < $xpathLen) { + switch ($xpath[$i]) { + case '"': + case "'": + if (false === $i = strpos($xpath, $xpath[$i], $i + 1)) { + return $xpath; // The XPath expression is invalid + } + continue 2; + case '[': + ++$openedBrackets; + continue 2; + case ']': + --$openedBrackets; + continue 2; + } + } + if ($openedBrackets) { + continue; + } + + if ($startPosition < $xpathLen && '(' === $xpath[$startPosition]) { + // If the union is inside some braces, we need to preserve the opening braces and apply + // the change only inside it. + $j = 1 + strspn($xpath, "( \t\n\r\0\x0B", $startPosition + 1); + $parenthesis = substr($xpath, $startPosition, $j); + $startPosition += $j; + } else { + $parenthesis = ''; + } + $expression = rtrim(substr($xpath, $startPosition, $i - $startPosition)); + + if (str_starts_with($expression, 'self::*/')) { + $expression = './'.substr($expression, 8); + } + + // add prefix before absolute element selector + if ('' === $expression) { + $expression = $nonMatchingExpression; + } elseif (str_starts_with($expression, '//')) { + $expression = 'descendant-or-self::'.substr($expression, 2); + } elseif (str_starts_with($expression, './/')) { + $expression = 'descendant-or-self::'.substr($expression, 3); + } elseif (str_starts_with($expression, './')) { + $expression = 'self::'.substr($expression, 2); + } elseif (str_starts_with($expression, 'child::')) { + $expression = 'self::'.substr($expression, 7); + } elseif ('/' === $expression[0] || '.' === $expression[0] || str_starts_with($expression, 'self::')) { + $expression = $nonMatchingExpression; + } elseif (str_starts_with($expression, 'descendant::')) { + $expression = 'descendant-or-self::'.substr($expression, 12); + } elseif (preg_match('/^(ancestor|ancestor-or-self|attribute|following|following-sibling|namespace|parent|preceding|preceding-sibling)::/', $expression)) { + // the fake root has no parent, preceding or following nodes and also no attributes (even no namespace attributes) + $expression = $nonMatchingExpression; + } elseif (!str_starts_with($expression, 'descendant-or-self::')) { + $expression = 'self::'.$expression; + } + $expressions[] = $parenthesis.$expression; + + if ($i === $xpathLen) { + return implode(' | ', $expressions); + } + + $i += strspn($xpath, " \t\n\r\0\x0B", $i + 1); + $startPosition = $i + 1; + } + + return $xpath; // The XPath expression is invalid + } + + /** + * @return \DOMNode|null + */ + public function getNode(int $position) + { + return $this->nodes[$position] ?? null; + } + + /** + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + return \count($this->nodes); + } + + /** + * @return \ArrayIterator<int, \DOMNode> + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new \ArrayIterator($this->nodes); + } + + /** + * @return array + */ + protected function sibling(\DOMNode $node, string $siblingDir = 'nextSibling') + { + $nodes = []; + + $currentNode = $this->getNode(0); + do { + if ($node !== $currentNode && \XML_ELEMENT_NODE === $node->nodeType) { + $nodes[] = $node; + } + } while ($node = $node->$siblingDir); + + return $nodes; + } + + private function parseHtml5(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument + { + return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent, $charset)); + } + + private function parseXhtml(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument + { + $htmlContent = $this->convertToHtmlEntities($htmlContent, $charset); + + $internalErrors = libxml_use_internal_errors(true); + if (\LIBXML_VERSION < 20900) { + $disableEntities = libxml_disable_entity_loader(true); + } + + $dom = new \DOMDocument('1.0', $charset); + $dom->validateOnParse = true; + + if ('' !== trim($htmlContent)) { + @$dom->loadHTML($htmlContent); + } + + libxml_use_internal_errors($internalErrors); + if (\LIBXML_VERSION < 20900) { + libxml_disable_entity_loader($disableEntities); + } + + return $dom; + } + + /** + * Converts charset to HTML-entities to ensure valid parsing. + */ + private function convertToHtmlEntities(string $htmlContent, string $charset = 'UTF-8'): string + { + set_error_handler(function () { throw new \Exception(); }); + + try { + return mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], $charset); + } catch (\Exception|\ValueError $e) { + try { + $htmlContent = iconv($charset, 'UTF-8', $htmlContent); + $htmlContent = mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8'); + } catch (\Exception|\ValueError $e) { + } + + return $htmlContent; + } finally { + restore_error_handler(); + } + } + + /** + * @throws \InvalidArgumentException + */ + private function createDOMXPath(\DOMDocument $document, array $prefixes = []): \DOMXPath + { + $domxpath = new \DOMXPath($document); + + foreach ($prefixes as $prefix) { + $namespace = $this->discoverNamespace($domxpath, $prefix); + if (null !== $namespace) { + $domxpath->registerNamespace($prefix, $namespace); + } + } + + return $domxpath; + } + + /** + * @throws \InvalidArgumentException + */ + private function discoverNamespace(\DOMXPath $domxpath, string $prefix): ?string + { + if (\array_key_exists($prefix, $this->namespaces)) { + return $this->namespaces[$prefix]; + } + + if ($this->cachedNamespaces->offsetExists($prefix)) { + return $this->cachedNamespaces[$prefix]; + } + + // ask for one namespace, otherwise we'd get a collection with an item for each node + $namespaces = $domxpath->query(sprintf('(//namespace::*[name()="%s"])[last()]', $this->defaultNamespacePrefix === $prefix ? '' : $prefix)); + + return $this->cachedNamespaces[$prefix] = ($node = $namespaces->item(0)) ? $node->nodeValue : null; + } + + private function findNamespacePrefixes(string $xpath): array + { + if (preg_match_all('/(?P<prefix>[a-z_][a-z_0-9\-\.]*+):[^"\/:]/i', $xpath, $matches)) { + return array_unique($matches['prefix']); + } + + return []; + } + + /** + * Creates a crawler for some subnodes. + * + * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $nodes + * + * @return static + */ + private function createSubCrawler($nodes): object + { + $crawler = new static($nodes, $this->uri, $this->baseHref); + $crawler->isHtml = $this->isHtml; + $crawler->document = $this->document; + $crawler->namespaces = $this->namespaces; + $crawler->cachedNamespaces = $this->cachedNamespaces; + $crawler->html5Parser = $this->html5Parser; + + return $crawler; + } + + /** + * @throws \LogicException If the CssSelector Component is not available + */ + private function createCssSelectorConverter(): CssSelectorConverter + { + if (!class_exists(CssSelectorConverter::class)) { + throw new \LogicException('To filter with a CSS selector, install the CssSelector component ("composer require symfony/css-selector"). Or use filterXpath instead.'); + } + + return new CssSelectorConverter($this->isHtml); + } + + /** + * Parse string into DOMDocument object using HTML5 parser if the content is HTML5 and the library is available. + * Use libxml parser otherwise. + */ + private function parseHtmlString(string $content, string $charset): \DOMDocument + { + if ($this->canParseHtml5String($content)) { + return $this->parseHtml5($content, $charset); + } + + return $this->parseXhtml($content, $charset); + } + + private function canParseHtml5String(string $content): bool + { + if (null === $this->html5Parser) { + return false; + } + if (false === ($pos = stripos($content, '<!doctype html>'))) { + return false; + } + $header = substr($content, 0, $pos); + + return '' === $header || $this->isValidHtml5Heading($header); + } + + private function isValidHtml5Heading(string $heading): bool + { + return 1 === preg_match('/^\x{FEFF}?\s*(<!--[^>]*?-->\s*)*$/u', $heading); + } +} diff --git a/symfony/dom-crawler/Field/ChoiceFormField.php b/symfony/dom-crawler/Field/ChoiceFormField.php new file mode 100644 index 00000000..9eed27ba --- /dev/null +++ b/symfony/dom-crawler/Field/ChoiceFormField.php @@ -0,0 +1,321 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * ChoiceFormField represents a choice form field. + * + * It is constructed from an HTML select tag, or an HTML checkbox, or radio inputs. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class ChoiceFormField extends FormField +{ + /** + * @var string + */ + private $type; + /** + * @var bool + */ + private $multiple; + /** + * @var array + */ + private $options; + /** + * @var bool + */ + private $validationDisabled = false; + + /** + * Returns true if the field should be included in the submitted values. + * + * @return bool true if the field should be included in the submitted values, false otherwise + */ + public function hasValue() + { + // don't send a value for unchecked checkboxes + if (\in_array($this->type, ['checkbox', 'radio']) && null === $this->value) { + return false; + } + + return true; + } + + /** + * Check if the current selected option is disabled. + * + * @return bool + */ + public function isDisabled() + { + if (parent::isDisabled() && 'select' === $this->type) { + return true; + } + + foreach ($this->options as $option) { + if ($option['value'] == $this->value && $option['disabled']) { + return true; + } + } + + return false; + } + + /** + * Sets the value of the field. + * + * @param string|array $value The value of the field + */ + public function select($value) + { + $this->setValue($value); + } + + /** + * Ticks a checkbox. + * + * @throws \LogicException When the type provided is not correct + */ + public function tick() + { + if ('checkbox' !== $this->type) { + throw new \LogicException(sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); + } + + $this->setValue(true); + } + + /** + * Unticks a checkbox. + * + * @throws \LogicException When the type provided is not correct + */ + public function untick() + { + if ('checkbox' !== $this->type) { + throw new \LogicException(sprintf('You cannot untick "%s" as it is not a checkbox (%s).', $this->name, $this->type)); + } + + $this->setValue(false); + } + + /** + * Sets the value of the field. + * + * @param string|array|bool|null $value The value of the field + * + * @throws \InvalidArgumentException When value type provided is not correct + */ + public function setValue($value) + { + if ('checkbox' === $this->type && false === $value) { + // uncheck + $this->value = null; + } elseif ('checkbox' === $this->type && true === $value) { + // check + $this->value = $this->options[0]['value']; + } else { + if (\is_array($value)) { + if (!$this->multiple) { + throw new \InvalidArgumentException(sprintf('The value for "%s" cannot be an array.', $this->name)); + } + + foreach ($value as $v) { + if (!$this->containsOption($v, $this->options)) { + throw new \InvalidArgumentException(sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $v, implode('", "', $this->availableOptionValues()))); + } + } + } elseif (!$this->containsOption($value, $this->options)) { + throw new \InvalidArgumentException(sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $value, implode('", "', $this->availableOptionValues()))); + } + + if ($this->multiple) { + $value = (array) $value; + } + + if (\is_array($value)) { + $this->value = $value; + } else { + parent::setValue($value); + } + } + } + + /** + * Adds a choice to the current ones. + * + * @throws \LogicException When choice provided is not multiple nor radio + * + * @internal + */ + public function addChoice(\DOMElement $node) + { + if (!$this->multiple && 'radio' !== $this->type) { + throw new \LogicException(sprintf('Unable to add a choice for "%s" as it is not multiple or is not a radio button.', $this->name)); + } + + $option = $this->buildOptionValue($node); + $this->options[] = $option; + + if ($node->hasAttribute('checked')) { + $this->value = $option['value']; + } + } + + /** + * Returns the type of the choice field (radio, select, or checkbox). + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Returns true if the field accepts multiple values. + * + * @return bool + */ + public function isMultiple() + { + return $this->multiple; + } + + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize() + { + if ('input' !== $this->node->nodeName && 'select' !== $this->node->nodeName) { + throw new \LogicException(sprintf('A ChoiceFormField can only be created from an input or select tag (%s given).', $this->node->nodeName)); + } + + if ('input' === $this->node->nodeName && 'checkbox' !== strtolower($this->node->getAttribute('type')) && 'radio' !== strtolower($this->node->getAttribute('type'))) { + throw new \LogicException(sprintf('A ChoiceFormField can only be created from an input tag with a type of checkbox or radio (given type is "%s").', $this->node->getAttribute('type'))); + } + + $this->value = null; + $this->options = []; + $this->multiple = false; + + if ('input' == $this->node->nodeName) { + $this->type = strtolower($this->node->getAttribute('type')); + $optionValue = $this->buildOptionValue($this->node); + $this->options[] = $optionValue; + + if ($this->node->hasAttribute('checked')) { + $this->value = $optionValue['value']; + } + } else { + $this->type = 'select'; + if ($this->node->hasAttribute('multiple')) { + $this->multiple = true; + $this->value = []; + $this->name = str_replace('[]', '', $this->name); + } + + $found = false; + foreach ($this->xpath->query('descendant::option', $this->node) as $option) { + $optionValue = $this->buildOptionValue($option); + $this->options[] = $optionValue; + + if ($option->hasAttribute('selected')) { + $found = true; + if ($this->multiple) { + $this->value[] = $optionValue['value']; + } else { + $this->value = $optionValue['value']; + } + } + } + + // if no option is selected and if it is a simple select box, take the first option as the value + if (!$found && !$this->multiple && !empty($this->options)) { + $this->value = $this->options[0]['value']; + } + } + } + + /** + * Returns option value with associated disabled flag. + */ + private function buildOptionValue(\DOMElement $node): array + { + $option = []; + + $defaultDefaultValue = 'select' === $this->node->nodeName ? '' : 'on'; + $defaultValue = (isset($node->nodeValue) && !empty($node->nodeValue)) ? $node->nodeValue : $defaultDefaultValue; + $option['value'] = $node->hasAttribute('value') ? $node->getAttribute('value') : $defaultValue; + $option['disabled'] = $node->hasAttribute('disabled'); + + return $option; + } + + /** + * Checks whether given value is in the existing options. + * + * @internal since Symfony 5.3 + * + * @return bool + */ + public function containsOption(string $optionValue, array $options) + { + if ($this->validationDisabled) { + return true; + } + + foreach ($options as $option) { + if ($option['value'] == $optionValue) { + return true; + } + } + + return false; + } + + /** + * Returns list of available field options. + * + * @internal since Symfony 5.3 + * + * @return array + */ + public function availableOptionValues() + { + $values = []; + + foreach ($this->options as $option) { + $values[] = $option['value']; + } + + return $values; + } + + /** + * Disables the internal validation of the field. + * + * @internal since Symfony 5.3 + * + * @return $this + */ + public function disableValidation() + { + $this->validationDisabled = true; + + return $this; + } +} diff --git a/symfony/dom-crawler/Field/FileFormField.php b/symfony/dom-crawler/Field/FileFormField.php new file mode 100644 index 00000000..bd97e768 --- /dev/null +++ b/symfony/dom-crawler/Field/FileFormField.php @@ -0,0 +1,102 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * FileFormField represents a file form field (an HTML file input tag). + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class FileFormField extends FormField +{ + /** + * Sets the PHP error code associated with the field. + * + * @param int $error The error code (one of UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, or UPLOAD_ERR_EXTENSION) + * + * @throws \InvalidArgumentException When error code doesn't exist + */ + public function setErrorCode(int $error) + { + $codes = [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, \UPLOAD_ERR_PARTIAL, \UPLOAD_ERR_NO_FILE, \UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION]; + if (!\in_array($error, $codes)) { + throw new \InvalidArgumentException(sprintf('The error code "%s" is not valid.', $error)); + } + + $this->value = ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => $error, 'size' => 0]; + } + + /** + * Sets the value of the field. + */ + public function upload(?string $value) + { + $this->setValue($value); + } + + /** + * Sets the value of the field. + */ + public function setValue(?string $value) + { + if (null !== $value && is_readable($value)) { + $error = \UPLOAD_ERR_OK; + $size = filesize($value); + $info = pathinfo($value); + $name = $info['basename']; + + // copy to a tmp location + $tmp = sys_get_temp_dir().'/'.strtr(substr(base64_encode(hash('sha256', uniqid(mt_rand(), true), true)), 0, 7), '/', '_'); + if (\array_key_exists('extension', $info)) { + $tmp .= '.'.$info['extension']; + } + if (is_file($tmp)) { + unlink($tmp); + } + copy($value, $tmp); + $value = $tmp; + } else { + $error = \UPLOAD_ERR_NO_FILE; + $size = 0; + $name = ''; + $value = ''; + } + + $this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size]; + } + + /** + * Sets path to the file as string for simulating HTTP request. + */ + public function setFilePath(string $path) + { + parent::setValue($path); + } + + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize() + { + if ('input' !== $this->node->nodeName) { + throw new \LogicException(sprintf('A FileFormField can only be created from an input tag (%s given).', $this->node->nodeName)); + } + + if ('file' !== strtolower($this->node->getAttribute('type'))) { + throw new \LogicException(sprintf('A FileFormField can only be created from an input tag with a type of file (given type is "%s").', $this->node->getAttribute('type'))); + } + + $this->setValue(null); + } +} diff --git a/symfony/dom-crawler/Field/FormField.php b/symfony/dom-crawler/Field/FormField.php new file mode 100644 index 00000000..066af4a1 --- /dev/null +++ b/symfony/dom-crawler/Field/FormField.php @@ -0,0 +1,131 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * FormField is the abstract class for all form fields. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +abstract class FormField +{ + /** + * @var \DOMElement + */ + protected $node; + /** + * @var string + */ + protected $name; + /** + * @var string + */ + protected $value; + /** + * @var \DOMDocument + */ + protected $document; + /** + * @var \DOMXPath + */ + protected $xpath; + /** + * @var bool + */ + protected $disabled; + + /** + * @param \DOMElement $node The node associated with this field + */ + public function __construct(\DOMElement $node) + { + $this->node = $node; + $this->name = $node->getAttribute('name'); + $this->xpath = new \DOMXPath($node->ownerDocument); + + $this->initialize(); + } + + /** + * Returns the label tag associated to the field or null if none. + * + * @return \DOMElement|null + */ + public function getLabel() + { + $xpath = new \DOMXPath($this->node->ownerDocument); + + if ($this->node->hasAttribute('id')) { + $labels = $xpath->query(sprintf('descendant::label[@for="%s"]', $this->node->getAttribute('id'))); + if ($labels->length > 0) { + return $labels->item(0); + } + } + + $labels = $xpath->query('ancestor::label[1]', $this->node); + + return $labels->length > 0 ? $labels->item(0) : null; + } + + /** + * Returns the name of the field. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Gets the value of the field. + * + * @return string|array|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Sets the value of the field. + */ + public function setValue(?string $value) + { + $this->value = $value ?? ''; + } + + /** + * Returns true if the field should be included in the submitted values. + * + * @return bool + */ + public function hasValue() + { + return true; + } + + /** + * Check if the current field is disabled. + * + * @return bool + */ + public function isDisabled() + { + return $this->node->hasAttribute('disabled'); + } + + /** + * Initializes the form field. + */ + abstract protected function initialize(); +} diff --git a/symfony/dom-crawler/Field/InputFormField.php b/symfony/dom-crawler/Field/InputFormField.php new file mode 100644 index 00000000..1c3c84d7 --- /dev/null +++ b/symfony/dom-crawler/Field/InputFormField.php @@ -0,0 +1,46 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * InputFormField represents an input form field (an HTML input tag). + * + * For inputs with type of file, checkbox, or radio, there are other more + * specialized classes (cf. FileFormField and ChoiceFormField). + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class InputFormField extends FormField +{ + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize() + { + if ('input' !== $this->node->nodeName && 'button' !== $this->node->nodeName) { + throw new \LogicException(sprintf('An InputFormField can only be created from an input or button tag (%s given).', $this->node->nodeName)); + } + + $type = strtolower($this->node->getAttribute('type')); + if ('checkbox' === $type) { + throw new \LogicException('Checkboxes should be instances of ChoiceFormField.'); + } + + if ('file' === $type) { + throw new \LogicException('File inputs should be instances of FileFormField.'); + } + + $this->value = $this->node->getAttribute('value'); + } +} diff --git a/symfony/dom-crawler/Field/TextareaFormField.php b/symfony/dom-crawler/Field/TextareaFormField.php new file mode 100644 index 00000000..15526e1c --- /dev/null +++ b/symfony/dom-crawler/Field/TextareaFormField.php @@ -0,0 +1,37 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler\Field; + +/** + * TextareaFormField represents a textarea form field (an HTML textarea tag). + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class TextareaFormField extends FormField +{ + /** + * Initializes the form field. + * + * @throws \LogicException When node type is incorrect + */ + protected function initialize() + { + if ('textarea' !== $this->node->nodeName) { + throw new \LogicException(sprintf('A TextareaFormField can only be created from a textarea tag (%s given).', $this->node->nodeName)); + } + + $this->value = ''; + foreach ($this->node->childNodes as $node) { + $this->value .= $node->wholeText; + } + } +} diff --git a/symfony/dom-crawler/Form.php b/symfony/dom-crawler/Form.php new file mode 100644 index 00000000..ebad35b3 --- /dev/null +++ b/symfony/dom-crawler/Form.php @@ -0,0 +1,501 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +use Symfony\Component\DomCrawler\Field\ChoiceFormField; +use Symfony\Component\DomCrawler\Field\FormField; + +/** + * Form represents an HTML form. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class Form extends Link implements \ArrayAccess +{ + /** + * @var \DOMElement + */ + private $button; + + /** + * @var FormFieldRegistry + */ + private $fields; + + /** + * @var string + */ + private $baseHref; + + /** + * @param \DOMElement $node A \DOMElement instance + * @param string|null $currentUri The URI of the page where the form is embedded + * @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form) + * @param string|null $baseHref The URI of the <base> used for relative links, but not for empty action + * + * @throws \LogicException if the node is not a button inside a form tag + */ + public function __construct(\DOMElement $node, string $currentUri = null, string $method = null, string $baseHref = null) + { + parent::__construct($node, $currentUri, $method); + $this->baseHref = $baseHref; + + $this->initialize(); + } + + /** + * Gets the form node associated with this form. + * + * @return \DOMElement + */ + public function getFormNode() + { + return $this->node; + } + + /** + * Sets the value of the fields. + * + * @param array $values An array of field values + * + * @return $this + */ + public function setValues(array $values) + { + foreach ($values as $name => $value) { + $this->fields->set($name, $value); + } + + return $this; + } + + /** + * Gets the field values. + * + * The returned array does not include file fields (@see getFiles). + * + * @return array + */ + public function getValues() + { + $values = []; + foreach ($this->fields->all() as $name => $field) { + if ($field->isDisabled()) { + continue; + } + + if (!$field instanceof Field\FileFormField && $field->hasValue()) { + $values[$name] = $field->getValue(); + } + } + + return $values; + } + + /** + * Gets the file field values. + * + * @return array + */ + public function getFiles() + { + if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { + return []; + } + + $files = []; + + foreach ($this->fields->all() as $name => $field) { + if ($field->isDisabled()) { + continue; + } + + if ($field instanceof Field\FileFormField) { + $files[$name] = $field->getValue(); + } + } + + return $files; + } + + /** + * Gets the field values as PHP. + * + * This method converts fields with the array notation + * (like foo[bar] to arrays) like PHP does. + * + * @return array + */ + public function getPhpValues() + { + $values = []; + foreach ($this->getValues() as $name => $value) { + $qs = http_build_query([$name => $value], '', '&'); + if (!empty($qs)) { + parse_str($qs, $expandedValue); + $varName = substr($name, 0, \strlen(key($expandedValue))); + $values[] = [$varName => current($expandedValue)]; + } + } + + return array_replace_recursive([], ...$values); + } + + /** + * Gets the file field values as PHP. + * + * This method converts fields with the array notation + * (like foo[bar] to arrays) like PHP does. + * The returned array is consistent with the array for field values + * (@see getPhpValues), rather than uploaded files found in $_FILES. + * For a compound file field foo[bar] it will create foo[bar][name], + * instead of foo[name][bar] which would be found in $_FILES. + * + * @return array + */ + public function getPhpFiles() + { + $values = []; + foreach ($this->getFiles() as $name => $value) { + $qs = http_build_query([$name => $value], '', '&'); + if (!empty($qs)) { + parse_str($qs, $expandedValue); + $varName = substr($name, 0, \strlen(key($expandedValue))); + + array_walk_recursive( + $expandedValue, + function (&$value, $key) { + if (ctype_digit($value) && ('size' === $key || 'error' === $key)) { + $value = (int) $value; + } + } + ); + + reset($expandedValue); + + $values[] = [$varName => current($expandedValue)]; + } + } + + return array_replace_recursive([], ...$values); + } + + /** + * Gets the URI of the form. + * + * The returned URI is not the same as the form "action" attribute. + * This method merges the value if the method is GET to mimics + * browser behavior. + * + * @return string + */ + public function getUri() + { + $uri = parent::getUri(); + + if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { + $query = parse_url($uri, \PHP_URL_QUERY); + $currentParameters = []; + if ($query) { + parse_str($query, $currentParameters); + } + + $queryString = http_build_query(array_merge($currentParameters, $this->getValues()), '', '&'); + + $pos = strpos($uri, '?'); + $base = false === $pos ? $uri : substr($uri, 0, $pos); + $uri = rtrim($base.'?'.$queryString, '?'); + } + + return $uri; + } + + protected function getRawUri() + { + // If the form was created from a button rather than the form node, check for HTML5 action overrides + if ($this->button !== $this->node && $this->button->getAttribute('formaction')) { + return $this->button->getAttribute('formaction'); + } + + return $this->node->getAttribute('action'); + } + + /** + * Gets the form method. + * + * If no method is defined in the form, GET is returned. + * + * @return string + */ + public function getMethod() + { + if (null !== $this->method) { + return $this->method; + } + + // If the form was created from a button rather than the form node, check for HTML5 method override + if ($this->button !== $this->node && $this->button->getAttribute('formmethod')) { + return strtoupper($this->button->getAttribute('formmethod')); + } + + return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET'; + } + + /** + * Gets the form name. + * + * If no name is defined on the form, an empty string is returned. + */ + public function getName(): string + { + return $this->node->getAttribute('name'); + } + + /** + * Returns true if the named field exists. + * + * @return bool + */ + public function has(string $name) + { + return $this->fields->has($name); + } + + /** + * Removes a field from the form. + */ + public function remove(string $name) + { + $this->fields->remove($name); + } + + /** + * Gets a named field. + * + * @return FormField|FormField[]|FormField[][] + * + * @throws \InvalidArgumentException When field is not present in this form + */ + public function get(string $name) + { + return $this->fields->get($name); + } + + /** + * Sets a named field. + */ + public function set(FormField $field) + { + $this->fields->add($field); + } + + /** + * Gets all fields. + * + * @return FormField[] + */ + public function all() + { + return $this->fields->all(); + } + + /** + * Returns true if the named field exists. + * + * @param string $name The field name + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($name) + { + return $this->has($name); + } + + /** + * Gets the value of a field. + * + * @param string $name The field name + * + * @return FormField|FormField[]|FormField[][] + * + * @throws \InvalidArgumentException if the field does not exist + */ + #[\ReturnTypeWillChange] + public function offsetGet($name) + { + return $this->fields->get($name); + } + + /** + * Sets the value of a field. + * + * @param string $name The field name + * @param string|array $value The value of the field + * + * @return void + * + * @throws \InvalidArgumentException if the field does not exist + */ + #[\ReturnTypeWillChange] + public function offsetSet($name, $value) + { + $this->fields->set($name, $value); + } + + /** + * Removes a field from the form. + * + * @param string $name The field name + * + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($name) + { + $this->fields->remove($name); + } + + /** + * Disables validation. + * + * @return $this + */ + public function disableValidation() + { + foreach ($this->fields->all() as $field) { + if ($field instanceof Field\ChoiceFormField) { + $field->disableValidation(); + } + } + + return $this; + } + + /** + * Sets the node for the form. + * + * Expects a 'submit' button \DOMElement and finds the corresponding form element, or the form element itself. + * + * @throws \LogicException If given node is not a button or input or does not have a form ancestor + */ + protected function setNode(\DOMElement $node) + { + $this->button = $node; + if ('button' === $node->nodeName || ('input' === $node->nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) { + if ($node->hasAttribute('form')) { + // if the node has the HTML5-compliant 'form' attribute, use it + $formId = $node->getAttribute('form'); + $form = $node->ownerDocument->getElementById($formId); + if (null === $form) { + throw new \LogicException(sprintf('The selected node has an invalid form attribute (%s).', $formId)); + } + $this->node = $form; + + return; + } + // we loop until we find a form ancestor + do { + if (null === $node = $node->parentNode) { + throw new \LogicException('The selected node does not have a form ancestor.'); + } + } while ('form' !== $node->nodeName); + } elseif ('form' !== $node->nodeName) { + throw new \LogicException(sprintf('Unable to submit on a "%s" tag.', $node->nodeName)); + } + + $this->node = $node; + } + + /** + * Adds form elements related to this form. + * + * Creates an internal copy of the submitted 'button' element and + * the form node or the entire document depending on whether we need + * to find non-descendant elements through HTML5 'form' attribute. + */ + private function initialize() + { + $this->fields = new FormFieldRegistry(); + + $xpath = new \DOMXPath($this->node->ownerDocument); + + // add submitted button if it has a valid name + if ('form' !== $this->button->nodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) { + if ('input' == $this->button->nodeName && 'image' == strtolower($this->button->getAttribute('type'))) { + $name = $this->button->getAttribute('name'); + $this->button->setAttribute('value', '0'); + + // temporarily change the name of the input node for the x coordinate + $this->button->setAttribute('name', $name.'.x'); + $this->set(new Field\InputFormField($this->button)); + + // temporarily change the name of the input node for the y coordinate + $this->button->setAttribute('name', $name.'.y'); + $this->set(new Field\InputFormField($this->button)); + + // restore the original name of the input node + $this->button->setAttribute('name', $name); + } else { + $this->set(new Field\InputFormField($this->button)); + } + } + + // find form elements corresponding to the current form + if ($this->node->hasAttribute('id')) { + // corresponding elements are either descendants or have a matching HTML5 form attribute + $formId = Crawler::xpathLiteral($this->node->getAttribute('id')); + + $fieldNodes = $xpath->query(sprintf('( descendant::input[@form=%s] | descendant::button[@form=%1$s] | descendant::textarea[@form=%1$s] | descendant::select[@form=%1$s] | //form[@id=%1$s]//input[not(@form)] | //form[@id=%1$s]//button[not(@form)] | //form[@id=%1$s]//textarea[not(@form)] | //form[@id=%1$s]//select[not(@form)] )[not(ancestor::template)]', $formId)); + foreach ($fieldNodes as $node) { + $this->addField($node); + } + } else { + // do the xpath query with $this->node as the context node, to only find descendant elements + // however, descendant elements with form attribute are not part of this form + $fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[not(ancestor::template)]', $this->node); + foreach ($fieldNodes as $node) { + $this->addField($node); + } + } + + if ($this->baseHref && '' !== $this->node->getAttribute('action')) { + $this->currentUri = $this->baseHref; + } + } + + private function addField(\DOMElement $node) + { + if (!$node->hasAttribute('name') || !$node->getAttribute('name')) { + return; + } + + $nodeName = $node->nodeName; + if ('select' == $nodeName || 'input' == $nodeName && 'checkbox' == strtolower($node->getAttribute('type'))) { + $this->set(new Field\ChoiceFormField($node)); + } elseif ('input' == $nodeName && 'radio' == strtolower($node->getAttribute('type'))) { + // there may be other fields with the same name that are no choice + // fields already registered (see https://github.com/symfony/symfony/issues/11689) + if ($this->has($node->getAttribute('name')) && $this->get($node->getAttribute('name')) instanceof ChoiceFormField) { + $this->get($node->getAttribute('name'))->addChoice($node); + } else { + $this->set(new Field\ChoiceFormField($node)); + } + } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type'))) { + $this->set(new Field\FileFormField($node)); + } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image'])) { + $this->set(new Field\InputFormField($node)); + } elseif ('textarea' == $nodeName) { + $this->set(new Field\TextareaFormField($node)); + } + } +} diff --git a/symfony/dom-crawler/FormFieldRegistry.php b/symfony/dom-crawler/FormFieldRegistry.php new file mode 100644 index 00000000..93522adc --- /dev/null +++ b/symfony/dom-crawler/FormFieldRegistry.php @@ -0,0 +1,178 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +use Symfony\Component\DomCrawler\Field\FormField; + +/** + * This is an internal class that must not be used directly. + * + * @internal + */ +class FormFieldRegistry +{ + private $fields = []; + + private $base = ''; + + /** + * Adds a field to the registry. + */ + public function add(FormField $field) + { + $segments = $this->getSegments($field->getName()); + + $target = &$this->fields; + while ($segments) { + if (!\is_array($target)) { + $target = []; + } + $path = array_shift($segments); + if ('' === $path) { + $target = &$target[]; + } else { + $target = &$target[$path]; + } + } + $target = $field; + } + + /** + * Removes a field based on the fully qualifed name and its children from the registry. + */ + public function remove(string $name) + { + $segments = $this->getSegments($name); + $target = &$this->fields; + while (\count($segments) > 1) { + $path = array_shift($segments); + if (!\is_array($target) || !\array_key_exists($path, $target)) { + return; + } + $target = &$target[$path]; + } + unset($target[array_shift($segments)]); + } + + /** + * Returns the value of the field based on the fully qualifed name and its children. + * + * @return FormField|FormField[]|FormField[][] + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function &get(string $name) + { + $segments = $this->getSegments($name); + $target = &$this->fields; + while ($segments) { + $path = array_shift($segments); + if (!\is_array($target) || !\array_key_exists($path, $target)) { + throw new \InvalidArgumentException(sprintf('Unreachable field "%s".', $path)); + } + $target = &$target[$path]; + } + + return $target; + } + + /** + * Tests whether the form has the given field based on the fully qualified name. + */ + public function has(string $name): bool + { + try { + $this->get($name); + + return true; + } catch (\InvalidArgumentException $e) { + return false; + } + } + + /** + * Set the value of a field based on the fully qualified name and its children. + * + * @param mixed $value The value + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function set(string $name, $value) + { + $target = &$this->get($name); + if ((!\is_array($value) && $target instanceof Field\FormField) || $target instanceof Field\ChoiceFormField) { + $target->setValue($value); + } elseif (\is_array($value)) { + $registry = new static(); + $registry->base = $name; + $registry->fields = $value; + foreach ($registry->all() as $k => $v) { + $this->set($k, $v); + } + } else { + throw new \InvalidArgumentException(sprintf('Cannot set value on a compound field "%s".', $name)); + } + } + + /** + * Returns the list of field with their value. + * + * @return FormField[] The list of fields as [string] Fully qualified name => (mixed) value) + */ + public function all(): array + { + return $this->walk($this->fields, $this->base); + } + + /** + * Transforms a PHP array in a list of fully qualified name / value. + */ + private function walk(array $array, ?string $base = '', array &$output = []): array + { + foreach ($array as $k => $v) { + $path = empty($base) ? $k : sprintf('%s[%s]', $base, $k); + if (\is_array($v)) { + $this->walk($v, $path, $output); + } else { + $output[$path] = $v; + } + } + + return $output; + } + + /** + * Splits a field name into segments as a web browser would do. + * + * getSegments('base[foo][3][]') = ['base', 'foo, '3', '']; + * + * @return string[] + */ + private function getSegments(string $name): array + { + if (preg_match('/^(?P<base>[^[]+)(?P<extra>(\[.*)|$)/', $name, $m)) { + $segments = [$m['base']]; + while (!empty($m['extra'])) { + $extra = $m['extra']; + if (preg_match('/^\[(?P<segment>.*?)\](?P<extra>.*)$/', $extra, $m)) { + $segments[] = $m['segment']; + } else { + $segments[] = $extra; + } + } + + return $segments; + } + + return [$name]; + } +} diff --git a/symfony/dom-crawler/Image.php b/symfony/dom-crawler/Image.php new file mode 100644 index 00000000..b1ac5ca2 --- /dev/null +++ b/symfony/dom-crawler/Image.php @@ -0,0 +1,37 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +/** + * Image represents an HTML image (an HTML img tag). + */ +class Image extends AbstractUriElement +{ + public function __construct(\DOMElement $node, string $currentUri = null) + { + parent::__construct($node, $currentUri, 'GET'); + } + + protected function getRawUri() + { + return $this->node->getAttribute('src'); + } + + protected function setNode(\DOMElement $node) + { + if ('img' !== $node->nodeName) { + throw new \LogicException(sprintf('Unable to visualize a "%s" tag.', $node->nodeName)); + } + + $this->node = $node; + } +} diff --git a/symfony/dom-crawler/LICENSE b/symfony/dom-crawler/LICENSE new file mode 100644 index 00000000..88bf75bb --- /dev/null +++ b/symfony/dom-crawler/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2022 Fabien Potencier + +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/symfony/dom-crawler/Link.php b/symfony/dom-crawler/Link.php new file mode 100644 index 00000000..80a356e4 --- /dev/null +++ b/symfony/dom-crawler/Link.php @@ -0,0 +1,34 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +/** + * Link represents an HTML link (an HTML a, area or link tag). + * + * @author Fabien Potencier <fabien@symfony.com> + */ +class Link extends AbstractUriElement +{ + protected function getRawUri() + { + return $this->node->getAttribute('href'); + } + + protected function setNode(\DOMElement $node) + { + if ('a' !== $node->nodeName && 'area' !== $node->nodeName && 'link' !== $node->nodeName) { + throw new \LogicException(sprintf('Unable to navigate from a "%s" tag.', $node->nodeName)); + } + + $this->node = $node; + } +} diff --git a/symfony/dom-crawler/README.md b/symfony/dom-crawler/README.md new file mode 100644 index 00000000..c77a5e39 --- /dev/null +++ b/symfony/dom-crawler/README.md @@ -0,0 +1,13 @@ +DomCrawler Component +==================== + +The DomCrawler component eases DOM navigation for HTML and XML documents. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/dom_crawler.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/symfony/dom-crawler/UriResolver.php b/symfony/dom-crawler/UriResolver.php new file mode 100644 index 00000000..be64f525 --- /dev/null +++ b/symfony/dom-crawler/UriResolver.php @@ -0,0 +1,136 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DomCrawler; + +/** + * The UriResolver class takes an URI (relative, absolute, fragment, etc.) + * and turns it into an absolute URI against another given base URI. + * + * @author Fabien Potencier <fabien@symfony.com> + * @author Grégoire Pineau <lyrixx@lyrixx.info> + */ +class UriResolver +{ + /** + * Resolves a URI according to a base URI. + * + * For example if $uri=/foo/bar and $baseUri=https://symfony.com it will + * return https://symfony.com/foo/bar + * + * If the $uri is not absolute you must pass an absolute $baseUri + */ + public static function resolve(string $uri, ?string $baseUri): string + { + $uri = trim($uri); + + // absolute URL? + if (null !== parse_url($uri, \PHP_URL_SCHEME)) { + return $uri; + } + + if (null === $baseUri) { + throw new \InvalidArgumentException('The URI is relative, so you must define its base URI passing an absolute URL.'); + } + + // empty URI + if (!$uri) { + return $baseUri; + } + + // an anchor + if ('#' === $uri[0]) { + return self::cleanupAnchor($baseUri).$uri; + } + + $baseUriCleaned = self::cleanupUri($baseUri); + + if ('?' === $uri[0]) { + return $baseUriCleaned.$uri; + } + + // absolute URL with relative schema + if (0 === strpos($uri, '//')) { + return preg_replace('#^([^/]*)//.*$#', '$1', $baseUriCleaned).$uri; + } + + $baseUriCleaned = preg_replace('#^(.*?//[^/]*)(?:\/.*)?$#', '$1', $baseUriCleaned); + + // absolute path + if ('/' === $uri[0]) { + return $baseUriCleaned.$uri; + } + + // relative path + $path = parse_url(substr($baseUri, \strlen($baseUriCleaned)), \PHP_URL_PATH); + $path = self::canonicalizePath(substr($path, 0, strrpos($path, '/')).'/'.$uri); + + return $baseUriCleaned.('' === $path || '/' !== $path[0] ? '/' : '').$path; + } + + /** + * Returns the canonicalized URI path (see RFC 3986, section 5.2.4). + */ + private static function canonicalizePath(string $path): string + { + if ('' === $path || '/' === $path) { + return $path; + } + + if ('.' === substr($path, -1)) { + $path .= '/'; + } + + $output = []; + + foreach (explode('/', $path) as $segment) { + if ('..' === $segment) { + array_pop($output); + } elseif ('.' !== $segment) { + $output[] = $segment; + } + } + + return implode('/', $output); + } + + /** + * Removes the query string and the anchor from the given uri. + */ + private static function cleanupUri(string $uri): string + { + return self::cleanupQuery(self::cleanupAnchor($uri)); + } + + /** + * Removes the query string from the uri. + */ + private static function cleanupQuery(string $uri): string + { + if (false !== $pos = strpos($uri, '?')) { + return substr($uri, 0, $pos); + } + + return $uri; + } + + /** + * Removes the anchor from the uri. + */ + private static function cleanupAnchor(string $uri): string + { + if (false !== $pos = strpos($uri, '#')) { + return substr($uri, 0, $pos); + } + + return $uri; + } +} diff --git a/symfony/dom-crawler/composer.json b/symfony/dom-crawler/composer.json new file mode 100644 index 00000000..f89432da --- /dev/null +++ b/symfony/dom-crawler/composer.json @@ -0,0 +1,42 @@ +{ + "name": "symfony/dom-crawler", + "type": "library", + "description": "Eases DOM navigation for HTML and XML documents", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "symfony/css-selector": "^4.4|^5.0|^6.0", + "masterminds/html5": "^2.6" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "suggest": { + "symfony/css-selector": "" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\DomCrawler\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} |