diff --git a/.ddev/commands/host/libs b/.ddev/commands/host/libs new file mode 100755 index 00000000..d8b5d1f8 --- /dev/null +++ b/.ddev/commands/host/libs @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +## Description: Run Composer command for TER libraries +## Usage: libs [command] [options] +## Example: ddev libs install + +ddev composer -d /var/www/html/Resources/Private/Libs/Build "$@" diff --git a/.ddev/commands/web/frontend b/.ddev/commands/web/frontend new file mode 100755 index 00000000..c26e6792 --- /dev/null +++ b/.ddev/commands/web/frontend @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +## Description: Run yarn command for the Frontend subproject +## Usage: frontend [command] [options] +## Example: ddev frontend install\nddev frontend start\nddev frontend build\nddev frontend lint\nddev frontend fix + +yarn --cwd /var/www/html/Resources/Private/Frontend "$@" diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 00000000..8477acc0 --- /dev/null +++ b/.ddev/config.yaml @@ -0,0 +1,266 @@ +name: typo3-ext-warming +type: typo3 +docroot: .Build/web +php_version: "8.1" +webserver_type: nginx-fpm +router_http_port: "80" +router_https_port: "443" +xdebug_enabled: false +additional_hostnames: [] +additional_fqdns: [] +database: + type: mariadb + version: "10.4" +nfs_mount_enabled: false +mutagen_enabled: false +omit_containers: [dba] +webimage_extra_packages: [php8.1-pcov] +use_dns_when_possible: true +composer_version: "2" +web_environment: + - TYPO3_CONTEXT=Development/DDEV + - typo3DatabaseHost=db + - typo3DatabaseUsername=root + - typo3DatabasePassword=root + - typo3DatabaseName=db +nodejs_version: "16" + +# Key features of ddev's config.yaml: + +# name: # Name of the project, automatically provides +# http://projectname.ddev.site and https://projectname.ddev.site + +# type: # drupal6/7/8, backdrop, typo3, wordpress, php + +# docroot: # Relative path to the directory containing index.php. + +# php_version: "7.4" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2" + +# You can explicitly specify the webimage but this +# is not recommended, as the images are often closely tied to ddev's' behavior, +# so this can break upgrades. + +# webimage: # nginx/php docker image. + +# database: +# type: # mysql, mariadb +# version: # database version, like "10.3" or "8.0" +# Note that mariadb_version or mysql_version from v1.18 and earlier +# will automatically be converted to this notation with just a "ddev config --auto" + +# router_http_port: # Port to be used for http (defaults to port 80) +# router_https_port: # Port for https (defaults to 443) + +# xdebug_enabled: false # Set to true to enable xdebug and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xdebug" to enable xdebug and "ddev xdebug off" to disable it work better, +# as leaving xdebug enabled all the time is a big performance hit. + +# xhprof_enabled: false # Set to true to enable xhprof and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xhprof" to enable xhprof and "ddev xhprof off" to disable it work better, +# as leaving xhprof enabled all the time is a big performance hit. + +# webserver_type: nginx-fpm # or apache-fpm + +# timezone: Europe/Berlin +# This is the timezone used in the containers and by PHP; +# it can be set to any valid timezone, +# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# For example Europe/Dublin or MST7MDT + +# composer_root: +# Relative path to the composer root directory from the project root. This is +# the directory which contains the composer.json and where all Composer related +# commands are executed. + +# composer_version: "2" +# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 +# to use the latest major version available at the time your container is built. +# It is also possible to use each other Composer version channel. This includes: +# - 2.2 (latest Composer LTS version) +# - stable +# - preview +# - snapshot +# Alternatively, an explicit Composer version may be specified, for example "2.2.18". +# To reinstall Composer after the image was built, run "ddev debug refresh". + +# nodejs_version: "16" +# change from the default system Node.js version to another supported version, like 12, 14, 17, 18. +# Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any +# Node.js version, including v6, etc. + +# additional_hostnames: +# - somename +# - someothername +# would provide http and https URLs for "somename.ddev.site" +# and "someothername.ddev.site". + +# additional_fqdns: +# - example.com +# - sub1.example.com +# would provide http and https URLs for "example.com" and "sub1.example.com" +# Please take care with this because it can cause great confusion. + +# upload_dir: custom/upload/dir +# would set the destination path for ddev import-files to /custom/upload/dir +# When mutagen is enabled this path is bind-mounted so that all the files +# in the upload_dir don't have to be synced into mutagen + +# working_dir: +# web: /var/www/html +# db: /home +# would set the default working directory for the web and db services. +# These values specify the destination directory for ddev ssh and the +# directory in which commands passed into ddev exec are run. + +# omit_containers: [db, dba, ddev-ssh-agent] +# Currently only these containers are supported. Some containers can also be +# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit +# the "db" container, several standard features of ddev that access the +# database container will be unusable. In the global configuration it is also +# possible to omit ddev-router, but not here. + +# nfs_mount_enabled: false +# Great performance improvement but requires host configuration first. +# See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs + +# mutagen_enabled: false +# Performance improvement using mutagen asynchronous updates. +# See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen + +# fail_on_hook_fail: False +# Decide whether 'ddev start' should be interrupted by a failing hook + +# host_https_port: "59002" +# The host port binding for https can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_webserver_port: "59001" +# The host port binding for the ddev-webserver can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_db_port: "59002" +# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic +# unless explicitly specified. + +# phpmyadmin_port: "8036" +# phpmyadmin_https_port: "8037" +# The PHPMyAdmin ports can be changed from the default 8036 and 8037 + +# host_phpmyadmin_port: "8036" +# The phpmyadmin (dba) port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be specified and bound. + +# mailhog_port: "8025" +# mailhog_https_port: "8026" +# The MailHog ports can be changed from the default 8025 and 8026 + +# host_mailhog_port: "8025" +# The mailhog port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be bound directly to localhost if specified here. + +# webimage_extra_packages: [php7.4-tidy, php-bcmath] +# Extra Debian packages that are needed in the webimage can be added here + +# dbimage_extra_packages: [telnet,netcat] +# Extra Debian packages that are needed in the dbimage can be added here + +# use_dns_when_possible: true +# If the host has internet access and the domain configured can +# successfully be looked up, DNS will be used for hostname resolution +# instead of editing /etc/hosts +# Defaults to true + +# project_tld: ddev.site +# The top-level domain used for project URLs +# The default "ddev.site" allows DNS lookup via a wildcard +# If you prefer you can change this to "ddev.local" to preserve +# pre-v1.9 behavior. + +# ngrok_args: --basic-auth username:pass1234 +# Provide extra flags to the "ngrok http" command, see +# https://ngrok.com/docs#http or run "ngrok http -h" + +# disable_settings_management: false +# If true, ddev will not create CMS-specific settings files like +# Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php +# In this case the user must provide all such settings. + +# You can inject environment variables into the web container with: +# web_environment: +# - SOMEENV=somevalue +# - SOMEOTHERENV=someothervalue + +# no_project_mount: false +# (Experimental) If true, ddev will not mount the project into the web container; +# the user is responsible for mounting it manually or via a script. +# This is to enable experimentation with alternate file mounting strategies. +# For advanced users only! + +# bind_all_interfaces: false +# If true, host ports will be bound on all network interfaces, +# not just the localhost interface. This means that ports +# will be available on the local network if the host firewall +# allows it. + +# default_container_timeout: 120 +# The default time that ddev waits for all containers to become ready can be increased from +# the default 120. This helps in importing huge databases, for example. + +#web_extra_exposed_ports: +#- name: nodejs +# container_port: 3000 +# http_port: 2999 +# https_port: 3000 +#- name: something +# container_port: 4000 +# https_port: 4000 +# http_port: 3999 +# Allows a set of extra ports to be exposed via ddev-router +# The port behavior on the ddev-webserver must be arranged separately, for example +# using web_extra_daemons. +# For example, with a web app on port 3000 inside the container, this config would +# expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 +# web_extra_exposed_ports: +# - container_port: 3000 +# http_port: 9998 +# https_port: 9999 + +#web_extra_daemons: +#- name: "http-1" +# command: "/var/www/html/node_modules/.bin/http-server -p 3000" +# directory: /var/www/html +#- name: "http-2" +# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" +# directory: /var/www/html + +# override_config: false +# By default, config.*.yaml files are *merged* into the configuration +# But this means that some things can't be overridden +# For example, if you have 'nfs_mount_enabled: true'' you can't override it with a merge +# and you can't erase existing hooks or all environment variables. +# However, with "override_config: true" in a particular config.*.yaml file, +# 'nfs_mount_enabled: false' can override the existing values, and +# hooks: +# post-start: [] +# or +# web_environment: [] +# or +# additional_hostnames: [] +# can have their intended affect. 'override_config' affects only behavior of the +# config.*.yaml file it exists in. + +# Many ddev commands can be extended to run tasks before or after the +# ddev command is executed, for example "post-start", "post-import-db", +# "pre-composer", "post-composer" +# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more +# information on the commands that can be extended and the tasks you can define +# for them. Example: +#hooks: +# post-start: +# - exec: composer install -d /var/www/html diff --git a/.editorconfig-lint.php b/.editorconfig-lint.php index 9640a835..22d0bbf2 100644 --- a/.editorconfig-lint.php +++ b/.editorconfig-lint.php @@ -26,6 +26,7 @@ ->in(__DIR__) ->ignoreVCSIgnored(true) ->exclude([ + 'Resources/Public/Css', 'Resources/Public/JavaScript', ]) ; diff --git a/.gitattributes b/.gitattributes index b98c217b..9ee1b349 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ * text=auto +/.ddev export-ignore /.github export-ignore /Resources/Private/Frontend export-ignore /Resources/Private/Libs export-ignore @@ -15,7 +16,8 @@ /dependency-checker.json export-ignore /docker-compose.yml export-ignore /packaging_exclude.php export-ignore -/phpstan.neon export-ignore -/phpunit.ci.xml export-ignore -/phpunit.xml export-ignore +/phpstan.php export-ignore +/phpstan-baseline.neon export-ignore +/phpunit.functional.xml export-ignore +/phpunit.unit.xml export-ignore /rector.php export-ignore diff --git a/.github/workflows/cgl.yaml b/.github/workflows/cgl.yaml index bbb8170a..b2916348 100644 --- a/.github/workflows/cgl.yaml +++ b/.github/workflows/cgl.yaml @@ -40,11 +40,11 @@ jobs: # Linting - name: Lint composer.json - run: composer lint:composer -- --dry-run + run: composer lint:composer - name: Lint Editorconfig - run: .Build/bin/ec --finder-config .editorconfig-lint.php + run: composer lint:editorconfig - name: Lint PHP - run: composer lint:php -- --dry-run + run: composer lint:php # SCA - name: SCA PHP diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 315f7f78..20ce35b6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,16 +14,14 @@ jobs: strategy: fail-fast: false matrix: - typo3-version: ["10.4", "11.5"] - php-version: ["7.4", "8.0", "8.1", "8.2"] + php-version: ["8.1", "8.2"] + typo3-version: ["12.4"] dependencies: ["highest", "lowest"] - exclude: - - typo3-version: "10.4" - php-version: "8.0" - - typo3-version: "10.4" - php-version: "8.1" - - typo3-version: "10.4" - php-version: "8.2" + env: + typo3DatabaseName: typo3 + typo3DatabaseHost: '127.0.0.1' + typo3DatabaseUsername: root + typo3DatabasePassword: root steps: - uses: actions/checkout@v3 with: @@ -37,6 +35,10 @@ jobs: tools: composer:v2 coverage: none + # Start MySQL service + - name: Start MySQL + run: sudo /etc/init.d/mysql start + # Install dependencies - name: Install Composer dependencies uses: ramsey/composer-install@v2 @@ -51,6 +53,11 @@ jobs: coverage: name: Test coverage runs-on: ubuntu-latest + env: + typo3DatabaseName: typo3 + typo3DatabaseHost: '127.0.0.1' + typo3DatabaseUsername: root + typo3DatabasePassword: root steps: - uses: actions/checkout@v3 with: @@ -64,17 +71,21 @@ jobs: tools: composer:v2 coverage: pcov + # Start MySQL service + - name: Start MySQL + run: sudo /etc/init.d/mysql start + # Install dependencies - name: Install Composer dependencies uses: ramsey/composer-install@v2 # Run tests - name: Run tests - run: composer test:ci + run: composer test:coverage # Report coverage - name: Fix coverage path - working-directory: .Build/log/coverage + working-directory: .Build/coverage run: sed -i 's#/home/runner/work/typo3-warming/typo3-warming#${{ github.workspace }}#g' clover.xml - name: CodeClimate report uses: paambaati/codeclimate-action@v4.0.0 @@ -83,11 +94,11 @@ jobs: CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} with: coverageLocations: | - ${{ github.workspace }}/.Build/log/coverage/clover.xml:clover + ${{ github.workspace }}/.Build/coverage/clover.xml:clover - name: codecov report uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: .Build/log/coverage + directory: .Build/coverage fail_ci_if_error: true verbose: true diff --git a/.gitignore b/.gitignore index 46fab65c..40c92923 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -/.Build/ -/config/ +/.Build +/config +/Tests/Build/Configuration/* +!/Tests/Build/Configuration/system/ +/Tests/Build/Configuration/system/* +!/Tests/Build/Configuration/system/services.php /var/ /Resources/Private/Libs/Build/vendor /Resources/Private/Libs/vendors.phar diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ba2be45..995c3caa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,86 +1,5 @@ # Contributing -Thanks for considering contributing to this project! Each contribution is -highly appreciated. In order to maintain a high code quality, please follow -all steps below. +Please have a look in the [official documentation][1]. -## Preparation - -```bash -# Clone repository -git clone https://github.com/eliashaeussler/typo3-warming.git -cd typo3-warming - -# Install Composer dependencies -composer install - -# Install Node dependencies -yarn --cwd Resources/Private/Frontend -``` - -## Run linters - -### TYPO3 - -```bash -# All linters -composer lint - -# Specific linters -composer lint:composer -composer lint:editorconfig -composer lint:php -``` - -### Frontend - -```bash -# All linters -yarn --cwd Resources/Private/Frontend lint -yarn --cwd Resources/Private/Frontend lint:fix - -# Specific linters -yarn --cwd Resources/Private/Frontend lint:scss -yarn --cwd Resources/Private/Frontend lint:scss:fix -yarn --cwd Resources/Private/Frontend lint:ts -yarn --cwd Resources/Private/Frontend lint:ts:fix -``` - -## Run static code analysis - -```bash -# All static code analyzers -composer sca - -# Specific static code analyzers -composer sca:php -``` - -## Run tests - -```bash -# All tests -composer test - -# All tests with code coverage -composer test:ci -``` - -### Test reports - -Code coverage reports are written to `.Build/log/coverage`. You can open the -last HTML report like follows: - -```bash -open .Build/log/coverage/html/index.html -``` - -## Submit a pull request - -Once you have finished your work, please **submit a pull request** and describe -what you've done. Ideally, your PR references an issue describing the problem -you're trying to solve. - -All described code quality tools are automatically executed on each pull request -for all currently supported PHP versions and TYPO3 versions. Take a look at -the appropriate [workflows](.github/workflows) to get a detailed overview. +[1]: https://docs.typo3.org/p/eliashaeussler/typo3-warming/main/en-us/Contributing/Index.html diff --git a/Classes/Backend/ContextMenu/ItemProviders/CacheWarmupProvider.php b/Classes/Backend/ContextMenu/ItemProviders/CacheWarmupProvider.php index 7f862831..a31e4786 100644 --- a/Classes/Backend/ContextMenu/ItemProviders/CacheWarmupProvider.php +++ b/Classes/Backend/ContextMenu/ItemProviders/CacheWarmupProvider.php @@ -23,16 +23,11 @@ namespace EliasHaeussler\Typo3Warming\Backend\ContextMenu\ItemProviders; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use EliasHaeussler\Typo3Warming\Sitemap\SitemapLocator; -use EliasHaeussler\Typo3Warming\Traits\BackendUserAuthenticationTrait; -use EliasHaeussler\Typo3Warming\Utility\AccessUtility; -use TYPO3\CMS\Backend\ContextMenu\ItemProviders\PageProvider; -use TYPO3\CMS\Core\Exception\SiteNotFoundException; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; -use TYPO3\CMS\Core\Site\SiteFinder; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use TYPO3\CMS\Backend; +use TYPO3\CMS\Core; /** * CacheWarmupProvider @@ -40,12 +35,10 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class CacheWarmupProvider extends PageProvider +final class CacheWarmupProvider extends Backend\ContextMenu\ItemProviders\PageProvider { - use BackendUserAuthenticationTrait; - - protected const ITEM_MODE_PAGE = 'cacheWarmupPage'; - protected const ITEM_MODE_SITE = 'cacheWarmupSite'; + private const ITEM_MODE_PAGE = 'cacheWarmupPage'; + private const ITEM_MODE_SITE = 'cacheWarmupSite'; /** * @var arraysitemapLocator = GeneralUtility::makeInstance(SitemapLocator::class); - $this->siteFinder = GeneralUtility::makeInstance(SiteFinder::class); - $this->configuration = GeneralUtility::makeInstance(Configuration::class); + public function __construct( + private readonly Sitemap\SitemapLocator $sitemapLocator, + private readonly Core\Site\SiteFinder $siteFinder, + private readonly Configuration\Configuration $configuration, + ) { + parent::__construct(); } protected function canRender(string $itemName, string $type): bool @@ -111,7 +100,7 @@ protected function canRender(string $itemName, string $type): bool } // Language items in sub-menus are already filtered - if (str_starts_with($itemName, 'lang_')) { + if (str_contains($itemName, '_lang_')) { return true; } @@ -129,7 +118,7 @@ protected function canRender(string $itemName, string $type): bool return $this->canWarmupCachesOfSite(); } - return AccessUtility::canWarmupCacheOfPage((int)$this->identifier); + return Utility\AccessUtility::canWarmupCacheOfPage((int)$this->identifier); } /** @@ -175,20 +164,20 @@ private function initSubMenus(): void // Get all languages of current site that are available // for the current Backend user - $languages = $site->getAvailableLanguages(static::getBackendUser()); + $languages = $site->getAvailableLanguages($this->backendUser); // Remove sites where no XML sitemap is available if ($itemName === self::ITEM_MODE_SITE) { $languages = array_filter( $languages, - fn (SiteLanguage $siteLanguage): bool => $this->canWarmupCachesOfSite($siteLanguage) + fn (Core\Site\Entity\SiteLanguage $siteLanguage): bool => $this->canWarmupCachesOfSite($siteLanguage) ); } else { $languages = array_filter( $languages, - fn (SiteLanguage $siteLanguage): bool => AccessUtility::canWarmupCacheOfPage( + fn (Core\Site\Entity\SiteLanguage $siteLanguage): bool => Utility\AccessUtility::canWarmupCacheOfPage( (int)$this->identifier, - $siteLanguage->getLanguageId() + $siteLanguage->getLanguageId(), ) ); } @@ -205,7 +194,7 @@ private function initSubMenus(): void // Add each site language as child element of the current item foreach ($languages as $language) { - $configuration['childItems']['lang_' . $language->getLanguageId()] = [ + $configuration['childItems'][$itemName . '_lang_' . $language->getLanguageId()] = [ 'label' => $language->getTitle(), 'iconIdentifier' => $language->getFlagIdentifier(), 'callbackAction' => $configuration['callbackAction'] ?? null, @@ -223,34 +212,44 @@ private function initSubMenus(): void protected function getAdditionalAttributes(string $itemName): array { $attributes = [ - 'data-callback-module' => 'TYPO3/CMS/Warming/Backend/ContextMenu/CacheWarmupContextMenuAction', + 'data-callback-module' => '@eliashaeussler/typo3-warming/backend/context-menu-action', ]; - // Add language ID as data attribute if current item is part - // of a submenu within the configured context menu items - if (str_starts_with($itemName, 'lang_')) { - $attributes['data-language-id'] = (int)substr($itemName, 5); + // Early return if current item is not part of a submenu + // within the configured context menu items + if (!str_contains($itemName, '_lang_')) { + return $attributes; } + [$parentItem, $languageId] = explode('_lang_', $itemName); + + // Add site identifier as data attribute + if ($parentItem === self::ITEM_MODE_SITE) { + $attributes['data-site-identifier'] = $this->getCurrentSite()?->getIdentifier(); + } + + // Add language ID as data attribute + $attributes['data-language-id'] = (int)$languageId; + return $attributes; } - private function canWarmupCachesOfSite(SiteLanguage $siteLanguage = null): bool + private function canWarmupCachesOfSite(Core\Site\Entity\SiteLanguage $siteLanguage = null): bool { $site = $this->getCurrentSite(); - $languageId = $siteLanguage !== null ? $siteLanguage->getLanguageId() : null; + $languageId = $siteLanguage?->getLanguageId(); return $site !== null && $site->getRootPageId() === (int)$this->identifier - && AccessUtility::canWarmupCacheOfSite($site, $languageId) + && Utility\AccessUtility::canWarmupCacheOfSite($site, $languageId) && $this->sitemapLocator->siteContainsSitemap($site, $siteLanguage); } - private function getCurrentSite(): ?Site + private function getCurrentSite(): ?Core\Site\Entity\Site { try { return $this->siteFinder->getSiteByPageId((int)$this->identifier); - } catch (SiteNotFoundException $e) { + } catch (Core\Exception\SiteNotFoundException) { return null; } } diff --git a/Classes/Backend/ToolbarItems/CacheWarmupToolbarItem.php b/Classes/Backend/ToolbarItems/CacheWarmupToolbarItem.php index 617f90d6..e44a1875 100644 --- a/Classes/Backend/ToolbarItems/CacheWarmupToolbarItem.php +++ b/Classes/Backend/ToolbarItems/CacheWarmupToolbarItem.php @@ -23,13 +23,11 @@ namespace EliasHaeussler\Typo3Warming\Backend\ToolbarItems; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use EliasHaeussler\Typo3Warming\Traits\TranslatableTrait; -use EliasHaeussler\Typo3Warming\Traits\ViewTrait; -use EliasHaeussler\Typo3Warming\Utility\AccessUtility; -use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface; -use TYPO3\CMS\Core\Page\PageRenderer; -use TYPO3\CMS\Core\Site\SiteFinder; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Utility; +use EliasHaeussler\Typo3Warming\View; +use TYPO3\CMS\Backend; +use TYPO3\CMS\Core; /** * CacheWarmupToolbarItem @@ -37,50 +35,53 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class CacheWarmupToolbarItem implements ToolbarItemInterface +final class CacheWarmupToolbarItem implements Backend\Toolbar\ToolbarItemInterface { - use TranslatableTrait; - use ViewTrait; - - private Configuration $configuration; - private SiteFinder $siteFinder; - - public function __construct(Configuration $configuration, SiteFinder $siteFinder, PageRenderer $pageRenderer) - { - $this->configuration = $configuration; - $this->siteFinder = $siteFinder; - - $pageRenderer->loadRequireJsModule('TYPO3/CMS/Warming/Backend/Toolbar/CacheWarmupMenu'); + public function __construct( + private readonly Configuration\Configuration $configuration, + private readonly View\TemplateRenderer $renderer, + private readonly Core\Site\SiteFinder $siteFinder, + Core\Page\PageRenderer $pageRenderer, + ) { + $pageRenderer->loadJavaScriptModule('@eliashaeussler/typo3-warming/backend/toolbar-menu.js'); $pageRenderer->addInlineLanguageLabelArray([ - // Toolbar - 'cacheWarmup.toolbar.sitemap.missing' => static::translate('toolbar.sitemap.missing'), - 'cacheWarmup.toolbar.sitemap.placeholder' => static::translate('toolbar.sitemap.placeholder'), - 'cacheWarmup.toolbar.copy.successful' => static::translate('toolbar.copy.successful'), - // Notification - 'cacheWarmup.notification.aborted.title' => static::translate('notification.aborted.title'), - 'cacheWarmup.notification.aborted.message' => static::translate('notification.aborted.message'), - 'cacheWarmup.notification.error.title' => static::translate('notification.error.title'), - 'cacheWarmup.notification.error.message' => static::translate('notification.error.message'), - 'cacheWarmup.notification.action.showReport' => static::translate('notification.action.showReport'), - 'cacheWarmup.notification.action.retry' => static::translate('notification.action.retry'), - - // Report Modal - 'cacheWarmup.modal.report.title' => static::translate('modal.report.title'), - 'cacheWarmup.modal.report.panel.failed' => static::translate('modal.report.panel.failed'), - 'cacheWarmup.modal.report.panel.successful' => static::translate('modal.report.panel.successful'), - 'cacheWarmup.modal.report.action.view' => static::translate('modal.report.action.view'), - 'cacheWarmup.modal.report.message.total' => static::translate('modal.report.message.total'), - 'cacheWarmup.modal.report.message.noUrlsCrawled' => static::translate('modal.report.message.noUrlsCrawled'), + 'warming.notification.aborted.title' => Configuration\Localization::translate('notification.aborted.title'), + 'warming.notification.aborted.message' => Configuration\Localization::translate('notification.aborted.message'), + 'warming.notification.error.title' => Configuration\Localization::translate('notification.error.title'), + 'warming.notification.error.message' => Configuration\Localization::translate('notification.error.message'), + 'warming.notification.action.showReport' => Configuration\Localization::translate('notification.action.showReport'), + 'warming.notification.action.retry' => Configuration\Localization::translate('notification.action.retry'), + 'warming.notification.noSitesSelected.title' => Configuration\Localization::translate('notification.noSitesSelected.title'), + 'warming.notification.noSitesSelected.message' => Configuration\Localization::translate('notification.noSitesSelected.message'), // Progress Modal - 'cacheWarmup.modal.progress.title' => static::translate('modal.progress.title'), - 'cacheWarmup.modal.progress.button.report' => static::translate('modal.progress.button.report'), - 'cacheWarmup.modal.progress.button.retry' => static::translate('modal.progress.button.retry'), - 'cacheWarmup.modal.progress.button.close' => static::translate('modal.progress.button.close'), - 'cacheWarmup.modal.progress.failedCounter' => static::translate('modal.progress.failedCounter'), - 'cacheWarmup.modal.progress.allCounter' => static::translate('modal.progress.allCounter'), - 'cacheWarmup.modal.progress.placeholder' => static::translate('modal.progress.placeholder'), + 'warming.modal.progress.title' => Configuration\Localization::translate('modal.progress.title'), + 'warming.modal.progress.button.report' => Configuration\Localization::translate('modal.progress.button.report'), + 'warming.modal.progress.button.retry' => Configuration\Localization::translate('modal.progress.button.retry'), + 'warming.modal.progress.button.close' => Configuration\Localization::translate('modal.progress.button.close'), + 'warming.modal.progress.failedCounter' => Configuration\Localization::translate('modal.progress.failedCounter'), + 'warming.modal.progress.allCounter' => Configuration\Localization::translate('modal.progress.allCounter'), + 'warming.modal.progress.placeholder' => Configuration\Localization::translate('modal.progress.placeholder'), + + // Report Modal + 'warming.modal.report.title' => Configuration\Localization::translate('modal.report.title'), + 'warming.modal.report.panel.failed' => Configuration\Localization::translate('modal.report.panel.failed'), + 'warming.modal.report.panel.failed.summary' => Configuration\Localization::translate('modal.report.panel.failed.summary'), + 'warming.modal.report.panel.successful' => Configuration\Localization::translate('modal.report.panel.successful'), + 'warming.modal.report.panel.successful.summary' => Configuration\Localization::translate('modal.report.panel.successful.summary'), + 'warming.modal.report.panel.excluded' => Configuration\Localization::translate('modal.report.panel.excluded'), + 'warming.modal.report.panel.excluded.summary' => Configuration\Localization::translate('modal.report.panel.excluded.summary'), + 'warming.modal.report.panel.excluded.sitemaps' => Configuration\Localization::translate('modal.report.panel.excluded.sitemaps'), + 'warming.modal.report.panel.excluded.urls' => Configuration\Localization::translate('modal.report.panel.excluded.urls'), + 'warming.modal.report.action.view' => Configuration\Localization::translate('modal.report.action.view'), + 'warming.modal.report.message.total' => Configuration\Localization::translate('modal.report.message.total'), + 'warming.modal.report.message.noUrlsCrawled' => Configuration\Localization::translate('modal.report.message.noUrlsCrawled'), + + // Sites Modal + 'warming.modal.sites.title' => Configuration\Localization::translate('modal.sites.title'), + 'warming.modal.sites.userAgent.action.successful' => Configuration\Localization::translate('modal.sites.userAgent.action.successful'), + 'warming.modal.sites.button.start' => Configuration\Localization::translate('modal.sites.button.start'), ]); } @@ -92,7 +93,7 @@ public function checkAccess(): bool } foreach ($this->siteFinder->getAllSites() as $site) { - if (AccessUtility::canWarmupCacheOfSite($site)) { + if (Utility\AccessUtility::canWarmupCacheOfSite($site)) { return true; } } @@ -102,20 +103,17 @@ public function checkAccess(): bool public function getItem(): string { - return $this->buildView('CacheWarmupToolbarItem.html')->render(); + return $this->renderer->render('Toolbar/CacheWarmupToolbarItem'); } public function hasDropDown(): bool { - return true; + return false; } public function getDropDown(): string { - $view = $this->buildView('CacheWarmupToolbarItemDropDown.html'); - $view->assign('userAgent', $this->configuration->getUserAgent()); - - return $view->render(); + return ''; } /** @@ -123,7 +121,9 @@ public function getDropDown(): string */ public function getAdditionalAttributes(): array { - return []; + return [ + 'class' => 'tx-warming-toolbar-item', + ]; } public function getIndex(): int diff --git a/Classes/Cache/CacheManager.php b/Classes/Cache/SitemapsCache.php similarity index 51% rename from Classes/Cache/CacheManager.php rename to Classes/Cache/SitemapsCache.php index dcb7174e..9df33f00 100644 --- a/Classes/Cache/CacheManager.php +++ b/Classes/Cache/SitemapsCache.php @@ -23,66 +23,82 @@ namespace EliasHaeussler\Typo3Warming\Cache; -use EliasHaeussler\Typo3Warming\Sitemap\SiteAwareSitemap; -use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\CacheWarmup\Exception; +use EliasHaeussler\Typo3Warming\Sitemap; +use TYPO3\CMS\Core; /** - * CacheManager + * SitemapsCache * * @author Elias Häußler * @license GPL-2.0-or-later */ -final class CacheManager +final class SitemapsCache { - public const CACHE_IDENTIFIER = 'tx_warming'; + private const ENTRY_IDENTIFIER = 'sitemaps'; - private PhpFrontend $cache; - - public function __construct(PhpFrontend $cache) - { - $this->cache = $cache; + public function __construct( + private readonly Core\Cache\Frontend\PhpFrontend $cache, + ) { } /** - * @return ($site is null ? array> : string|null) + * @throws Exception\InvalidUrlException */ - public function get(Site $site = null, SiteLanguage $siteLanguage = null) - { - $cacheData = $this->cache->require(self::CACHE_IDENTIFIER); - - // Enforce array for cached data - if (!\is_array($cacheData)) { - $cacheData = []; - } - - // Return complete cache if no specific site is requested - if ($site === null) { - return $cacheData['sitemaps'] ?? []; + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): Sitemap\SiteAwareSitemap|null { + /** @var array>|false $cacheData */ + $cacheData = $this->cache->require(self::ENTRY_IDENTIFIER); + + // Early return if cache is empty + if ($cacheData === false) { + return null; } + // Fetch sitemap from cache data $siteIdentifier = $site->getIdentifier(); $languageIdentifier = $this->buildLanguageIdentifier($site, $siteLanguage); + $sitemap = $cacheData[$siteIdentifier][$languageIdentifier] ?? null; - return $cacheData['sitemaps'][$siteIdentifier][$languageIdentifier] ?? null; + // Early return if sitemap is not cached + if (!\is_string($sitemap)) { + return null; + } + + return new Sitemap\SiteAwareSitemap( + new Core\Http\Uri($sitemap), + $site, + $siteLanguage ?? $site->getDefaultLanguage(), + ); } - public function set(SiteAwareSitemap $sitemap): void + public function set(Sitemap\SiteAwareSitemap $sitemap): void { - $cacheData = $this->get(); + /** @var array>|false $cacheData */ + $cacheData = $this->cache->require(self::ENTRY_IDENTIFIER); + + // Enforce array for cached data + if ($cacheData === false) { + $cacheData = []; + } + + // Append sitemap url to cache data $siteIdentifier = $sitemap->getSite()->getIdentifier(); $languageIdentifier = $this->buildLanguageIdentifier($sitemap->getSite(), $sitemap->getSiteLanguage()); $cacheData[$siteIdentifier][$languageIdentifier] = (string)$sitemap->getUri(); $this->cache->set( - self::CACHE_IDENTIFIER, - sprintf('return %s;', var_export(['sitemaps' => $cacheData], true)) + self::ENTRY_IDENTIFIER, + sprintf('return %s;', var_export($cacheData, true)), ); } - private function buildLanguageIdentifier(Site $site, SiteLanguage $siteLanguage = null): string - { + private function buildLanguageIdentifier( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): string { $languageIdentifier = 'default'; if ($siteLanguage !== null && $siteLanguage !== $site->getDefaultLanguage()) { $languageIdentifier = (string)$siteLanguage->getLanguageId(); diff --git a/Classes/Command/ShowUserAgentCommand.php b/Classes/Command/ShowUserAgentCommand.php index 23eb44a4..ef4e64f7 100644 --- a/Classes/Command/ShowUserAgentCommand.php +++ b/Classes/Command/ShowUserAgentCommand.php @@ -23,10 +23,8 @@ namespace EliasHaeussler\Typo3Warming\Command; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use EliasHaeussler\Typo3Warming\Configuration; +use Symfony\Component\Console; /** * ShowUserAgentCommand @@ -34,15 +32,12 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class ShowUserAgentCommand extends Command +final class ShowUserAgentCommand extends Console\Command\Command { - private Configuration $configuration; - - public function __construct(Configuration $configuration, string $name = null) - { - $this->configuration = $configuration; - - parent::__construct($name); + public function __construct( + private readonly Configuration\Configuration $configuration, + ) { + parent::__construct(); } protected function configure(): void @@ -50,10 +45,10 @@ protected function configure(): void $this->setDescription('Show custom "User-Agent" header to be used for Frontend requests by default crawlers.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int { $output->write($this->configuration->getUserAgent()); - return 0; + return self::SUCCESS; } } diff --git a/Classes/Command/WarmupCommand.php b/Classes/Command/WarmupCommand.php index 2bed7be3..91ce83e8 100644 --- a/Classes/Command/WarmupCommand.php +++ b/Classes/Command/WarmupCommand.php @@ -23,23 +23,16 @@ namespace EliasHaeussler\Typo3Warming\Command; -use EliasHaeussler\CacheWarmup\Command\CacheWarmupCommand; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedConfigurationException; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedSiteException; -use EliasHaeussler\Typo3Warming\Service\CacheWarmupService; -use EliasHaeussler\Typo3Warming\Sitemap\SitemapLocator; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -use TYPO3\CMS\Core\Exception\SiteNotFoundException; -use TYPO3\CMS\Core\Site\SiteFinder; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Core\Utility\MathUtility; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Crawler; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Http; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use JsonException; +use Symfony\Component\Console; +use TYPO3\CMS\Core; /** * WarmupCommand @@ -47,29 +40,18 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class WarmupCommand extends Command +final class WarmupCommand extends Console\Command\Command { private const ALL_LANGUAGES = -1; - private CacheWarmupService $warmupService; - private Configuration $configuration; - private SitemapLocator $sitemapLocator; - private SiteFinder $siteFinder; - private SymfonyStyle $io; - public function __construct( - CacheWarmupService $warmupService, - Configuration $configuration, - SitemapLocator $sitemapLocator, - SiteFinder $siteFinder, - string $name = null + private readonly Http\Client\ClientFactory $clientFactory, + private readonly Configuration\Configuration $configuration, + private readonly Crawler\Strategy\CrawlingStrategyFactory $crawlingStrategyFactory, + private readonly Sitemap\SitemapLocator $sitemapLocator, + private readonly Core\Site\SiteFinder $siteFinder, ) { - $this->warmupService = $warmupService; - $this->configuration = $configuration; - $this->sitemapLocator = $sitemapLocator; - $this->siteFinder = $siteFinder; - - parent::__construct($name); + parent::__construct(); } protected function configure(): void @@ -116,7 +98,7 @@ protected function configure(): void ' │ if individual caches warm up incorrectly.', ' │ This is especially useful for automated execution of cache warmups.', ' ├─ Default: false', - ' └─ Example: warming:cachewarmup -s 1 -x', + ' └─ Example: warming:cachewarmup -s 1 --strict', '', '* Crawl limit', ' ├─ The maximum number of pages to be warmed up can be defined via the extension configuration limit.', @@ -126,6 +108,24 @@ protected function configure(): void ' ├─ Example: warming:cachewarmup -s 1 --limit 100 (limits crawling to 100 pages)', ' └─ Example: warming:cachewarmup -s 1 --limit 0 (no limit)', '', + '* Crawling strategy', + ' ├─ A crawling strategy defines how URLs will be crawled, e.g. by sorting them by a specific property.', + ' │ It can be defined via the extension configuration strategy or by using the --strategy option.', + ' │ The following strategies are currently available:', + ...array_map( + static fn (string $strategy) => ' │ * ' . $strategy . '', + array_keys($this->crawlingStrategyFactory->getAll()), + ), + ' ├─ Default: ' . ($this->configuration->getStrategy() ?? 'none') . '', + ' └─ Example: warming:cachewarmup --strategy ' . CacheWarmup\Crawler\Strategy\SortByPriorityStrategy::getName() . '', + '', + '* Format output', + ' ├─ By default, all user-oriented output is printed as plain text to the console.', + ' │ However, you can use other formatters by using the --format (or -f) option.', + ' ├─ Default: ' . CacheWarmup\Formatter\TextFormatter::getType() . '', + ' ├─ Example: warming:cachewarmup --format ' . CacheWarmup\Formatter\TextFormatter::getType() . ' (normal output as plaintext)', + ' └─ Example: warming:cachewarmup --format ' . CacheWarmup\Formatter\JsonFormatter::getType() . ' (displays output as JSON)', + '', 'Crawling configuration', '======================', '', @@ -146,51 +146,71 @@ protected function configure(): void $this->addOption( 'pages', 'p', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Pages whose Frontend caches are to be warmed up.' + Console\Input\InputOption::VALUE_REQUIRED | Console\Input\InputOption::VALUE_IS_ARRAY, + 'Pages whose Frontend caches are to be warmed up.', ); $this->addOption( 'sites', 's', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Site identifiers or root page IDs of sites whose caches are to be warmed up.' + Console\Input\InputOption::VALUE_REQUIRED | Console\Input\InputOption::VALUE_IS_ARRAY, + 'Site identifiers or root page IDs of sites whose caches are to be warmed up.', ); $this->addOption( 'languages', 'l', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Optional identifiers of languages for which caches are to be warmed up.' + Console\Input\InputOption::VALUE_REQUIRED | Console\Input\InputOption::VALUE_IS_ARRAY, + 'Optional identifiers of languages for which caches are to be warmed up.', ); $this->addOption( 'limit', null, - InputOption::VALUE_REQUIRED, + Console\Input\InputOption::VALUE_REQUIRED, 'Maximum number of pages to be crawled. Set to 0 to disable the limit.', - $this->configuration->getLimit() + $this->configuration->getLimit(), + ); + $this->addOption( + 'strategy', + null, + Console\Input\InputOption::VALUE_REQUIRED, + 'Optional strategy to prepare URLs before crawling them.', + $this->configuration->getStrategy(), + ); + $this->addOption( + 'format', + 'f', + Console\Input\InputOption::VALUE_REQUIRED, + 'Formatter used to print the cache warmup result', + CacheWarmup\Formatter\TextFormatter::getType(), ); $this->addOption( 'strict', 'x', - InputOption::VALUE_NONE, - 'Fail if an error occurred during cache warmup.' + Console\Input\InputOption::VALUE_NONE, + 'Fail if an error occurred during cache warmup.', ); } - protected function initialize(InputInterface $input, OutputInterface $output): void - { - $this->io = new SymfonyStyle($input, $output); - } - - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Console\Exception\ExceptionInterface + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + * @throws JsonException + */ + protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int { - // Initialize sub-command - $subCommand = new CacheWarmupCommand(); - $subCommand->setApplication($this->getApplication() ?? new Application()); - $subCommandInput = $this->initializeSubCommandInput($subCommand, $input); + // Initialize sub command + $subCommand = new CacheWarmup\Command\CacheWarmupCommand($this->clientFactory->get()); + $subCommand->setApplication($this->getApplication() ?? new Console\Application()); + + // Initialize sub command input + $subCommandInput = new Console\Input\ArrayInput( + $this->prepareCommandParameters($input), + $subCommand->getDefinition(), + ); $subCommandInput->setInteractive(false); - $output->writeln('Running cache warmup by Elias Häußler and contributors.'); - // Run cache warmup in sub command from eliashaeussler/cache-warmup $statusCode = $subCommand->run($subCommandInput, $output); @@ -199,16 +219,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $statusCode; } - return 0; + return self::SUCCESS; } - private function initializeSubCommandInput(CacheWarmupCommand $subCommand, InputInterface $input): ArrayInput + /** + * @return array + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + * @throws JsonException + */ + private function prepareCommandParameters(Console\Input\InputInterface $input): array { // Resolve input options $languages = $this->resolveLanguages($input->getOption('languages')); - $urls = array_unique($this->resolveUrls($input->getOption('pages'), $languages)); - $sitemaps = array_unique($this->resolveSitemaps($input->getOption('sites'), $languages)); + $urls = array_unique($this->resolvePages($input->getOption('pages'), $languages)); + $sitemaps = array_unique($this->resolveSites($input->getOption('sites'), $languages)); $limit = max(0, (int)$input->getOption('limit')); + $strategy = $input->getOption('strategy'); + $format = $input->getOption('format'); + $excludePatterns = $this->configuration->getExcludePatterns(); // Fetch crawler and crawler options $crawler = $this->configuration->getVerboseCrawler(); @@ -220,45 +251,54 @@ private function initializeSubCommandInput(CacheWarmupCommand $subCommand, Input '--urls' => $urls, '--limit' => $limit, '--crawler' => $crawler, + '--format' => $format, ]; - // Early return if no crawler options are given - if ($crawlerOptions === []) { - return new ArrayInput($subCommandParameters, $subCommand->getDefinition()); - } - // Add crawler options to sub-command parameters - if ($subCommand->getDefinition()->hasOption('crawler-options')) { + if ($crawlerOptions !== []) { $subCommandParameters['--crawler-options'] = json_encode($crawlerOptions, JSON_THROW_ON_ERROR); - } else { - $this->io->writeln([ - 'This version of eliashaeussler/cache-warmup does not yet support crawler options.', - 'Please upgrade to version 0.7.13 or any later version.', - ]); } - return new ArrayInput($subCommandParameters, $subCommand->getDefinition()); + // Add exclude patterns + if ($excludePatterns !== []) { + $subCommandParameters['--exclude'] = $excludePatterns; + } + + // Add crawling strategy + if ($strategy !== null) { + $subCommandParameters['--strategy'] = $strategy; + } + + return $subCommandParameters; } /** - * @param list $pages + * @param array $pages * @param list $languages * @return list - * @throws SiteNotFoundException + * @throws Core\Exception\SiteNotFoundException */ - private function resolveUrls(array $pages, array $languages): array + private function resolvePages(array $pages, array $languages): array { $resolvedUrls = []; foreach ($pages as $pageList) { - foreach (GeneralUtility::intExplode(',', (string)$pageList, true) as $page) { + $normalizedPages = Core\Utility\GeneralUtility::intExplode(',', $pageList, true); + + foreach ($normalizedPages as $page) { $languageIds = $languages; + if ($languageIds === [self::ALL_LANGUAGES]) { $site = $this->siteFinder->getSiteByPageId($page); $languageIds = array_keys($site->getLanguages()); } + foreach ($languageIds as $languageId) { - $resolvedUrls[] = (string)$this->warmupService->generateUri($page, $languageId); + $uri = Utility\HttpUtility::generateUri($page, $languageId); + + if ($uri !== null) { + $resolvedUrls[] = (string)$uri; + } } } } @@ -267,28 +307,32 @@ private function resolveUrls(array $pages, array $languages): array } /** - * @param list $sites + * @param array $sites * @param list $languages * @return list - * @throws SiteNotFoundException - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException */ - private function resolveSitemaps(array $sites, array $languages): array + private function resolveSites(array $sites, array $languages): array { $resolvedSitemaps = []; foreach ($sites as $siteList) { - foreach (GeneralUtility::trimExplode(',', (string)$siteList, true) as $site) { - if (MathUtility::canBeInterpretedAsInteger($site)) { + foreach (Core\Utility\GeneralUtility::trimExplode(',', $siteList, true) as $site) { + if (Core\Utility\MathUtility::canBeInterpretedAsInteger($site)) { $site = $this->siteFinder->getSiteByRootPageId((int)$site); } else { $site = $this->siteFinder->getSiteByIdentifier($site); } + $languageIds = $languages; + if ([self::ALL_LANGUAGES] === $languageIds) { $languageIds = array_keys($site->getLanguages()); } + foreach ($languageIds as $languageId) { $resolvedSitemaps[] = (string)$this->sitemapLocator->locateBySite( $site, @@ -302,7 +346,7 @@ private function resolveSitemaps(array $sites, array $languages): array } /** - * @param list $languages + * @param array $languages * @return list */ private function resolveLanguages(array $languages): array @@ -315,7 +359,9 @@ private function resolveLanguages(array $languages): array } foreach ($languages as $languageList) { - foreach (GeneralUtility::intExplode(',', (string)$languageList, true) as $languageId) { + $normalizedLanguages = Core\Utility\GeneralUtility::intExplode(',', $languageList, true); + + foreach ($normalizedLanguages as $languageId) { $resolvedLanguages[] = $languageId; } } diff --git a/Classes/Configuration/Configuration.php b/Classes/Configuration/Configuration.php index 85b6046e..577d49e7 100644 --- a/Classes/Configuration/Configuration.php +++ b/Classes/Configuration/Configuration.php @@ -23,16 +23,11 @@ namespace EliasHaeussler\Typo3Warming\Configuration; -use EliasHaeussler\CacheWarmup\Crawler\CrawlerInterface; -use EliasHaeussler\CacheWarmup\Crawler\VerboseCrawlerInterface; -use EliasHaeussler\Typo3Warming\Crawler\ConcurrentUserAgentCrawler; -use EliasHaeussler\Typo3Warming\Crawler\OutputtingUserAgentCrawler; -use JsonException; -use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Domain\Repository\PageRepository; -use TYPO3\CMS\Core\Exception; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Security\Cryptography\HashService; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Crawler; +use EliasHaeussler\Typo3Warming\Extension; +use TYPO3\CMS\Core; +use TYPO3\CMS\Extbase; /** * Configuration @@ -42,58 +37,43 @@ */ final class Configuration { - public const DEFAULT_LIMIT = 250; - public const DEFAULT_CRAWLER = ConcurrentUserAgentCrawler::class; - public const DEFAULT_VERBOSE_CRAWLER = OutputtingUserAgentCrawler::class; - public const DEFAULT_SUPPORTED_DOKTYPES = [ - PageRepository::DOKTYPE_DEFAULT, + private const DEFAULT_CRAWLER = Crawler\ConcurrentUserAgentCrawler::class; + private const DEFAULT_VERBOSE_CRAWLER = Crawler\OutputtingUserAgentCrawler::class; + private const DEFAULT_LIMIT = 250; + private const DEFAULT_SUPPORTED_DOKTYPES = [ + Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT, ]; - private ExtensionConfiguration $configuration; - private HashService $hashService; - private string $userAgent; + private readonly string $userAgent; - public function __construct(ExtensionConfiguration $configuration, HashService $hashService) - { - $this->configuration = $configuration; - $this->hashService = $hashService; + public function __construct( + private readonly Core\Configuration\ExtensionConfiguration $configuration, + private readonly CacheWarmup\Crawler\CrawlerFactory $crawlerFactory, + private readonly Crawler\Strategy\CrawlingStrategyFactory $crawlingStrategyFactory, + private readonly Extbase\Security\Cryptography\HashService $hashService, + ) { $this->userAgent = $this->generateUserAgent(); } - public function getLimit(): int - { - try { - $limit = $this->configuration->get(Extension::KEY, 'limit'); - - if (!is_numeric($limit)) { - return self::DEFAULT_LIMIT; - } - - return abs((int)$limit); - } catch (Exception $e) { - return self::DEFAULT_LIMIT; - } - } - /** - * @return class-string + * @return class-string */ public function getCrawler(): string { try { - /** @var class-string|null $crawler */ + /** @var class-string|null $crawler */ $crawler = $this->configuration->get(Extension::KEY, 'crawler'); if (!\is_string($crawler)) { return self::DEFAULT_CRAWLER; } - if (!\in_array(CrawlerInterface::class, class_implements($crawler) ?: [])) { + if (!is_a($crawler, CacheWarmup\Crawler\CrawlerInterface::class, true)) { return self::DEFAULT_CRAWLER; } return $crawler; - } catch (Exception $e) { + } catch (Core\Exception) { return self::DEFAULT_CRAWLER; } } @@ -106,31 +86,36 @@ public function getCrawlerOptions(): array try { $json = $this->configuration->get(Extension::KEY, 'crawlerOptions'); - return $this->parseCrawlerOptions($json); - } catch (Exception $e) { + // Early return if no crawler options are configured + if (!\is_string($json) || $json === '') { + return []; + } + + return $this->crawlerFactory->parseCrawlerOptions($json); + } catch (Core\Exception) { return []; } } /** - * @return class-string + * @return class-string */ public function getVerboseCrawler(): string { try { - /** @var class-string|null $crawler */ + /** @var class-string|null $crawler */ $crawler = $this->configuration->get(Extension::KEY, 'verboseCrawler'); if (!\is_string($crawler)) { return self::DEFAULT_VERBOSE_CRAWLER; } - if (!\in_array(VerboseCrawlerInterface::class, class_implements($crawler) ?: [])) { + if (!is_a($crawler, CacheWarmup\Crawler\VerboseCrawlerInterface::class, true)) { return self::DEFAULT_VERBOSE_CRAWLER; } return $crawler; - } catch (Exception $e) { + } catch (Core\Exception) { return self::DEFAULT_VERBOSE_CRAWLER; } } @@ -143,93 +128,115 @@ public function getVerboseCrawlerOptions(): array try { $json = $this->configuration->get(Extension::KEY, 'verboseCrawlerOptions'); - return $this->parseCrawlerOptions($json); - } catch (Exception $e) { + // Early return if no crawler options are configured + if (!\is_string($json) || $json === '') { + return []; + } + + return $this->crawlerFactory->parseCrawlerOptions($json); + } catch (Core\Exception) { return []; } } - public function isEnabledInPageTree(): bool + public function getLimit(): int { try { - $enablePageTree = $this->configuration->get(Extension::KEY, 'enablePageTree'); + $limit = $this->configuration->get(Extension::KEY, 'limit'); - return (bool)$enablePageTree; - } catch (Exception $e) { - return true; + if (!is_numeric($limit)) { + return self::DEFAULT_LIMIT; + } + + return abs((int)$limit); + } catch (Core\Exception) { + return self::DEFAULT_LIMIT; } } - public function isEnabledInToolbar(): bool + /** + * @return list + */ + public function getExcludePatterns(): array { try { - $enableToolbar = $this->configuration->get(Extension::KEY, 'enableToolbar'); + $exclude = $this->configuration->get(Extension::KEY, 'exclude'); - return (bool)$enableToolbar; - } catch (Exception $e) { - return true; + // Early return if no exclude patterns are configured + if (!\is_string($exclude) || $exclude === '') { + return []; + } + + return Core\Utility\GeneralUtility::trimExplode(',', $exclude, true); + } catch (Core\Exception) { + return []; } } - /** - * @return list - */ - public function getSupportedDoktypes(): array + public function getStrategy(): ?string { try { - $doktypes = $this->configuration->get(Extension::KEY, 'supportedDoktypes'); + $strategy = $this->configuration->get(Extension::KEY, 'strategy'); - if (!\is_string($doktypes)) { - return self::DEFAULT_SUPPORTED_DOKTYPES; + // Early return if no crawling strategy is configured + if (!\is_string($strategy) || $strategy === '') { + return null; } - return GeneralUtility::intExplode(',', $doktypes, true); - } catch (Exception $e) { - return self::DEFAULT_SUPPORTED_DOKTYPES; + // Early return if configured crawling strategy is invalid + if (!$this->crawlingStrategyFactory->has($strategy)) { + return null; + } + + return $strategy; + } catch (Core\Exception) { + return null; } } - public function getUserAgent(): string + public function isEnabledInPageTree(): bool { - return $this->userAgent; + try { + $enablePageTree = $this->configuration->get(Extension::KEY, 'enablePageTree'); + + return (bool)$enablePageTree; + } catch (Core\Exception) { + return true; + } } /** - * @return array + * @return list */ - public function getAll(): array + public function getSupportedDoktypes(): array { try { - $configuration = $this->configuration->get(Extension::KEY); - \assert(\is_array($configuration)); + $doktypes = $this->configuration->get(Extension::KEY, 'supportedDoktypes'); - return $configuration; - } catch (Exception $e) { - return []; + if (!\is_string($doktypes)) { + return self::DEFAULT_SUPPORTED_DOKTYPES; + } + + return array_values(Core\Utility\GeneralUtility::intExplode(',', $doktypes, true)); + } catch (Core\Exception) { + return self::DEFAULT_SUPPORTED_DOKTYPES; } } - /** - * @param mixed $json - * @return array - */ - private function parseCrawlerOptions($json): array + public function isEnabledInToolbar(): bool { - if (!\is_string($json)) { - return []; - } - try { - $crawlerOptions = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - return []; - } + $enableToolbar = $this->configuration->get(Extension::KEY, 'enableToolbar'); - if (!\is_array($crawlerOptions)) { - return []; + return (bool)$enableToolbar; + } catch (Core\Exception) { + return true; } + } - return $crawlerOptions; + public function getUserAgent(): string + { + return $this->userAgent; } private function generateUserAgent(): string diff --git a/Classes/Configuration/Extension.php b/Classes/Configuration/Extension.php deleted file mode 100644 index ab137b38..00000000 --- a/Classes/Configuration/Extension.php +++ /dev/null @@ -1,118 +0,0 @@ - - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -namespace EliasHaeussler\Typo3Warming\Configuration; - -use EliasHaeussler\Typo3Warming\Backend\ContextMenu\ItemProviders\CacheWarmupProvider; -use EliasHaeussler\Typo3Warming\Backend\ToolbarItems\CacheWarmupToolbarItem; -use TYPO3\CMS\Core\Core\Environment; -use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider; -use TYPO3\CMS\Core\Imaging\IconRegistry; -use TYPO3\CMS\Core\Utility\GeneralUtility; - -/** - * Extension - * - * @author Elias Häußler - * @license GPL-2.0-or-later - * @codeCoverageIgnore - */ -final class Extension -{ - public const KEY = 'warming'; - public const NAME = 'Warming'; - - /** - * Register context menu item provider. - * - * FOR USE IN ext_localconf.php ONLY. - */ - public static function registerContextMenuProvider(): void - { - $GLOBALS['TYPO3_CONF_VARS']['BE']['ContextMenu']['ItemProviders'][1619185993] = CacheWarmupProvider::class; - } - - /** - * Register custom icons. - * - * FOR USE IN ext_localconf.php ONLY. - */ - public static function registerIcons(): void - { - $iconRegistry = GeneralUtility::makeInstance(IconRegistry::class); - $iconRegistry->registerIcon( - 'cache-warmup-page', - SvgIconProvider::class, - ['source' => 'EXT:warming/Resources/Public/Icons/cache-warmup-page.svg'] - ); - $iconRegistry->registerIcon( - 'cache-warmup-site', - SvgIconProvider::class, - ['source' => 'EXT:warming/Resources/Public/Icons/cache-warmup-site.svg'] - ); - } - - /** - * Register cache warmup toolbar item. - * - * FOR USE IN ext_localconf.php ONLY. - */ - public static function registerToolbarItem(): void - { - $GLOBALS['TYPO3_CONF_VARS']['BE']['toolbarItems'][1619165047] = CacheWarmupToolbarItem::class; - } - - /** - * Register custom styles for Backend. - * - * FOR USE IN ext_tables.php ONLY. - */ - public static function registerCustomStyles(): void - { - $GLOBALS['TBE_STYLES']['skins'][self::KEY] = [ - 'name' => self::KEY, - 'stylesheetDirectories' => [ - 'css' => 'EXT:warming/Resources/Public/Css/Backend', - ], - ]; - } - - /** - * Load additional libraries provided by PHAR file (only to be used in non-Composer-mode). - * - * FOR USE IN ext_localconf.php AND NON-COMPOSER-MODE ONLY. - */ - public static function loadVendorLibraries(): void - { - // Vendor libraries are already available in Composer mode - if (Environment::isComposerMode()) { - return; - } - - $vendorPharFile = GeneralUtility::getFileAbsFileName('EXT:warming/Resources/Private/Libs/vendors.phar'); - - if (file_exists($vendorPharFile)) { - require 'phar://' . $vendorPharFile . '/vendor/autoload.php'; - } - } -} diff --git a/Classes/Configuration/Localization.php b/Classes/Configuration/Localization.php new file mode 100644 index 00000000..d1a94a99 --- /dev/null +++ b/Classes/Configuration/Localization.php @@ -0,0 +1,50 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Configuration; + +use EliasHaeussler\Typo3Warming\Utility; + +/** + * Localization + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class Localization +{ + /** + * @param array $arguments + */ + public static function translate(string $key, array $arguments = [], string $type = null): ?string + { + $localizationKey = sprintf( + 'LLL:EXT:warming/Resources/Private/Language/locallang%s.xls:%s', + $type !== null ? ('_' . $type) : '', + $key, + ); + $translation = Utility\BackendUtility::getLanguageService()->sL($localizationKey); + + return vsprintf($translation, $arguments); + } +} diff --git a/Classes/Controller/CacheWarmupController.php b/Classes/Controller/CacheWarmupController.php index cf808311..b9529768 100644 --- a/Classes/Controller/CacheWarmupController.php +++ b/Classes/Controller/CacheWarmupController.php @@ -23,34 +23,19 @@ namespace EliasHaeussler\Typo3Warming\Controller; -use EliasHaeussler\CacheWarmup\Crawler\CrawlerInterface; -use EliasHaeussler\CacheWarmup\CrawlingState; -use EliasHaeussler\Typo3Warming\Exception\MissingPageIdException; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedConfigurationException; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedSiteException; -use EliasHaeussler\Typo3Warming\Request\WarmupRequest; -use EliasHaeussler\Typo3Warming\Service\CacheWarmupService; -use EliasHaeussler\Typo3Warming\Sitemap\SitemapLocator; -use EliasHaeussler\Typo3Warming\Traits\BackendUserAuthenticationTrait; -use EliasHaeussler\Typo3Warming\Traits\TranslatableTrait; -use EliasHaeussler\Typo3Warming\Traits\ViewTrait; -use EliasHaeussler\Typo3Warming\Utility\AccessUtility; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Symfony\Component\Serializer\Exception\ExceptionInterface; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use TYPO3\CMS\Backend\Utility\BackendUtility; -use TYPO3\CMS\Core\Exception\SiteNotFoundException; -use TYPO3\CMS\Core\Http\HtmlResponse; -use TYPO3\CMS\Core\Http\JsonResponse; -use TYPO3\CMS\Core\Http\RedirectResponse; -use TYPO3\CMS\Core\Http\Response; -use TYPO3\CMS\Core\Imaging\IconFactory; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; -use TYPO3\CMS\Core\Site\SiteFinder; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use CuyZ\Valinor; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\SSE; +use EliasHaeussler\Typo3Warming\Crawler; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Http; +use EliasHaeussler\Typo3Warming\Service; +use EliasHaeussler\Typo3Warming\ValueObject; +use GuzzleHttp\Exception\GuzzleException; +use JsonException; +use Psr\Http\Message; +use Psr\Log; +use TYPO3\CMS\Core; /** * CacheWarmupController @@ -60,369 +45,72 @@ */ final class CacheWarmupController { - use BackendUserAuthenticationTrait; - use TranslatableTrait; - use ViewTrait; - - public const MODE_SITE = 'site'; - public const MODE_PAGE = 'page'; - - public const STATE_FAILED = 'failed'; - public const STATE_WARNING = 'warning'; - public const STATE_SUCCESS = 'success'; - public const STATE_UNKNOWN = 'unknown'; - - private SiteFinder $siteFinder; - private CacheWarmupService $warmupService; - private IconFactory $iconFactory; - private SitemapLocator $sitemapLocator; - private DenormalizerInterface $denormalizer; - public function __construct( - SiteFinder $siteFinder, - CacheWarmupService $warmupService, - IconFactory $iconFactory, - SitemapLocator $sitemapLocator + private readonly Log\LoggerInterface $logger, + private readonly Valinor\Mapper\TreeMapper $mapper, + private readonly Http\Message\ResponseFactory $responseFactory, + private readonly Service\CacheWarmupService $warmupService, ) { - $this->siteFinder = $siteFinder; - $this->warmupService = $warmupService; - $this->iconFactory = $iconFactory; - $this->sitemapLocator = $sitemapLocator; - $this->denormalizer = new ObjectNormalizer(); } /** - * @throws MissingPageIdException - * @throws SiteNotFoundException - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException + * @throws CacheWarmup\Exception\Exception + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + * @throws GuzzleException + * @throws JsonException + * @throws SSE\Exception\StreamIsActive + * @throws SSE\Exception\StreamIsClosed + * @throws SSE\Exception\StreamIsInactive */ - public function mainAction(ServerRequestInterface $request): ResponseInterface + public function __invoke(Message\ServerRequestInterface $request): Message\ResponseInterface { // Ensure request was sent using the EventSource API performing an SSE request - if (['text/event-stream'] !== $request->getHeader('Accept')) { - return $this->buildBadRequestResponse(); + if (!SSE\Stream\SelfEmittingEventStream::canHandle($request)) { + return $this->responseFactory->badRequest('Invalid request headers'); } // Build warmup request object try { - $warmupRequest = $this->createWarmupRequest($request->getQueryParams()); - $warmupRequest->setUpdateCallback([$this, 'sendWarmupProgressEvent']); - $warmupRequest->setSite($this->determineSite($warmupRequest->getPageId())); - } catch (ExceptionInterface $e) { - return $this->buildBadRequestResponse(); - } - - // Send headers for SSE - $headers = [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - ]; - foreach ($headers as $key => $value) { - header(sprintf('%s: %s', $key, $value)); - } + $warmupRequest = $this->mapper->map(ValueObject\Request\WarmupRequest::class, $request->getQueryParams()); + } catch (Valinor\Mapper\MappingError $error) { + $errors = Valinor\Mapper\Tree\Message\Messages::flattenFromNode($error->node()); + $messages = array_map('strval', $errors->toArray()); - // Perform cache warmup - $crawler = $this->performCacheWarmup($warmupRequest); - - // Send final response - $this->sendServerEvent( - $warmupRequest->getId(), - 'warmupFinished', - $this->buildJsonResponseData($warmupRequest, $crawler) - ); - - return new Response(); - } - - public function sendWarmupProgressEvent(WarmupRequest $request): void - { - $successfulUrls = iterator_to_array($request->getSuccessfulCrawls()); - $failedUrls = iterator_to_array($request->getFailedCrawls()); + $this->logger->error( + 'Error during mapping of query parameters to warmup request object.', + ['errors' => $messages], + ); - $eventData = [ - 'progress' => [ - 'current' => \count($successfulUrls) + \count($failedUrls), - 'total' => $request->getTotal(), - ], - 'urls' => [ - 'successful' => array_map([$this, 'decorateCrawlingState'], $successfulUrls), - 'failed' => array_map([$this, 'decorateCrawlingState'], $failedUrls), - ], - ]; - $this->sendServerEvent($request->getId(), 'warmupProgress', $eventData, 50); - } - - /** - * @param array $eventData - */ - private function sendServerEvent(string $id, string $eventName, array $eventData, int $retry = null): void - { - // Build event - $event = [ - 'id' => $id, - 'event' => $eventName, - 'data' => json_encode($eventData, JSON_THROW_ON_ERROR), - ]; - if ($retry !== null && $retry > 0) { - $event['retry'] = $retry; - } - - // Print event data to buffer - foreach ($event as $key => $value) { - echo sprintf('%s: %s', $key, $value) . PHP_EOL; + return $this->responseFactory->badRequest('Invalid request parameters'); } - echo PHP_EOL; - // Flush output buffer - if (ob_get_level() > 0) { - ob_flush(); - } - flush(); - } + // Open event stream + $eventStream = SSE\Stream\SelfEmittingEventStream::create($warmupRequest->getId()); + $eventStream->open(); - /** - * @throws MissingPageIdException - * @throws SiteNotFoundException - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException - */ - public function legacyWarmupAction(ServerRequestInterface $request): ResponseInterface - { - // Build warmup request object - try { - $warmupRequest = $this->createWarmupRequest($request->getQueryParams()); - $warmupRequest->setSite($this->determineSite($warmupRequest->getPageId())); - } catch (ExceptionInterface $e) { - return $this->buildBadRequestResponse(); + // Apply event stream to crawler + $crawler = $this->warmupService->getCrawler(); + if ($crawler instanceof Crawler\StreamableCrawler) { + $crawler->setStream($eventStream); } // Perform cache warmup - $crawler = $this->performCacheWarmup($warmupRequest); - - // Redirect to requested URL - $redirectUrl = $this->getRedirectUrl($request); - if ($redirectUrl !== '') { - return new RedirectResponse(GeneralUtility::locationHeaderUrl($redirectUrl), 301); - } - - return new JsonResponse($this->buildJsonResponseData($warmupRequest, $crawler)); - } - - /** - * @throws MissingPageIdException - * @throws SiteNotFoundException - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException - */ - private function performCacheWarmup(WarmupRequest $warmupRequest): CrawlerInterface - { - switch ($warmupRequest->getMode()) { - case self::MODE_PAGE: - if (empty($warmupRequest->getPageId())) { - throw UnsupportedConfigurationException::forMissingPageId(); - } - - return $this->warmupService->warmupPages([$warmupRequest->getPageId()], $warmupRequest); - - case self::MODE_SITE: - default: - $site = $warmupRequest->getSite(); - - if ($site === null) { - throw MissingPageIdException::create(); - } - - return $this->warmupService->warmupSites([$site], $warmupRequest); - } - } - - /** - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException - */ - public function fetchSitesAction(): ResponseInterface - { - $actions = []; - - foreach (array_filter($this->siteFinder->getAllSites(), [AccessUtility::class, 'canWarmupCacheOfSite']) as $site) { - $row = BackendUtility::getRecord('pages', $site->getRootPageId(), '*', ' AND hidden = 0'); - - // Skip site if associated root page is not available - if (!\is_array($row)) { - continue; - } - - $sitemapsFound = false; - $action = [ - 'title' => ($site->getConfiguration()['websiteTitle'] ?? null) ?: BackendUtility::getRecordTitle('pages', $row), - 'pageId' => $site->getRootPageId(), - 'iconIdentifier' => $this->iconFactory->getIconForRecord('pages', $row)->getIdentifier(), - 'sitemaps' => [], - ]; - - // Check all available languages for possible sitemaps - foreach ($this->sitemapLocator->locateAllBySite($site) as $sitemap) { - $siteLanguage = $sitemap->getSiteLanguage(); - \assert($siteLanguage instanceof SiteLanguage); - - $sitemapConfiguration = [ - 'language' => $siteLanguage, - 'isDefaultLanguage' => $siteLanguage === $site->getDefaultLanguage(), - ]; - - if ($this->sitemapLocator->siteContainsSitemap($site, $siteLanguage)) { - $sitemapConfiguration['url'] = (string)$sitemap->getUri(); - $sitemapsFound = true; - } else { - $sitemapConfiguration['missing'] = true; - } - - $action['sitemaps'][] = $sitemapConfiguration; - } - - // Add flag if no site language has a sitemap - if (!$sitemapsFound) { - $action['missing'] = true; - } - - $actions[] = $action; - } - - $view = $this->buildView('CacheWarmupToolbarItemActions.html'); - $view->assign('actions', $actions); - - return new HtmlResponse($view->render()); - } - - /** - * @param array $queryParams - * @throws ExceptionInterface - */ - private function createWarmupRequest(array $queryParams): WarmupRequest - { - return $this->denormalizer->denormalize($queryParams, WarmupRequest::class); - } - - /** - * @throws MissingPageIdException - * @throws SiteNotFoundException - */ - private function determineSite(?int $pageId): Site - { - $allSites = $this->siteFinder->getAllSites(); - - if ($pageId !== null) { - return $this->siteFinder->getSiteByPageId($pageId); - } - - if (\count($allSites) !== 1) { - throw MissingPageIdException::create(); - } - - return end($allSites); - } - - private function getRedirectUrl(ServerRequestInterface $request): string - { - $parsedBody = $request->getParsedBody(); - - if (\is_array($parsedBody)) { - $redirect = $parsedBody['redirect'] ?? null; - } elseif (\is_object($parsedBody) && property_exists($parsedBody, 'redirect')) { - $redirect = $parsedBody->redirect; - } - - $redirect ??= $request->getQueryParams()['redirect'] ?? ''; - - return GeneralUtility::sanitizeLocalUrl($redirect); - } - - /** - * @return array - * @throws MissingPageIdException - */ - private function buildJsonResponseData(WarmupRequest $warmupRequest, CrawlerInterface $crawler): array - { - $successfulCount = \count($crawler->getSuccessfulUrls()); - $failedCount = \count($crawler->getFailedUrls()); - $state = $this->determineCrawlState($successfulCount, $failedCount); - - $data = [ - 'state' => $state, - 'title' => static::translate('notification.title.' . $state), - 'urls' => [ - 'failed' => array_map([$this, 'decorateCrawlingState'], $crawler->getFailedUrls()), - 'successful' => array_map([$this, 'decorateCrawlingState'], $crawler->getSuccessfulUrls()), - ], - ]; - - // Throw exception if page ID is not available - $pageId = $warmupRequest->getPageId(); - if ($pageId === null) { - throw MissingPageIdException::create(); - } - - switch ($warmupRequest->getMode()) { - case self::MODE_PAGE: - $pageTitle = $this->getPageTitle($pageId); - $data['message'] = static::translate('notification.message.page.' . $state, [$pageTitle, $pageId]); - break; - - case self::MODE_SITE: - default: - $site = $warmupRequest->getSite(); - - if ($site === null) { - throw MissingPageIdException::create(); - } - - $pageTitle = $this->getPageTitle($site->getRootPageId()); - $data['message'] = static::translate('notification.message.site', [$pageTitle, $pageId, $successfulCount, $failedCount]); - break; - } - - return $data; - } - - private function buildBadRequestResponse(string $reason = 'Invalid Request Headers'): ResponseInterface - { - return new Response(null, 400, [], $reason); - } - - /** - * @throws MissingPageIdException - */ - private function getPageTitle(int $pageId): string - { - $record = BackendUtility::getRecord('pages', $pageId); - - if ($record === null) { - throw MissingPageIdException::create(); - } - - return BackendUtility::getRecordTitle('pages', $record); - } + $result = $this->warmupService->warmup( + $warmupRequest->getSites(), + $warmupRequest->getPages(), + $warmupRequest->getConfiguration()->getLimit(), + $warmupRequest->getConfiguration()->getStrategy(), + ); - private function determineCrawlState(int $successfulCount, int $failedCount): string - { - if ($failedCount > 0 && $successfulCount === 0) { - return self::STATE_FAILED; - } - if ($failedCount > 0 && $successfulCount > 0) { - return self::STATE_WARNING; - } - if ($failedCount === 0) { - return self::STATE_SUCCESS; - } + // Send final response + $warmupFinishedEvent = new Http\Message\Event\WarmupFinishedEvent($warmupRequest, $result); + $eventStream->sendEvent($warmupFinishedEvent); - return self::STATE_UNKNOWN; - } + // Close event stream + $eventStream->close(); - private function decorateCrawlingState(CrawlingState $crawlingState): string - { - return (string)$crawlingState->getUri(); + return $this->responseFactory->ok(); } } diff --git a/Classes/Controller/CacheWarmupLegacyController.php b/Classes/Controller/CacheWarmupLegacyController.php new file mode 100644 index 00000000..65d5377e --- /dev/null +++ b/Classes/Controller/CacheWarmupLegacyController.php @@ -0,0 +1,91 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Controller; + +use CuyZ\Valinor; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Http; +use EliasHaeussler\Typo3Warming\Service; +use EliasHaeussler\Typo3Warming\ValueObject; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message; +use Psr\Log; +use TYPO3\CMS\Core; + +/** + * CacheWarmupLegacyController + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class CacheWarmupLegacyController +{ + public function __construct( + private readonly Log\LoggerInterface $logger, + private readonly Valinor\Mapper\TreeMapper $mapper, + private readonly Http\Message\ResponseFactory $responseFactory, + private readonly Service\CacheWarmupService $warmupService, + ) { + } + + /** + * @throws CacheWarmup\Exception\Exception + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\MissingPageIdException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + * @throws GuzzleException + */ + public function __invoke(Message\ServerRequestInterface $request): Message\ResponseInterface + { + // Build warmup request object + try { + $warmupRequest = $this->mapper->map(ValueObject\Request\WarmupRequest::class, $request->getQueryParams()); + } catch (Valinor\Mapper\MappingError $error) { + $errors = Valinor\Mapper\Tree\Message\Messages::flattenFromNode($error->node()); + $messages = array_map('strval', $errors->toArray()); + + $this->logger->error( + 'Error during mapping of query parameters to warmup request object.', + ['errors' => $messages], + ); + + return $this->responseFactory->badRequest('Invalid request parameters'); + } + + // Perform cache warmup + $result = $this->warmupService->warmup( + $warmupRequest->getSites(), + $warmupRequest->getPages(), + $warmupRequest->getConfiguration()->getLimit(), + $warmupRequest->getConfiguration()->getStrategy(), + ); + + // Build response data + $warmupFinishedEvent = new Http\Message\Event\WarmupFinishedEvent($warmupRequest, $result); + + return $this->responseFactory->json($warmupFinishedEvent->getData()); + } +} diff --git a/Classes/Controller/FetchSitesController.php b/Classes/Controller/FetchSitesController.php new file mode 100644 index 00000000..e93debe8 --- /dev/null +++ b/Classes/Controller/FetchSitesController.php @@ -0,0 +1,133 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Controller; + +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Crawler; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Http; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use EliasHaeussler\Typo3Warming\ValueObject; +use Psr\Http\Message; +use TYPO3\CMS\Backend; +use TYPO3\CMS\Core; + +/** + * FetchSitesController + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class FetchSitesController +{ + public function __construct( + private readonly Configuration\Configuration $configuration, + private readonly Crawler\Strategy\CrawlingStrategyFactory $crawlingStrategyFactory, + private readonly Core\Imaging\IconFactory $iconFactory, + private readonly Http\Message\ResponseFactory $responseFactory, + private readonly Core\Site\SiteFinder $siteFinder, + private readonly Sitemap\SitemapLocator $sitemapLocator, + ) { + } + + /** + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + */ + public function __invoke(): Message\ResponseInterface + { + $siteGroups = []; + + foreach (array_filter($this->siteFinder->getAllSites(), Utility\AccessUtility::canWarmupCacheOfSite(...)) as $site) { + $row = Backend\Utility\BackendUtility::getRecord('pages', $site->getRootPageId(), '*', ' AND hidden = 0'); + + if (\is_array($row)) { + $siteGroups[] = $this->createSiteGroup($site, $row); + } + } + + return $this->responseFactory->htmlTemplate('Modal/SitesModal', [ + 'siteGroups' => $siteGroups, + 'userAgent' => $this->configuration->getUserAgent(), + 'configuration' => [ + 'limit' => $this->configuration->getLimit(), + 'strategy' => $this->configuration->getStrategy(), + ], + 'availableStrategies' => array_keys($this->crawlingStrategyFactory->getAll()), + 'isAdmin' => Utility\BackendUtility::getBackendUser()->isAdmin(), + ]); + } + + /** + * @param array $page + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + */ + private function createSiteGroup(Core\Site\Entity\Site $site, array $page): ValueObject\Modal\SiteGroup + { + $items = []; + + // Check all available languages for possible sitemaps + foreach ($this->sitemapLocator->locateAllBySite($site) as $sitemap) { + $siteLanguage = $sitemap->getSiteLanguage(); + $url = null; + + // Check if sitemap is accessible + if ($this->sitemapLocator->siteContainsSitemap($site, $siteLanguage)) { + $url = (string)$sitemap->getUri(); + } + + $items[] = new ValueObject\Modal\SiteGroupItem( + $siteLanguage, + $siteLanguage === $site->getDefaultLanguage(), + $url, + ); + } + + return new ValueObject\Modal\SiteGroup( + $site, + $this->resolvePageTitle($site, $page), + $this->iconFactory->getIconForRecord('pages', $page)->getIdentifier(), + $items, + ); + } + + /** + * @param array $page + */ + private function resolvePageTitle(Core\Site\Entity\Site $site, array $page): string + { + $websiteTitle = $site->getConfiguration()['websiteTitle'] ?? null; + + if (\is_string($websiteTitle) && trim($websiteTitle) !== '') { + return $websiteTitle; + } + + return Backend\Utility\BackendUtility::getRecordTitle('pages', $page); + } +} diff --git a/Classes/Crawler/ConcurrentUserAgentCrawler.php b/Classes/Crawler/ConcurrentUserAgentCrawler.php index dd557fbe..95e94c02 100644 --- a/Classes/Crawler/ConcurrentUserAgentCrawler.php +++ b/Classes/Crawler/ConcurrentUserAgentCrawler.php @@ -23,48 +23,93 @@ namespace EliasHaeussler\Typo3Warming\Crawler; -use EliasHaeussler\CacheWarmup\Crawler\ConcurrentCrawler; -use EliasHaeussler\CacheWarmup\CrawlingState; -use GuzzleHttp\Psr7\Request; -use Psr\Http\Message\ResponseInterface; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\SSE; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Http; +use GuzzleHttp\ClientInterface; +use TYPO3\CMS\Core; /** * ConcurrentAgentCrawler * * @author Elias Häußler * @license GPL-2.0-or-later + * + * @extends CacheWarmup\Crawler\AbstractConfigurableCrawler, + * request_options: array, + * client_config: array, + * }> */ -class ConcurrentUserAgentCrawler extends ConcurrentCrawler implements RequestAwareInterface +final class ConcurrentUserAgentCrawler extends CacheWarmup\Crawler\AbstractConfigurableCrawler implements StreamableCrawler { - use ConfigurableClientTrait; - use RequestAwareTrait; - use UserAgentTrait; + use CacheWarmup\Crawler\ConcurrentCrawlerTrait { + getRequestHeaders as getDefaultRequestHeaders; + } + + protected static array $defaultOptions = [ + 'concurrency' => 5, + 'request_method' => 'GET', + 'request_headers' => [], + 'request_options' => [], + 'client_config' => [], + ]; + + private readonly Http\Client\ClientFactory $clientFactory; + private readonly Configuration\Configuration $configuration; + private ClientInterface $client; + private ?SSE\Stream\EventStream $stream = null; + + public function __construct(array $options = []) + { + $this->clientFactory = Core\Utility\GeneralUtility::makeInstance(Http\Client\ClientFactory::class); + $this->configuration = Core\Utility\GeneralUtility::makeInstance(Configuration\Configuration::class); + + parent::__construct($options); + } - protected function getRequests(): \Iterator + public function crawl(array $urls): CacheWarmup\Result\CacheWarmupResult { - /** @var Request $request */ - foreach (parent::getRequests() as $request) { - yield $this->applyUserAgentHeader($request->withMethod('GET')); + $numberOfUrls = \count($urls); + $resultHandler = new CacheWarmup\Http\Message\Handler\ResultCollectorHandler(); + $handlers = [$resultHandler]; + + if ($this->stream !== null) { + $streamHandler = new Http\Message\Handler\StreamResponseHandler($this->stream, $numberOfUrls); + $handlers[] = $streamHandler; } + + // Start crawling + $pool = $this->createPool($urls, $this->client, $handlers); + $pool->promise()->wait(); + + return $resultHandler->getResult(); } - public function onSuccess(ResponseInterface $response, int $index): void + public function setOptions(array $options): void { - $data = [ - 'response' => $response, - ]; + parent::setOptions($options); - $this->successfulUrls[] = $crawlingState = CrawlingState::createSuccessful($this->urls[$index], $data); - $this->updateRequest($crawlingState); + // Recreate client with updated client config + $this->client = $this->clientFactory->get($this->options['client_config']); + } + + public function setStream(SSE\Stream\EventStream $stream): void + { + $this->stream = $stream; } - public function onFailure(\Throwable $exception, int $index): void + /** + * @return array + */ + protected function getRequestHeaders(): array { - $data = [ - 'exception' => $exception, - ]; + $headers = $this->getDefaultRequestHeaders(); + $headers['User-Agent'] = $this->configuration->getUserAgent(); - $this->failedUrls[] = $crawlingState = CrawlingState::createFailed($this->urls[$index], $data); - $this->updateRequest($crawlingState); + return $headers; } } diff --git a/Classes/Crawler/OutputtingUserAgentCrawler.php b/Classes/Crawler/OutputtingUserAgentCrawler.php index b282a10a..51315d88 100644 --- a/Classes/Crawler/OutputtingUserAgentCrawler.php +++ b/Classes/Crawler/OutputtingUserAgentCrawler.php @@ -23,25 +23,99 @@ namespace EliasHaeussler\Typo3Warming\Crawler; -use EliasHaeussler\CacheWarmup\Crawler\OutputtingCrawler; -use GuzzleHttp\Psr7\Request; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Http; +use GuzzleHttp\ClientInterface; +use Symfony\Component\Console; +use TYPO3\CMS\Core; /** * OutputtingUserAgentCrawler * * @author Elias Häußler * @license GPL-2.0-or-later + * + * @extends CacheWarmup\Crawler\AbstractConfigurableCrawler, + * request_options: array, + * client_config: array, + * }> */ -class OutputtingUserAgentCrawler extends OutputtingCrawler +final class OutputtingUserAgentCrawler extends CacheWarmup\Crawler\AbstractConfigurableCrawler implements CacheWarmup\Crawler\VerboseCrawlerInterface { - use ConfigurableClientTrait; - use UserAgentTrait; + use CacheWarmup\Crawler\ConcurrentCrawlerTrait { + getRequestHeaders as getDefaultRequestHeaders; + } + + protected static array $defaultOptions = [ + 'concurrency' => 5, + 'request_method' => 'GET', + 'request_headers' => [], + 'request_options' => [], + 'client_config' => [], + ]; + + private readonly Http\Client\ClientFactory $clientFactory; + private readonly Configuration\Configuration $configuration; + private Console\Output\OutputInterface $output; + private ClientInterface $client; + + public function __construct(array $options = []) + { + $this->clientFactory = Core\Utility\GeneralUtility::makeInstance(Http\Client\ClientFactory::class); + $this->configuration = Core\Utility\GeneralUtility::makeInstance(Configuration\Configuration::class); + $this->output = new Console\Output\ConsoleOutput(); - protected function getRequests(): \Iterator + parent::__construct($options); + } + + public function crawl(array $urls): CacheWarmup\Result\CacheWarmupResult { - /** @var Request $request */ - foreach (parent::getRequests() as $request) { - yield $this->applyUserAgentHeader($request->withMethod('GET')); + $numberOfUrls = \count($urls); + $resultHandler = new CacheWarmup\Http\Message\Handler\ResultCollectorHandler(); + + // Create progress response handler (depends on the available output) + if ($this->output instanceof Console\Output\ConsoleOutputInterface && $this->output->isVerbose()) { + $progressBarHandler = new CacheWarmup\Http\Message\Handler\VerboseProgressHandler($this->output, $numberOfUrls); + } else { + $progressBarHandler = new CacheWarmup\Http\Message\Handler\CompactProgressHandler($this->output, $numberOfUrls); } + + // Create request pool + $pool = $this->createPool($urls, $this->client, [$resultHandler, $progressBarHandler]); + + // Start crawling + $progressBarHandler->startProgressBar(); + $pool->promise()->wait(); + $progressBarHandler->finishProgressBar(); + + return $resultHandler->getResult(); + } + + public function setOptions(array $options): void + { + parent::setOptions($options); + + // Recreate client with updated client config + $this->client = $this->clientFactory->get($this->options['client_config']); + } + + public function setOutput(Console\Output\OutputInterface $output): void + { + $this->output = $output; + } + + /** + * @return array + */ + protected function getRequestHeaders(): array + { + $headers = $this->getDefaultRequestHeaders(); + $headers['User-Agent'] = $this->configuration->getUserAgent(); + + return $headers; } } diff --git a/Classes/Crawler/Strategy/CrawlingStrategyFactory.php b/Classes/Crawler/Strategy/CrawlingStrategyFactory.php new file mode 100644 index 00000000..22d2a733 --- /dev/null +++ b/Classes/Crawler/Strategy/CrawlingStrategyFactory.php @@ -0,0 +1,72 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Crawler\Strategy; + +use EliasHaeussler\CacheWarmup; +use Symfony\Component\DependencyInjection; + +/** + * CrawlingStrategyFactory + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class CrawlingStrategyFactory +{ + /** + * @param DependencyInjection\ServiceLocator $strategies + */ + public function __construct( + private readonly DependencyInjection\ServiceLocator $strategies, + ) { + } + + public function get(string $strategy): ?CacheWarmup\Crawler\Strategy\CrawlingStrategy + { + if ($this->strategies->has($strategy)) { + return $this->strategies->get($strategy); + } + + return null; + } + + /** + * @return array + */ + public function getAll(): array + { + $strategies = []; + + foreach (array_keys($this->strategies->getProvidedServices()) as $crawlingStrategy) { + $strategies[$crawlingStrategy] = $this->strategies->get($crawlingStrategy); + } + + return $strategies; + } + + public function has(string $strategy): bool + { + return $this->strategies->has($strategy); + } +} diff --git a/Classes/Crawler/RequestAwareInterface.php b/Classes/Crawler/StreamableCrawler.php similarity index 81% rename from Classes/Crawler/RequestAwareInterface.php rename to Classes/Crawler/StreamableCrawler.php index 32d60d27..a3a5dbd0 100644 --- a/Classes/Crawler/RequestAwareInterface.php +++ b/Classes/Crawler/StreamableCrawler.php @@ -23,15 +23,16 @@ namespace EliasHaeussler\Typo3Warming\Crawler; -use EliasHaeussler\Typo3Warming\Request\WarmupRequest; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\SSE; /** - * RequestAwareInterface + * StreamableCrawler * * @author Elias Häußler * @license GPL-2.0-or-later */ -interface RequestAwareInterface +interface StreamableCrawler extends CacheWarmup\Crawler\CrawlerInterface { - public function setRequest(WarmupRequest $request): void; + public function setStream(SSE\Stream\EventStream $stream): void; } diff --git a/Classes/Traits/BackendUserAuthenticationTrait.php b/Classes/Enums/WarmupState.php similarity index 75% rename from Classes/Traits/BackendUserAuthenticationTrait.php rename to Classes/Enums/WarmupState.php index 1a696d80..18df8fde 100644 --- a/Classes/Traits/BackendUserAuthenticationTrait.php +++ b/Classes/Enums/WarmupState.php @@ -21,20 +21,18 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Traits; - -use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +namespace EliasHaeussler\Typo3Warming\Enums; /** - * BackendUserAuthenticationTrait + * WarmupState * * @author Elias Häußler * @license GPL-2.0-or-later */ -trait BackendUserAuthenticationTrait +enum WarmupState: string { - protected static function getBackendUser(): BackendUserAuthentication - { - return $GLOBALS['BE_USER']; - } + case Failed = 'failed'; + case Success = 'success'; + case Unknown = 'unknown'; + case Warning = 'warning'; } diff --git a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingResponse.ts b/Classes/Exception/Exception.php similarity index 79% rename from Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingResponse.ts rename to Classes/Exception/Exception.php index 1489dfd2..cea6d233 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingResponse.ts +++ b/Classes/Exception/Exception.php @@ -1,9 +1,11 @@ -'use strict' + + * Copyright (C) 2023 Elias Häußler * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,13 +21,14 @@ * along with this program. If not, see . */ +namespace EliasHaeussler\Typo3Warming\Exception; + /** - * Interface describing the response of a current crawl. + * Exception * * @author Elias Häußler * @license GPL-2.0-or-later */ -export default interface CrawlingResponse { - title: string; - message: string; +abstract class Exception extends \Exception +{ } diff --git a/Classes/Exception/InvalidProviderException.php b/Classes/Exception/InvalidProviderException.php new file mode 100644 index 00000000..65bb3b9b --- /dev/null +++ b/Classes/Exception/InvalidProviderException.php @@ -0,0 +1,55 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Exception; + +use EliasHaeussler\Typo3Warming\Sitemap; + +/** + * InvalidProviderException + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class InvalidProviderException extends Exception +{ + public static function create(object $provider): self + { + return new self( + sprintf( + 'The given provider "%s" does not implement the interface "%s".', + $provider::class, + Sitemap\Provider\Provider::class, + ), + 1619524996, + ); + } + + public static function forInvalidType(mixed $value): self + { + return new self( + sprintf('Providers must be of type object, "%s" given.', \gettype($value)), + 1619525071, + ); + } +} diff --git a/Classes/Exception/MissingPageIdException.php b/Classes/Exception/MissingPageIdException.php index 58928a89..768656b4 100644 --- a/Classes/Exception/MissingPageIdException.php +++ b/Classes/Exception/MissingPageIdException.php @@ -29,7 +29,7 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class MissingPageIdException extends \Exception +final class MissingPageIdException extends Exception { public static function create(): self { diff --git a/Classes/Exception/UnsupportedConfigurationException.php b/Classes/Exception/UnsupportedConfigurationException.php index c95e6170..6fd69cf7 100644 --- a/Classes/Exception/UnsupportedConfigurationException.php +++ b/Classes/Exception/UnsupportedConfigurationException.php @@ -29,39 +29,10 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class UnsupportedConfigurationException extends \Exception +final class UnsupportedConfigurationException extends Exception { public static function forBaseUrl(string $baseUrl): self { return new self(sprintf('The given base URL "%s" is not supported.', $baseUrl), 1619168965); } - - public static function forMissingPageId(): self - { - return new self('No page ID given. Omitting the page ID is not supported.', 1619190793); - } - - public static function forTypeMismatch(string $expectedType, string $actualType): self - { - return new self( - sprintf('Expected variable of type "%s", got "%s" instead.', $expectedType, $actualType), - 1619196807 - ); - } - - public static function forUnresolvableClass(string $className): self - { - return new self( - sprintf('Given class "%s" does not exist or cannot be resolved.', $className), - 1619196886 - ); - } - - public static function forMissingImplementation(string $expectedInterface, string $actualClassName): self - { - return new self( - sprintf('Given class "%s" does not implement the expected interface "%s".', $actualClassName, $expectedInterface), - 1619196994 - ); - } } diff --git a/Classes/Exception/UnsupportedSiteException.php b/Classes/Exception/UnsupportedSiteException.php index e5c65065..f77aca40 100644 --- a/Classes/Exception/UnsupportedSiteException.php +++ b/Classes/Exception/UnsupportedSiteException.php @@ -23,7 +23,7 @@ namespace EliasHaeussler\Typo3Warming\Exception; -use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core; /** * UnsupportedSiteException @@ -31,9 +31,9 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class UnsupportedSiteException extends \Exception +final class UnsupportedSiteException extends Exception { - public static function forMissingSitemap(Site $site): self + public static function forMissingSitemap(Core\Site\Entity\Site $site): self { return new self( sprintf('The site "%s" is not supported since it does not provide a sitemap.', $site->getIdentifier()), diff --git a/Classes/Extension.php b/Classes/Extension.php new file mode 100644 index 00000000..694755da --- /dev/null +++ b/Classes/Extension.php @@ -0,0 +1,81 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming; + +use TYPO3\CMS\Core; + +/** + * Extension + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * @codeCoverageIgnore + */ +final class Extension +{ + public const KEY = 'warming'; + public const NAME = 'Warming'; + + /** + * Register additional caches. + * + * FOR USE IN ext_localconf.php. + */ + public static function registerCaches(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'][self::KEY] = [ + 'backend' => Core\Cache\Backend\SimpleFileBackend::class, + 'frontend' => Core\Cache\Frontend\PhpFrontend::class, + ]; + } + + /** + * Register custom styles for Backend. + * + * FOR USE IN ext_tables.php ONLY. + */ + public static function registerCustomStyles(): void + { + $GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets'][self::KEY] = 'EXT:warming/Resources/Public/Css'; + } + + /** + * Load additional libraries provided by PHAR file (only to be used in non-Composer-mode). + * + * FOR USE IN ext_localconf.php AND NON-COMPOSER-MODE ONLY. + */ + public static function loadVendorLibraries(): void + { + // Vendor libraries are already available in Composer mode + if (Core\Core\Environment::isComposerMode()) { + return; + } + + $vendorPharFile = Core\Utility\GeneralUtility::getFileAbsFileName('EXT:warming/Resources/Private/Libs/vendors.phar'); + + if (file_exists($vendorPharFile)) { + require 'phar://' . $vendorPharFile . '/vendor/autoload.php'; + } + } +} diff --git a/Classes/Crawler/ConfigurableClientTrait.php b/Classes/Http/Client/ClientFactory.php similarity index 52% rename from Classes/Crawler/ConfigurableClientTrait.php rename to Classes/Http/Client/ClientFactory.php index dbb03c47..f0130121 100644 --- a/Classes/Crawler/ConfigurableClientTrait.php +++ b/Classes/Http/Client/ClientFactory.php @@ -21,62 +21,45 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Crawler; +namespace EliasHaeussler\Typo3Warming\Http\Client; -use EliasHaeussler\CacheWarmup\Crawler\ConfigurableCrawlerInterface; use GuzzleHttp\ClientInterface; -use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; -use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core; /** - * ConfigurableClientTrait + * ClientFactory * * @author Elias Häußler * @license GPL-2.0-or-later */ -trait ConfigurableClientTrait +final class ClientFactory { - protected function initializeClient(): ClientInterface - { - $clientConfig = $this->getClientConfig(); + public function __construct( + private readonly Core\Http\Client\GuzzleClientFactory $guzzleClientFactory, + ) { + } + /** + * @param array $config + */ + public function get(array $config = []): ClientInterface + { // Early return if no client config is set - if ($clientConfig === []) { - return GuzzleClientFactory::getClient(); + if ($config === []) { + return $this->guzzleClientFactory->getClient(); } // Merge initial TYPO3 config with actual client config - $initialConfig = $GLOBALS['TYPO3_CONF_VARS']['HTTP']; - ArrayUtility::mergeRecursiveWithOverrule($GLOBALS['TYPO3_CONF_VARS']['HTTP'], $clientConfig); + $initialConfig = $GLOBALS['TYPO3_CONF_VARS']['HTTP'] ??= []; + Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($GLOBALS['TYPO3_CONF_VARS']['HTTP'], $config); // Initialize client and restore initial config try { - $client = GuzzleClientFactory::getClient(); + $client = $this->guzzleClientFactory->getClient(); } finally { $GLOBALS['TYPO3_CONF_VARS']['HTTP'] = $initialConfig; } return $client; } - - /** - * @return array - */ - protected function getClientConfig(): array - { - if (!($this instanceof ConfigurableCrawlerInterface)) { - return []; - } - - /* @phpstan-ignore-next-line */ - return $this->options['client_config'] ?? []; - } - - public function setOptions(array $options): void - { - parent::setOptions($options); - - // Re-initialize client with updated client config - $this->client = $this->initializeClient(); - } } diff --git a/Classes/Http/Message/Event/WarmupFinishedEvent.php b/Classes/Http/Message/Event/WarmupFinishedEvent.php new file mode 100644 index 00000000..e0ec0d23 --- /dev/null +++ b/Classes/Http/Message/Event/WarmupFinishedEvent.php @@ -0,0 +1,190 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Http\Message\Event; + +use EliasHaeussler\SSE; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Enums; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Result; +use EliasHaeussler\Typo3Warming\ValueObject; +use TYPO3\CMS\Backend; + +/** + * WarmupFinishedEvent + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * @internal + */ +final class WarmupFinishedEvent implements SSE\Event\Event +{ + public function __construct( + private readonly ValueObject\Request\WarmupRequest $request, + private readonly Result\CacheWarmupResult $result, + ) { + } + + public function getName(): string + { + return 'warmupFinished'; + } + + /** + * @return array{ + * state: string, + * title: string|null, + * progress: array{ + * current: int, + * total: int, + * }, + * urls: array{ + * failed: list, + * successful: list, + * }, + * excluded: array{ + * sitemaps: list, + * urls: list, + * }, + * messages: array, + * } + * @throws Exception\MissingPageIdException + */ + public function getData(): array + { + $state = $this->determineWarmupState(); + + $failedUrls = $this->result->getResult()->getFailed(); + $successfulUrls = $this->result->getResult()->getSuccessful(); + + return [ + 'state' => $state->value, + 'title' => Configuration\Localization::translate('notification.title.' . $state->value), + 'progress' => [ + 'current' => \count($failedUrls) + \count($successfulUrls), + 'total' => \count($failedUrls) + \count($successfulUrls), + ], + 'urls' => [ + 'failed' => array_map('strval', $failedUrls), + 'successful' => array_map('strval', $successfulUrls), + ], + 'excluded' => [ + 'sitemaps' => array_map('strval', $this->result->getExcludedSitemaps()), + 'urls' => array_map('strval', $this->result->getExcludedUrls()), + ], + 'messages' => $this->buildMessages($state), + ]; + } + + /** + * @return array + * @throws Exception\MissingPageIdException + */ + public function jsonSerialize(): array + { + return $this->getData(); + } + + private function determineWarmupState(): Enums\WarmupState + { + $failed = \count($this->result->getResult()->getFailed()); + $successful = \count($this->result->getResult()->getSuccessful()); + + if ($failed > 0 && $successful === 0) { + return Enums\WarmupState::Failed; + } + + if ($failed > 0 && $successful > 0) { + return Enums\WarmupState::Warning; + } + + if ($failed === 0) { + return Enums\WarmupState::Success; + } + + return Enums\WarmupState::Unknown; + } + + /** + * @return array + * @throws Exception\MissingPageIdException + */ + private function buildMessages(Enums\WarmupState $state): array + { + $messages = []; + $emptyMessage = Configuration\Localization::translate('notification.message.empty'); + + foreach ($this->request->getSites() as $siteWarmupRequest) { + foreach ($siteWarmupRequest->getLanguageIds() as $languageId) { + $site = $siteWarmupRequest->getSite(); + $siteLanguage = $site->getLanguageById($languageId); + + ['successful' => $successful, 'failed' => $failed] = $this->result->getCrawlingResultsBySite( + $site, + $siteLanguage, + ); + + $messages[] = Configuration\Localization::translate('notification.message.site', [ + $this->getPageTitle($site->getRootPageId()), + $site->getRootPageId(), + $siteLanguage->getTitle(), + $languageId, + \count($successful), + \count($failed), + ]); + } + } + + foreach ($this->request->getPages() as $pageWarmupRequest) { + $messages[] = Configuration\Localization::translate('notification.message.page.' . $state->value, [ + $this->getPageTitle($pageWarmupRequest->getPage()), + $pageWarmupRequest->getPage(), + ]); + } + + // Remove invalid messages + $messages = array_filter($messages); + + // Handle no cache warmup + if ($messages === [] && $emptyMessage !== null) { + $messages[] = $emptyMessage; + } + + return $messages; + } + + /** + * @throws Exception\MissingPageIdException + */ + private function getPageTitle(int $pageId): string + { + $record = Backend\Utility\BackendUtility::getRecord('pages', $pageId); + + if ($record === null) { + throw Exception\MissingPageIdException::create(); + } + + return Backend\Utility\BackendUtility::getRecordTitle('pages', $record); + } +} diff --git a/Classes/Http/Message/Event/WarmupProgressEvent.php b/Classes/Http/Message/Event/WarmupProgressEvent.php new file mode 100644 index 00000000..c7f40db4 --- /dev/null +++ b/Classes/Http/Message/Event/WarmupProgressEvent.php @@ -0,0 +1,89 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Http\Message\Event; + +use EliasHaeussler\SSE; + +/** + * WarmupProgressEvent + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * @internal + */ +final class WarmupProgressEvent implements SSE\Event\Event +{ + /** + * @param list $successfulUrls + * @param list $failedUrls + */ + public function __construct( + private readonly string $currentUrl, + private readonly array $successfulUrls, + private readonly array $failedUrls, + private readonly int $numberOfUrls, + ) { + } + + public function getName(): string + { + return 'warmupProgress'; + } + + /** + * @return array{ + * progress: array{ + * current: int, + * total: int, + * }, + * urls: array{ + * current: string, + * successful: list, + * failed: list, + * }, + * } + */ + public function getData(): array + { + return [ + 'progress' => [ + 'current' => \count($this->successfulUrls) + \count($this->failedUrls), + 'total' => $this->numberOfUrls, + ], + 'urls' => [ + 'current' => $this->currentUrl, + 'successful' => $this->successfulUrls, + 'failed' => $this->failedUrls, + ], + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->getData(); + } +} diff --git a/Classes/Http/Message/Handler/StreamResponseHandler.php b/Classes/Http/Message/Handler/StreamResponseHandler.php new file mode 100644 index 00000000..17fcf063 --- /dev/null +++ b/Classes/Http/Message/Handler/StreamResponseHandler.php @@ -0,0 +1,97 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Http\Message\Handler; + +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\SSE; +use EliasHaeussler\Typo3Warming\Http; +use JsonException; +use Psr\Http\Message; +use Throwable; + +/** + * StreamResponseHandler + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class StreamResponseHandler implements CacheWarmup\Http\Message\Handler\ResponseHandlerInterface +{ + /** + * @var list + */ + private array $successfulUrls = []; + + /** + * @var list + */ + private array $failedUrls = []; + + public function __construct( + private readonly SSE\Stream\EventStream $stream, + private readonly int $numberOfUrls, + ) { + } + + /** + * @throws JsonException + * @throws SSE\Exception\StreamIsClosed + * @throws SSE\Exception\StreamIsInactive + */ + public function onSuccess(Message\ResponseInterface $response, Message\UriInterface $uri): void + { + $this->successfulUrls[] = (string)$uri; + + $this->sendEvent($uri); + } + + /** + * @throws JsonException + * @throws SSE\Exception\StreamIsClosed + * @throws SSE\Exception\StreamIsInactive + */ + public function onFailure(Throwable $exception, Message\UriInterface $uri): void + { + $this->failedUrls[] = (string)$uri; + + $this->sendEvent($uri); + } + + /** + * @throws JsonException + * @throws SSE\Exception\StreamIsClosed + * @throws SSE\Exception\StreamIsInactive + */ + private function sendEvent(Message\UriInterface $currentUrl): void + { + $event = new Http\Message\Event\WarmupProgressEvent( + (string)$currentUrl, + $this->successfulUrls, + $this->failedUrls, + $this->numberOfUrls, + ); + + $this->stream->sendEvent($event); + } +} diff --git a/Classes/Http/Message/ResponseFactory.php b/Classes/Http/Message/ResponseFactory.php new file mode 100644 index 00000000..e651a5a1 --- /dev/null +++ b/Classes/Http/Message/ResponseFactory.php @@ -0,0 +1,75 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Http\Message; + +use EliasHaeussler\Typo3Warming\View; +use Psr\Http\Message; +use TYPO3\CMS\Core; + +/** + * ResponseFactory + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class ResponseFactory +{ + public function __construct( + private readonly View\TemplateRenderer $renderer, + ) { + } + + public function ok(): Message\ResponseInterface + { + return new Core\Http\Response(); + } + + public function html(string $html): Message\ResponseInterface + { + return new Core\Http\HtmlResponse($html); + } + + /** + * @param array $variables + */ + public function htmlTemplate(string $templatePath, array $variables = []): Message\ResponseInterface + { + $html = $this->renderer->render($templatePath, $variables); + + return $this->html($html); + } + + /** + * @param array $json + */ + public function json(array $json): Message\ResponseInterface + { + return new Core\Http\JsonResponse($json); + } + + public function badRequest(string $reason): Message\ResponseInterface + { + return new Core\Http\Response(null, 400, [], $reason); + } +} diff --git a/Classes/Mapper/MapperFactory.php b/Classes/Mapper/MapperFactory.php new file mode 100644 index 00000000..d1e24a88 --- /dev/null +++ b/Classes/Mapper/MapperFactory.php @@ -0,0 +1,61 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Mapper; + +use CuyZ\Valinor; +use TYPO3\CMS\Core; + +/** + * MapperFactory + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class MapperFactory +{ + public function __construct( + private readonly Core\Site\SiteFinder $siteFinder, + ) { + } + + public function get(): Valinor\Mapper\TreeMapper + { + return (new Valinor\MapperBuilder()) + ->registerConstructor( + $this->mapSites(...), + ) + ->allowSuperfluousKeys() + ->enableFlexibleCasting() + ->mapper() + ; + } + + /** + * @throws Core\Exception\SiteNotFoundException + */ + private function mapSites(string $siteIdentifier): Core\Site\Entity\Site + { + return $this->siteFinder->getSiteByIdentifier($siteIdentifier); + } +} diff --git a/Classes/Request/WarmupRequest.php b/Classes/Request/WarmupRequest.php deleted file mode 100644 index 771fca2e..00000000 --- a/Classes/Request/WarmupRequest.php +++ /dev/null @@ -1,211 +0,0 @@ - - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -namespace EliasHaeussler\Typo3Warming\Request; - -use EliasHaeussler\CacheWarmup\CrawlingState; -use EliasHaeussler\Typo3Warming\Controller\CacheWarmupController; -use Psr\Http\Message\UriInterface; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Utility\StringUtility; - -/** - * WarmupRequest - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -class WarmupRequest -{ - protected string $id; - - /** - * @var CacheWarmupController::MODE_* - */ - protected string $mode; - protected ?int $languageId; - protected ?int $pageId; - protected ?Site $site = null; - - /** - * @var UriInterface[] - */ - protected array $requestedUrls = []; - - /** - * @var CrawlingState[] - */ - protected array $crawlingStates = []; - - /** - * @var callable|null - */ - protected $updateCallback; - - /** - * @param CacheWarmupController::MODE_* $mode - */ - public function __construct( - string $requestId = null, - string $mode = CacheWarmupController::MODE_SITE, - int $languageId = null, - int $pageId = null - ) { - $this->id = $requestId ?? StringUtility::getUniqueId('_'); - $this->mode = $mode; - $this->languageId = $languageId; - $this->pageId = $pageId; - } - - public function getId(): string - { - return $this->id; - } - - public function getMode(): string - { - return $this->mode; - } - - public function getLanguageId(): ?int - { - return $this->languageId; - } - - public function getPageId(): ?int - { - return $this->pageId; - } - - public function getTotal(): int - { - return \count($this->requestedUrls); - } - - public function getProcessed(): int - { - return \count($this->crawlingStates); - } - - public function isSuccessful(): bool - { - foreach ($this->crawlingStates as $crawlingState) { - if (!$crawlingState->isSuccessful()) { - return false; - } - } - - return true; - } - - /** - * @return UriInterface[] - */ - public function getRequestedUrls(): array - { - return $this->requestedUrls; - } - - /** - * @param UriInterface[] $requestedUrls - */ - public function setRequestedUrls(array $requestedUrls): self - { - $this->requestedUrls = $requestedUrls; - - return $this; - } - - /** - * @return CrawlingState[] - */ - public function getCrawlingStates(): array - { - return $this->crawlingStates; - } - - public function addCrawlingState(CrawlingState $state): self - { - $this->crawlingStates[] = $state; - $this->triggerUpdate(); - - return $this; - } - - /** - * @return \Generator - */ - public function getSuccessfulCrawls(): \Generator - { - yield from $this->filterByState(CrawlingState::SUCCESSFUL); - } - - /** - * @return \Generator - */ - public function getFailedCrawls(): \Generator - { - yield from $this->filterByState(CrawlingState::FAILED); - } - - public function getSite(): ?Site - { - return $this->site; - } - - public function setSite(?Site $site): void - { - $this->site = $site; - } - - public function getUpdateCallback(): ?callable - { - return $this->updateCallback; - } - - public function setUpdateCallback(?callable $updateCallback): self - { - $this->updateCallback = $updateCallback; - - return $this; - } - - /** - * @return \Generator - */ - protected function filterByState(int $state): \Generator - { - foreach ($this->crawlingStates as $crawlingState) { - if ($crawlingState->is($state)) { - yield $crawlingState; - } - } - } - - protected function triggerUpdate(): void - { - if (\is_callable($this->updateCallback)) { - ($this->updateCallback)($this); - } - } -} diff --git a/Classes/Result/CacheWarmupResult.php b/Classes/Result/CacheWarmupResult.php new file mode 100644 index 00000000..04ab5b06 --- /dev/null +++ b/Classes/Result/CacheWarmupResult.php @@ -0,0 +1,122 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Result; + +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Sitemap; +use TYPO3\CMS\Core; + +/** + * CacheWarmupResult + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class CacheWarmupResult +{ + /** + * @param list $excludedSitemaps + * @param list $excludedUrls + */ + public function __construct( + private readonly CacheWarmup\Result\CacheWarmupResult $result, + private readonly array $excludedSitemaps = [], + private readonly array $excludedUrls = [], + ) { + } + + public function getResult(): CacheWarmup\Result\CacheWarmupResult + { + return $this->result; + } + + /** + * @return array{ + * successful: list, + * failed: list, + * } + */ + public function getCrawlingResultsBySite( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage, + ): array { + return [ + 'successful' => array_values( + array_filter( + $this->result->getSuccessful(), + fn (CacheWarmup\Result\CrawlingResult $crawlingResult) => $this->filterBySite( + $crawlingResult, + $site, + $siteLanguage, + ), + ), + ), + 'failed' => array_values( + array_filter( + $this->result->getFailed(), + fn (CacheWarmup\Result\CrawlingResult $crawlingResult) => $this->filterBySite( + $crawlingResult, + $site, + $siteLanguage, + ), + ), + ), + ]; + } + + /** + * @return list + */ + public function getExcludedSitemaps(): array + { + return $this->excludedSitemaps; + } + + /** + * @return list + */ + public function getExcludedUrls(): array + { + return $this->excludedUrls; + } + + private function filterBySite( + CacheWarmup\Result\CrawlingResult $crawlingResult, + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage, + ): bool { + $url = $crawlingResult->getUri(); + + if (!($url instanceof CacheWarmup\Sitemap\Url)) { + return false; + } + + $rootOrigin = $url->getRootOrigin(); + + return $rootOrigin instanceof Sitemap\SiteAwareSitemap + && $rootOrigin->getSite() === $site + && $rootOrigin->getSiteLanguage() === $siteLanguage + ; + } +} diff --git a/Classes/Service/CacheWarmupService.php b/Classes/Service/CacheWarmupService.php index 4ee43c2d..e61b782d 100644 --- a/Classes/Service/CacheWarmupService.php +++ b/Classes/Service/CacheWarmupService.php @@ -23,22 +23,17 @@ namespace EliasHaeussler\Typo3Warming\Service; -use EliasHaeussler\CacheWarmup\CacheWarmer; -use EliasHaeussler\CacheWarmup\Crawler\ConfigurableCrawlerInterface; -use EliasHaeussler\CacheWarmup\Crawler\CrawlerInterface; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use EliasHaeussler\Typo3Warming\Crawler\RequestAwareInterface; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedConfigurationException; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedSiteException; -use EliasHaeussler\Typo3Warming\Request\WarmupRequest; -use EliasHaeussler\Typo3Warming\Sitemap\SitemapLocator; -use Psr\Http\Message\UriInterface; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerAwareTrait; -use TYPO3\CMS\Core\Exception\SiteNotFoundException; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\SiteFinder; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Configuration; +use EliasHaeussler\Typo3Warming\Crawler; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Http; +use EliasHaeussler\Typo3Warming\Result; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use EliasHaeussler\Typo3Warming\ValueObject; +use GuzzleHttp\Exception\GuzzleException; +use TYPO3\CMS\Core; /** * CacheWarmupService @@ -46,147 +41,118 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class CacheWarmupService implements LoggerAwareInterface +final class CacheWarmupService { - use LoggerAwareTrait; - - private SiteFinder $siteFinder; - private SitemapLocator $sitemapLocator; - private int $limit; - private CrawlerInterface $crawler; + private CacheWarmup\Crawler\CrawlerInterface $crawler; /** - * @throws UnsupportedConfigurationException + * @throws CacheWarmup\Exception\InvalidCrawlerException */ public function __construct( - SiteFinder $siteFinder, - SitemapLocator $sitemapLocator, - Configuration $configuration + private readonly Http\Client\ClientFactory $clientFactory, + private readonly Configuration\Configuration $configuration, + private readonly CacheWarmup\Crawler\CrawlerFactory $crawlerFactory, + private readonly Crawler\Strategy\CrawlingStrategyFactory $crawlingStrategyFactory, + private readonly Sitemap\SitemapLocator $sitemapLocator, ) { - $this->siteFinder = $siteFinder; - $this->sitemapLocator = $sitemapLocator; - $this->limit = $configuration->getLimit(); - $this->crawler = $this->initializeCrawler( - $configuration->getCrawler(), - $configuration->getCrawlerOptions() + $this->setCrawler( + $this->configuration->getCrawler(), + $this->configuration->getCrawlerOptions(), ); } /** - * @param Site[] $sites - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException + * @param list $sites + * @param list $pages + * @throws CacheWarmup\Exception\Exception + * @throws Core\Exception\SiteNotFoundException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException + * @throws GuzzleException */ - public function warmupSites(array $sites, WarmupRequest $request): CrawlerInterface - { - $cacheWarmer = new CacheWarmer(); - $cacheWarmer->setLimit($this->limit); + public function warmup( + array $sites = [], + array $pages = [], + int $limit = null, + string $strategy = null, + ): Result\CacheWarmupResult { + $cacheWarmer = new CacheWarmup\CacheWarmer( + $limit ?? $this->configuration->getLimit(), + $this->clientFactory->get(), + $this->crawler, + $this->createCrawlingStrategy($strategy), + true, + $this->configuration->getExcludePatterns(), + ); - foreach ($sites as $site) { - $siteLanguage = null; - if ($request->getLanguageId() !== null) { - $siteLanguage = $site->getLanguageById($request->getLanguageId()); + foreach ($sites as $siteWarmupRequest) { + foreach ($siteWarmupRequest->getLanguageIds() as $languageId) { + $siteLanguage = $siteWarmupRequest->getSite()->getLanguageById($languageId); + $sitemap = $this->sitemapLocator->locateBySite($siteWarmupRequest->getSite(), $siteLanguage); + $cacheWarmer->addSitemaps($sitemap); } - $sitemap = $this->sitemapLocator->locateBySite($site, $siteLanguage); - $cacheWarmer->addSitemaps($sitemap); } - if ($this->crawler instanceof RequestAwareInterface) { - $request->setRequestedUrls($cacheWarmer->getUrls()); - $this->crawler->setRequest($request); - } + foreach ($pages as $pageWarmupRequest) { + $languageIds = [null]; - return $cacheWarmer->run($this->crawler); - } - - /** - * @param int[] $pageIds - * @throws SiteNotFoundException - */ - public function warmupPages(array $pageIds, WarmupRequest $request): CrawlerInterface - { - $cacheWarmer = new CacheWarmer(); - $cacheWarmer->setLimit($this->limit); + if ($pageWarmupRequest->getLanguageIds() !== []) { + $languageIds = $pageWarmupRequest->getLanguageIds(); + } - foreach ($pageIds as $pageId) { - $url = $this->generateUri($pageId, $request->getLanguageId()); - $cacheWarmer->addUrl($url); - } + foreach ($languageIds as $languageId) { + $url = Utility\HttpUtility::generateUri($pageWarmupRequest->getPage(), $languageId); - if ($this->crawler instanceof RequestAwareInterface) { - $request->setRequestedUrls($cacheWarmer->getUrls()); - $this->crawler->setRequest($request); + if ($url !== null) { + $cacheWarmer->addUrl((string)$url); + } + } } - return $cacheWarmer->run($this->crawler); - } - - /** - * @throws SiteNotFoundException - */ - public function generateUri(int $pageId, int $languageId = null): UriInterface - { - $site = $this->siteFinder->getSiteByPageId($pageId); - - return $site->getRouter()->generateUri((string)$pageId, ['_language' => $languageId]); + return new Result\CacheWarmupResult( + $cacheWarmer->run(), + $cacheWarmer->getExcludedSitemaps(), + $cacheWarmer->getExcludedUrls(), + ); } - public function getCrawler(): CrawlerInterface + public function getCrawler(): CacheWarmup\Crawler\CrawlerInterface { return $this->crawler; } /** - * @param class-string|CrawlerInterface $crawler - * @param array $options - * @throws UnsupportedConfigurationException - */ - public function setCrawler($crawler, array $options = []): self - { - $this->crawler = $this->initializeCrawler($crawler, $options); - return $this; - } - - /** - * @param class-string|CrawlerInterface $crawler + * @param class-string|CacheWarmup\Crawler\CrawlerInterface $crawler * @param array $options - * @throws UnsupportedConfigurationException + * @throws CacheWarmup\Exception\InvalidCrawlerException */ - private function initializeCrawler($crawler, array $options = []): CrawlerInterface + public function setCrawler(string|CacheWarmup\Crawler\CrawlerInterface $crawler, array $options = []): self { - if ($crawler instanceof CrawlerInterface) { - goto configurableCrawler; + if ($options !== []) { + $options = $this->crawlerFactory->parseCrawlerOptions($options); } - // Use default crawler if no custom crawler is given - if (empty($crawler)) { - $crawler = Configuration::DEFAULT_CRAWLER; - } - - // Throw exception if crawler variable type is unsupported - if (!\is_string($crawler)) { - throw UnsupportedConfigurationException::forTypeMismatch('string', \gettype($crawler)); + if ($crawler instanceof CacheWarmup\Crawler\ConfigurableCrawlerInterface && $options !== []) { + $crawler->setOptions($options); } - // Throw exception if crawler class does not exist - if (!class_exists($crawler)) { - throw UnsupportedConfigurationException::forUnresolvableClass($crawler); + if (\is_string($crawler)) { + $this->crawler = $this->crawlerFactory->get($crawler, $options); + } else { + $this->crawler = $crawler; } - // Throw exception if crawler class is invalid - if (!\in_array(CrawlerInterface::class, class_implements($crawler) ?: [])) { - throw UnsupportedConfigurationException::forMissingImplementation($crawler, CrawlerInterface::class); - } + return $this; + } - // Instantiate crawler - $crawler = GeneralUtility::makeInstance($crawler); + private function createCrawlingStrategy(string $strategy = null): ?CacheWarmup\Crawler\Strategy\CrawlingStrategy + { + $strategy ??= $this->configuration->getStrategy(); - // Apply crawler options to configurable crawler - configurableCrawler: - if ($crawler instanceof ConfigurableCrawlerInterface) { - $crawler->setOptions($options); + if ($strategy !== null) { + return $this->crawlingStrategyFactory->get($strategy); } - return $crawler; + return null; } } diff --git a/Classes/Sitemap/Provider/DefaultProvider.php b/Classes/Sitemap/Provider/DefaultProvider.php index 81cabd56..553fa96a 100644 --- a/Classes/Sitemap/Provider/DefaultProvider.php +++ b/Classes/Sitemap/Provider/DefaultProvider.php @@ -23,9 +23,9 @@ namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; -use EliasHaeussler\Typo3Warming\Sitemap\SiteAwareSitemap; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use TYPO3\CMS\Core; /** * DefaultProvider @@ -33,16 +33,18 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class DefaultProvider extends AbstractProvider +final class DefaultProvider implements Provider { public const DEFAULT_PATH = 'sitemap.xml'; - public function get(Site $site, SiteLanguage $siteLanguage = null): ?SiteAwareSitemap - { - return new SiteAwareSitemap( - $this->getSiteUrlWithPath($site, self::DEFAULT_PATH, $siteLanguage), + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?Sitemap\SiteAwareSitemap { + return new Sitemap\SiteAwareSitemap( + Utility\HttpUtility::getSiteUrlWithPath($site, self::DEFAULT_PATH, $siteLanguage), $site, - $siteLanguage + $siteLanguage ?? $site->getDefaultLanguage(), ); } diff --git a/Classes/Sitemap/Provider/PageTypeProvider.php b/Classes/Sitemap/Provider/PageTypeProvider.php new file mode 100644 index 00000000..1c482570 --- /dev/null +++ b/Classes/Sitemap/Provider/PageTypeProvider.php @@ -0,0 +1,73 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; + +use EliasHaeussler\Typo3Warming\Sitemap; +use TYPO3\CMS\Core; + +/** + * PageTypeProvider + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class PageTypeProvider implements Provider +{ + /** + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/setup.typoscript#L3 + */ + private const EXPECTED_PAGE_TYPE = 1533906435; + + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?Sitemap\SiteAwareSitemap { + // Early return if EXT:seo is not installed + if (!Core\Utility\ExtensionManagementUtility::isLoaded('seo')) { + return null; + } + + $pageTypeMap = $site->getConfiguration()['routeEnhancers']['PageTypeSuffix']['map'] ?? null; + + // Early return if no page type map is configured + if (!\is_array($pageTypeMap) || !\in_array(self::EXPECTED_PAGE_TYPE, $pageTypeMap, true)) { + return null; + } + + $uri = $site->getRouter()->generateUri('/', [ + 'type' => self::EXPECTED_PAGE_TYPE, + '_language' => $siteLanguage, + ]); + + return new Sitemap\SiteAwareSitemap($uri, $site, $siteLanguage ?? $site->getDefaultLanguage()); + } + + /** + * @codeCoverageIgnore + */ + public static function getPriority(): int + { + return 300; + } +} diff --git a/Classes/Sitemap/Provider/ProviderInterface.php b/Classes/Sitemap/Provider/Provider.php similarity index 78% rename from Classes/Sitemap/Provider/ProviderInterface.php rename to Classes/Sitemap/Provider/Provider.php index 7072b903..f7b7238d 100644 --- a/Classes/Sitemap/Provider/ProviderInterface.php +++ b/Classes/Sitemap/Provider/Provider.php @@ -23,19 +23,21 @@ namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; -use EliasHaeussler\Typo3Warming\Sitemap\SiteAwareSitemap; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\Typo3Warming\Sitemap; +use TYPO3\CMS\Core; /** - * ProviderInterface + * Provider * * @author Elias Häußler * @license GPL-2.0-or-later */ -interface ProviderInterface +interface Provider { - public function get(Site $site, SiteLanguage $siteLanguage = null): ?SiteAwareSitemap; + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?Sitemap\SiteAwareSitemap; public static function getPriority(): int; } diff --git a/Classes/Sitemap/Provider/RobotsTxtProvider.php b/Classes/Sitemap/Provider/RobotsTxtProvider.php index 4af444b2..49232188 100644 --- a/Classes/Sitemap/Provider/RobotsTxtProvider.php +++ b/Classes/Sitemap/Provider/RobotsTxtProvider.php @@ -23,12 +23,11 @@ namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; -use EliasHaeussler\Typo3Warming\Sitemap\SiteAwareSitemap; -use Psr\Http\Message\UriInterface; -use TYPO3\CMS\Core\Http\RequestFactory; -use TYPO3\CMS\Core\Http\Uri; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use Exception; +use Psr\Http\Message; +use TYPO3\CMS\Core; /** * RobotsTxtProvider @@ -36,43 +35,46 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class RobotsTxtProvider extends AbstractProvider +final class RobotsTxtProvider implements Provider { private const SITEMAP_PATTERN = '#^Sitemap:\s*(?Phttps?://[^\r\n]+)#im'; - private RequestFactory $requestFactory; - - public function __construct(RequestFactory $requestFactory) - { - $this->requestFactory = $requestFactory; + public function __construct( + private readonly Core\Http\RequestFactory $requestFactory, + ) { } - public function get(Site $site, SiteLanguage $siteLanguage = null): ?SiteAwareSitemap - { - $robotsTxt = $this->fetchRobotsTxt($this->getSiteUrlWithPath($site, 'robots.txt', $siteLanguage)); + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?Sitemap\SiteAwareSitemap { + $robotsTxtUri = Utility\HttpUtility::getSiteUrlWithPath($site, 'robots.txt', $siteLanguage); + $robotsTxt = $this->fetchRobotsTxt($robotsTxtUri); // Early return if no robots.txt exists - if (empty($robotsTxt)) { + if ($robotsTxt === null || trim($robotsTxt) === '') { return null; } // Early return if no sitemap is specified in robots.txt - if (!preg_match(self::SITEMAP_PATTERN, $robotsTxt, $matches)) { + if (preg_match(self::SITEMAP_PATTERN, $robotsTxt, $matches) !== 1) { return null; } - $uri = new Uri($matches['url']); - - return new SiteAwareSitemap($uri, $site, $siteLanguage); + return new Sitemap\SiteAwareSitemap( + new Core\Http\Uri($matches['url']), + $site, + $siteLanguage ?? $site->getDefaultLanguage(), + ); } - private function fetchRobotsTxt(UriInterface $uri): ?string + private function fetchRobotsTxt(Message\UriInterface $uri): ?string { try { $response = $this->requestFactory->request((string)$uri); return $response->getBody()->getContents(); - } catch (\Exception $e) { + } catch (Exception) { return null; } } diff --git a/Classes/Sitemap/Provider/SiteProvider.php b/Classes/Sitemap/Provider/SiteProvider.php index 8bc0c757..1dae3bbd 100644 --- a/Classes/Sitemap/Provider/SiteProvider.php +++ b/Classes/Sitemap/Provider/SiteProvider.php @@ -23,9 +23,9 @@ namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; -use EliasHaeussler\Typo3Warming\Sitemap\SiteAwareSitemap; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\Typo3Warming\Sitemap; +use EliasHaeussler\Typo3Warming\Utility; +use TYPO3\CMS\Core; /** * SiteProvider @@ -33,24 +33,27 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -final class SiteProvider extends AbstractProvider +final class SiteProvider implements Provider { - public function get(Site $site, SiteLanguage $siteLanguage = null): ?SiteAwareSitemap - { + public function get( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?Sitemap\SiteAwareSitemap { if ($siteLanguage !== null && $siteLanguage !== $site->getDefaultLanguage()) { $sitemapPath = $siteLanguage->toArray()['xml_sitemap_path'] ?? null; } else { $sitemapPath = $site->getConfiguration()['xml_sitemap_path'] ?? null; } - if (empty($sitemapPath)) { + // Early return if no sitemap path is configured + if (!\is_string($sitemapPath) || trim($sitemapPath) === '') { return null; } - return new SiteAwareSitemap( - $this->getSiteUrlWithPath($site, $sitemapPath, $siteLanguage), + return new Sitemap\SiteAwareSitemap( + Utility\HttpUtility::getSiteUrlWithPath($site, $sitemapPath, $siteLanguage), $site, - $siteLanguage + $siteLanguage ?? $site->getDefaultLanguage(), ); } diff --git a/Classes/Sitemap/SiteAwareSitemap.php b/Classes/Sitemap/SiteAwareSitemap.php index 2753a868..97b27d65 100644 --- a/Classes/Sitemap/SiteAwareSitemap.php +++ b/Classes/Sitemap/SiteAwareSitemap.php @@ -23,10 +23,9 @@ namespace EliasHaeussler\Typo3Warming\Sitemap; -use EliasHaeussler\CacheWarmup\Sitemap; -use Psr\Http\Message\UriInterface; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\CacheWarmup; +use Psr\Http\Message; +use TYPO3\CMS\Core; /** * SiteAwareSitemap @@ -34,31 +33,23 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -class SiteAwareSitemap extends Sitemap +class SiteAwareSitemap extends CacheWarmup\Sitemap\Sitemap { - protected Site $site; - protected ?SiteLanguage $siteLanguage; - - public function __construct(UriInterface $uri, Site $site, SiteLanguage $siteLanguage = null) - { + public function __construct( + Message\UriInterface $uri, + protected readonly Core\Site\Entity\Site $site, + protected readonly Core\Site\Entity\SiteLanguage $siteLanguage, + ) { parent::__construct($uri); - $this->site = $site; - $this->siteLanguage = $siteLanguage; } - public function getSite(): Site + public function getSite(): Core\Site\Entity\Site { return $this->site; } - public function getSiteLanguage(): ?SiteLanguage + public function getSiteLanguage(): Core\Site\Entity\SiteLanguage { return $this->siteLanguage; } - - public function setSiteLanguage(SiteLanguage $siteLanguage): self - { - $this->siteLanguage = $siteLanguage; - return $this; - } } diff --git a/Classes/Sitemap/SitemapLocator.php b/Classes/Sitemap/SitemapLocator.php index 09e83d2a..03595bac 100644 --- a/Classes/Sitemap/SitemapLocator.php +++ b/Classes/Sitemap/SitemapLocator.php @@ -23,15 +23,11 @@ namespace EliasHaeussler\Typo3Warming\Sitemap; -use EliasHaeussler\Typo3Warming\Cache\CacheManager; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedConfigurationException; -use EliasHaeussler\Typo3Warming\Exception\UnsupportedSiteException; -use EliasHaeussler\Typo3Warming\Sitemap\Provider\ProviderInterface; -use EliasHaeussler\Typo3Warming\Traits\BackendUserAuthenticationTrait; -use TYPO3\CMS\Core\Http\RequestFactory; -use TYPO3\CMS\Core\Http\Uri; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use EliasHaeussler\CacheWarmup; +use EliasHaeussler\Typo3Warming\Cache; +use EliasHaeussler\Typo3Warming\Exception; +use EliasHaeussler\Typo3Warming\Utility; +use TYPO3\CMS\Core; /** * SitemapLocator @@ -41,67 +37,61 @@ */ final class SitemapLocator { - use BackendUserAuthenticationTrait; - - private RequestFactory $requestFactory; - private CacheManager $cacheManager; - /** - * @var iterable + * @param iterable $providers + * @throws Exception\InvalidProviderException */ - private iterable $providers; - - /** - * @param iterable $providers - */ - public function __construct(RequestFactory $requestFactory, CacheManager $cacheManager, iterable $providers) - { - $this->requestFactory = $requestFactory; - $this->cacheManager = $cacheManager; - $this->providers = $providers; - + public function __construct( + private readonly Core\Http\RequestFactory $requestFactory, + private readonly Cache\SitemapsCache $cache, + private readonly iterable $providers, + ) { $this->validateProviders(); } /** - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException */ - public function locateBySite(Site $site, SiteLanguage $siteLanguage = null): SiteAwareSitemap - { + public function locateBySite( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): SiteAwareSitemap { // Get sitemap from cache - if (($sitemapUrl = $this->cacheManager->get($site, $siteLanguage)) !== null) { - return new SiteAwareSitemap(new Uri($sitemapUrl), $site, $siteLanguage); + if (($sitemap = $this->cache->get($site, $siteLanguage)) !== null) { + return $sitemap; } // Build and validate base URL $baseUrl = $siteLanguage !== null ? $siteLanguage->getBase() : $site->getBase(); if ($baseUrl->getHost() === '') { - throw UnsupportedConfigurationException::forBaseUrl((string)$baseUrl); + throw Exception\UnsupportedConfigurationException::forBaseUrl((string)$baseUrl); } // Resolve and validate sitemap $sitemap = $this->resolveSitemap($site, $siteLanguage); if ($sitemap === null) { - throw UnsupportedSiteException::forMissingSitemap($site); + throw Exception\UnsupportedSiteException::forMissingSitemap($site); } // Store resolved sitemap in cache - $this->cacheManager->set($sitemap); + $this->cache->set($sitemap); return $sitemap; } /** * @return array - * @throws UnsupportedConfigurationException - * @throws UnsupportedSiteException + * @throws CacheWarmup\Exception\InvalidUrlException + * @throws Exception\UnsupportedConfigurationException + * @throws Exception\UnsupportedSiteException */ - public function locateAllBySite(Site $site): array + public function locateAllBySite(Core\Site\Entity\Site $site): array { $sitemaps = []; - foreach ($site->getAvailableLanguages(static::getBackendUser()) as $siteLanguage) { + foreach ($site->getAvailableLanguages(Utility\BackendUtility::getBackendUser()) as $siteLanguage) { if ($siteLanguage->isEnabled()) { $sitemaps[$siteLanguage->getLanguageId()] = $this->locateBySite($site, $siteLanguage); } @@ -110,20 +100,25 @@ public function locateAllBySite(Site $site): array return $sitemaps; } - public function siteContainsSitemap(Site $site, SiteLanguage $siteLanguage = null): bool - { + // @todo think about the locate <> contains behavior + public function siteContainsSitemap( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): bool { try { $sitemap = $this->locateBySite($site, $siteLanguage); $response = $this->requestFactory->request((string)$sitemap->getUri(), 'HEAD'); return $response->getStatusCode() < 400; - } catch (\Exception $e) { + } catch (\Exception) { return false; } } - private function resolveSitemap(Site $site, SiteLanguage $siteLanguage = null): ?SiteAwareSitemap - { + private function resolveSitemap( + Core\Site\Entity\Site $site, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): ?SiteAwareSitemap { foreach ($this->providers as $provider) { if (($sitemap = $provider->get($site, $siteLanguage)) !== null) { return $sitemap; @@ -133,24 +128,19 @@ private function resolveSitemap(Site $site, SiteLanguage $siteLanguage = null): return null; } + /** + * @throws Exception\InvalidProviderException + */ private function validateProviders(): void { foreach ($this->providers as $provider) { + /* @phpstan-ignore-next-line */ if (!\is_object($provider)) { - throw new \InvalidArgumentException( - sprintf('Providers must be of type object, "%s" given.', \gettype($provider)), - 1619525071 - ); + throw Exception\InvalidProviderException::forInvalidType($provider); } - if (!\in_array(ProviderInterface::class, class_implements($provider) ?: [])) { - throw new \InvalidArgumentException( - sprintf( - 'The given provider "%s" does not implement the interface "%s".', - \get_class($provider), - ProviderInterface::class - ), - 1619524996 - ); + + if (!is_a($provider, Provider\Provider::class)) { + throw Exception\InvalidProviderException::create($provider); } } } diff --git a/Classes/Utility/AccessUtility.php b/Classes/Utility/AccessUtility.php index 64e0be27..22bcabb8 100644 --- a/Classes/Utility/AccessUtility.php +++ b/Classes/Utility/AccessUtility.php @@ -23,12 +23,9 @@ namespace EliasHaeussler\Typo3Warming\Utility; -use EliasHaeussler\Typo3Warming\Traits\BackendUserAuthenticationTrait; -use TYPO3\CMS\Backend\Utility\BackendUtility; -use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Type\Bitmask\Permission; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Core\Utility\RootlineUtility; +use EliasHaeussler\Typo3Warming\Utility; +use TYPO3\CMS\Backend; +use TYPO3\CMS\Core; /** * AccessUtility @@ -38,25 +35,25 @@ */ final class AccessUtility { - use BackendUserAuthenticationTrait; - public static function canWarmupCacheOfPage(int $pageId, int $languageId = null): bool { return self::checkPagePermissions($pageId, $languageId) && self::isPageAccessible($pageId) - && ($languageId !== null ? self::isLanguageAccessible($languageId) : true); + && ($languageId === null || self::isLanguageAccessible($languageId)) + ; } - public static function canWarmupCacheOfSite(Site $site, int $languageId = null): bool + public static function canWarmupCacheOfSite(Core\Site\Entity\Site $site, int $languageId = null): bool { return self::checkPagePermissions($site->getRootPageId(), $languageId) && self::isSiteAccessible($site->getIdentifier()) - && ($languageId !== null ? self::isLanguageAccessible($languageId) : true); + && ($languageId === null || self::isLanguageAccessible($languageId)) + ; } - private static function checkPagePermissions(int $pageId, int $languageId = null, int $permissions = Permission::PAGE_SHOW): bool + private static function checkPagePermissions(int $pageId, int $languageId = null): bool { - $backendUser = self::getBackendUser(); + $backendUser = Utility\BackendUtility::getBackendUser(); if ($languageId === null && $backendUser->isAdmin()) { return true; @@ -64,9 +61,9 @@ private static function checkPagePermissions(int $pageId, int $languageId = null // Fetch record and record localization (if language is given and is not default language), // additionally check for available pages by adding hidden=0 as additional WHERE clause - $record = BackendUtility::getRecord('pages', $pageId, '*', 'hidden = 0'); + $record = Backend\Utility\BackendUtility::getRecord('pages', $pageId, '*', 'hidden = 0'); if ($languageId !== null && $languageId > 0) { - $record = BackendUtility::getRecordLocalization('pages', $pageId, $languageId, 'hidden = 0'); + $record = Backend\Utility\BackendUtility::getRecordLocalization('pages', $pageId, $languageId, 'hidden = 0'); } // Early return if record is inaccessible @@ -86,19 +83,19 @@ private static function checkPagePermissions(int $pageId, int $languageId = null return false; } - return $backendUser->doesUserHaveAccess($record, $permissions); + return $backendUser->doesUserHaveAccess($record, Core\Type\Bitmask\Permission::PAGE_SHOW); } private static function isPageAccessible(int $pageId): bool { - $backendUser = self::getBackendUser(); + $backendUser = Utility\BackendUtility::getBackendUser(); if ($backendUser->isAdmin()) { return true; } $userTsConfig = $backendUser->getTSConfig(); - $allowedPages = GeneralUtility::trimExplode(',', (string)($userTsConfig['options.']['cacheWarmup.']['allowedPages'] ?? ''), true); + $allowedPages = Core\Utility\GeneralUtility::trimExplode(',', (string)($userTsConfig['options.']['cacheWarmup.']['allowedPages'] ?? ''), true); // Early return if no allowed pages are configured if ($allowedPages === []) { @@ -106,16 +103,15 @@ private static function isPageAccessible(int $pageId): bool } // Fetch rootline of current page id - $rootline = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get(); + $rootline = Core\Utility\GeneralUtility::makeInstance(Core\Utility\RootlineUtility::class, $pageId)->get(); $rootlineIds = array_column($rootline, 'uid'); foreach ($allowedPages as $allowedPage) { $recursiveLookup = str_ends_with($allowedPage, '+'); $normalizedPageId = rtrim($allowedPage, '+'); - // Continue if configured page must not be checked recursively - // or configured page is not numeric - if (!$recursiveLookup || !is_numeric($normalizedPageId)) { + // Continue if configured page is not numeric + if (!is_numeric($normalizedPageId)) { continue; } @@ -125,7 +121,7 @@ private static function isPageAccessible(int $pageId): bool } // Check if current page is in rootline of configured page id - if (\in_array((int)$normalizedPageId, $rootlineIds, true)) { + if ($recursiveLookup && \in_array((int)$normalizedPageId, $rootlineIds, true)) { return true; } } @@ -135,7 +131,7 @@ private static function isPageAccessible(int $pageId): bool private static function isSiteAccessible(string $siteIdentifier): bool { - $backendUser = self::getBackendUser(); + $backendUser = Utility\BackendUtility::getBackendUser(); if ($backendUser->isAdmin()) { return true; @@ -144,12 +140,12 @@ private static function isSiteAccessible(string $siteIdentifier): bool $userTsConfig = $backendUser->getTSConfig(); $allowedSites = (string)($userTsConfig['options.']['cacheWarmup.']['allowedSites'] ?? ''); - return GeneralUtility::inList($allowedSites, $siteIdentifier); + return Core\Utility\GeneralUtility::inList($allowedSites, $siteIdentifier); } private static function isLanguageAccessible(int $languageId): bool { - $backendUser = self::getBackendUser(); + $backendUser = Utility\BackendUtility::getBackendUser(); if ($backendUser->isAdmin()) { return true; diff --git a/Classes/Crawler/UserAgentTrait.php b/Classes/Utility/BackendUtility.php similarity index 60% rename from Classes/Crawler/UserAgentTrait.php rename to Classes/Utility/BackendUtility.php index 8e6c6030..fe5a61cc 100644 --- a/Classes/Crawler/UserAgentTrait.php +++ b/Classes/Utility/BackendUtility.php @@ -21,32 +21,31 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Crawler; +namespace EliasHaeussler\Typo3Warming\Utility; -use EliasHaeussler\Typo3Warming\Configuration\Configuration; -use GuzzleHttp\Psr7\Request; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core; /** - * UserAgentTrait + * BackendUtility * * @author Elias Häußler * @license GPL-2.0-or-later */ -trait UserAgentTrait +final class BackendUtility { - protected function applyUserAgentHeader(Request $request): Request + public static function getBackendUser(): Core\Authentication\BackendUserAuthentication { - /** @var Request $request */ - $request = $request->withAddedHeader('User-Agent', $this->getUserAgent()); + /** @var Core\Authentication\BackendUserAuthentication $backendUser */ + $backendUser = $GLOBALS['BE_USER']; - return $request; + return $backendUser; } - protected function getUserAgent(): string + public static function getLanguageService(): Core\Localization\LanguageService { - $configuration = GeneralUtility::makeInstance(Configuration::class); + /** @var Core\Localization\LanguageService $languageService */ + $languageService = $GLOBALS['LANG']; - return $configuration->getUserAgent(); + return $languageService; } } diff --git a/Classes/Utility/HttpUtility.php b/Classes/Utility/HttpUtility.php new file mode 100644 index 00000000..3cf7c3d9 --- /dev/null +++ b/Classes/Utility/HttpUtility.php @@ -0,0 +1,83 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\Utility; + +use Psr\Http\Message; +use TYPO3\CMS\Core; + +/** + * HttpUtility + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class HttpUtility +{ + public static function getSiteUrlWithPath( + Core\Site\Entity\Site $site, + string $path, + Core\Site\Entity\SiteLanguage $siteLanguage = null, + ): Message\UriInterface { + $baseUrl = $siteLanguage !== null ? $siteLanguage->getBase() : $site->getBase(); + $fullPath = rtrim($baseUrl->getPath(), '/') . '/' . ltrim($path, '/'); + + if (str_contains($fullPath, '?')) { + [$fullPath, $queryString] = explode('?', $fullPath, 2); + $baseUrl = $baseUrl->withQuery($queryString); + } + + return $baseUrl->withPath($fullPath); + } + + /** + * @throws Core\Exception\SiteNotFoundException + */ + public static function generateUri(int $pageId, int $languageId = null): ?Message\UriInterface + { + $pageRepository = Core\Utility\GeneralUtility::makeInstance(Core\Domain\Repository\PageRepository::class); + $siteFinder = Core\Utility\GeneralUtility::makeInstance(Core\Site\SiteFinder::class); + $page = $pageRepository->getPage($pageId); + + // Early return if page does not exist + if ($page === []) { + return null; + } + + // Resolve site and site language + $site = $siteFinder->getSiteByPageId($pageId); + $siteLanguage = $languageId !== null ? $site->getLanguageById($languageId) : $site->getDefaultLanguage(); + + // Check if page is suitable for language + if ($languageId > 0) { + $languageAspect = Core\Context\LanguageAspectFactory::createFromSiteLanguage($siteLanguage); + $page = $pageRepository->getLanguageOverlay('pages', $page, $languageAspect); + + if ($page === null || !$pageRepository->isPageSuitableForLanguage($page, $languageAspect)) { + return null; + } + } + + return $site->getRouter()->generateUri((string)$pageId, ['_language' => $siteLanguage]); + } +} diff --git a/Classes/ValueObject/Modal/SiteGroup.php b/Classes/ValueObject/Modal/SiteGroup.php new file mode 100644 index 00000000..20c9a964 --- /dev/null +++ b/Classes/ValueObject/Modal/SiteGroup.php @@ -0,0 +1,93 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\ValueObject\Modal; + +use TYPO3\CMS\Core; + +/** + * SiteGroup + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class SiteGroup +{ + /** + * @param list $items + */ + public function __construct( + private readonly Core\Site\Entity\Site $site, + private readonly string $title, + private readonly string $iconIdentifier, + private readonly array $items, + ) { + } + + public function getSite(): Core\Site\Entity\Site + { + return $this->site; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getIconIdentifier(): string + { + return $this->iconIdentifier; + } + + /** + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + public function hasOnlyDefaultLanguage(): bool + { + if (\count($this->items) > 1) { + return false; + } + + foreach ($this->items as $item) { + return $item->isDefaultLanguage(); + } + + return false; + } + + public function isMissing(): bool + { + foreach ($this->items as $item) { + if (!$item->isMissing()) { + return false; + } + } + + return true; + } +} diff --git a/Classes/Traits/ViewTrait.php b/Classes/ValueObject/Modal/SiteGroupItem.php similarity index 57% rename from Classes/Traits/ViewTrait.php rename to Classes/ValueObject/Modal/SiteGroupItem.php index a8e9cd84..18e54074 100644 --- a/Classes/Traits/ViewTrait.php +++ b/Classes/ValueObject/Modal/SiteGroupItem.php @@ -21,28 +21,42 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Traits; +namespace EliasHaeussler\Typo3Warming\ValueObject\Modal; -use EliasHaeussler\Typo3Warming\Configuration\Extension; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Fluid\View\StandaloneView; +use TYPO3\CMS\Core; /** - * ViewTrait + * SiteGroupItem * * @author Elias Häußler * @license GPL-2.0-or-later */ -trait ViewTrait +final class SiteGroupItem { - protected function buildView(string $filename): StandaloneView + public function __construct( + private readonly Core\Site\Entity\SiteLanguage $language, + private readonly bool $defaultLanguage, + private readonly ?string $url = null, + ) { + } + + public function getLanguage(): Core\Site\Entity\SiteLanguage + { + return $this->language; + } + + public function isDefaultLanguage(): bool { - $view = GeneralUtility::makeInstance(StandaloneView::class); - $view->setTemplateRootPaths(['EXT:warming/Resources/Private/Templates']); - $view->setPartialRootPaths(['EXT:warming/Resources/Private/Partials']); - $view->setTemplate($filename); - $view->getRequest()->setControllerExtensionName(Extension::NAME); + return $this->defaultLanguage; + } - return $view; + public function getUrl(): ?string + { + return $this->url; + } + + public function isMissing(): bool + { + return $this->url === null; } } diff --git a/Classes/ValueObject/Request/PageWarmupRequest.php b/Classes/ValueObject/Request/PageWarmupRequest.php new file mode 100644 index 00000000..5e5e9e57 --- /dev/null +++ b/Classes/ValueObject/Request/PageWarmupRequest.php @@ -0,0 +1,59 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\ValueObject\Request; + +/** + * PageWarmupRequest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class PageWarmupRequest +{ + /** + * @param positive-int $page + * @param list> $languageIds + */ + public function __construct( + private readonly int $page, + private readonly array $languageIds = [], + ) { + } + + /** + * @return positive-int + */ + public function getPage(): int + { + return $this->page; + } + + /** + * @return list> + */ + public function getLanguageIds(): array + { + return $this->languageIds; + } +} diff --git a/Tests/Hooks/BypassFinalHook.php b/Classes/ValueObject/Request/RequestConfiguration.php similarity index 68% rename from Tests/Hooks/BypassFinalHook.php rename to Classes/ValueObject/Request/RequestConfiguration.php index 373d044c..8ef563f5 100644 --- a/Tests/Hooks/BypassFinalHook.php +++ b/Classes/ValueObject/Request/RequestConfiguration.php @@ -21,24 +21,29 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Tests\Hooks; - -use DG\BypassFinals; -use PHPUnit\Runner\BeforeTestHook; +namespace EliasHaeussler\Typo3Warming\ValueObject\Request; /** - * BypassFinalHook + * RequestConfiguration * * @author Elias Häußler * @license GPL-2.0-or-later */ -final class BypassFinalHook implements BeforeTestHook +final class RequestConfiguration { - /** - * @see https://tomasvotruba.com/blog/2019/03/28/how-to-mock-final-classes-in-phpunit/ - */ - public function executeBeforeTest(string $test): void + public function __construct( + private readonly ?int $limit = null, + private readonly ?string $strategy = null, + ) { + } + + public function getLimit(): ?int + { + return $this->limit; + } + + public function getStrategy(): ?string { - BypassFinals::enable(); + return $this->strategy; } } diff --git a/Classes/Sitemap/Provider/AbstractProvider.php b/Classes/ValueObject/Request/SiteWarmupRequest.php similarity index 57% rename from Classes/Sitemap/Provider/AbstractProvider.php rename to Classes/ValueObject/Request/SiteWarmupRequest.php index 1d358432..4edc825c 100644 --- a/Classes/Sitemap/Provider/AbstractProvider.php +++ b/Classes/ValueObject/Request/SiteWarmupRequest.php @@ -21,30 +21,44 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\Sitemap\Provider; +namespace EliasHaeussler\Typo3Warming\ValueObject\Request; -use Psr\Http\Message\UriInterface; use TYPO3\CMS\Core\Site\Entity\Site; -use TYPO3\CMS\Core\Site\Entity\SiteLanguage; /** - * AbstractProvider + * SiteWarmupRequest * * @author Elias Häußler * @license GPL-2.0-or-later */ -abstract class AbstractProvider implements ProviderInterface +final class SiteWarmupRequest { - protected function getSiteUrlWithPath(Site $site, string $path, SiteLanguage $siteLanguage = null): UriInterface + /** + * @param list> $languageIds + */ + public function __construct( + private readonly Site $site, + private readonly array $languageIds = [], + ) { + } + + public function getSite(): Site + { + return $this->site; + } + + /** + * @return non-empty-list> + */ + public function getLanguageIds(): array { - $baseUrl = $siteLanguage !== null ? $siteLanguage->getBase() : $site->getBase(); - $fullPath = rtrim($baseUrl->getPath(), '/') . '/' . ltrim($path, '/'); + if ($this->languageIds === []) { + /** @var int<0, max> $languageId */ + $languageId = $this->site->getDefaultLanguage()->getLanguageId(); - if (str_contains($fullPath, '?')) { - [$fullPath, $queryString] = explode('?', $fullPath, 2); - $baseUrl = $baseUrl->withQuery($queryString); + return [$languageId]; } - return $baseUrl->withPath($fullPath); + return $this->languageIds; } } diff --git a/Classes/ValueObject/Request/WarmupRequest.php b/Classes/ValueObject/Request/WarmupRequest.php new file mode 100644 index 00000000..2e87ce6d --- /dev/null +++ b/Classes/ValueObject/Request/WarmupRequest.php @@ -0,0 +1,79 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\ValueObject\Request; + +/** + * WarmupRequest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class WarmupRequest +{ + /** + * @var non-empty-string + */ + private readonly string $id; + + /** + * @param list $sites + * @param list $pages + */ + public function __construct( + private readonly array $sites = [], + private readonly array $pages = [], + private readonly RequestConfiguration $configuration = new RequestConfiguration(), + ) { + $this->id = uniqid('_', true); + } + + /** + * @return non-empty-string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @return list + */ + public function getSites(): array + { + return $this->sites; + } + + /** + * @return list + */ + public function getPages(): array + { + return $this->pages; + } + + public function getConfiguration(): RequestConfiguration + { + return $this->configuration; + } +} diff --git a/Classes/View/TemplateRenderer.php b/Classes/View/TemplateRenderer.php new file mode 100644 index 00000000..4f291d68 --- /dev/null +++ b/Classes/View/TemplateRenderer.php @@ -0,0 +1,49 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace EliasHaeussler\Typo3Warming\View; + +use EliasHaeussler\Typo3Warming\Extension; +use TYPO3\CMS\Core; +use TYPO3\CMS\Fluid; + +/** + * TemplateRenderer + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +final class TemplateRenderer +{ + /** + * @param array $variables + */ + public function render(string $templatePath, array $variables = []): string + { + $view = Core\Utility\GeneralUtility::makeInstance(Fluid\View\StandaloneView::class); + $view->getTemplatePaths()->fillDefaultsByPackageName(Extension::KEY); + $view->assignMultiple($variables); + + return $view->render($templatePath); + } +} diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php index ba509a4d..2e5cf80a 100644 --- a/Configuration/Backend/AjaxRoutes.php +++ b/Configuration/Backend/AjaxRoutes.php @@ -22,14 +22,14 @@ return [ 'tx_warming_cache_warmup' => [ 'path' => '/warming/cache-warmup', - 'target' => \EliasHaeussler\Typo3Warming\Controller\CacheWarmupController::class . '::mainAction', + 'target' => \EliasHaeussler\Typo3Warming\Controller\CacheWarmupController::class, ], 'tx_warming_cache_warmup_legacy' => [ 'path' => '/warming/cache-warmup-legacy', - 'target' => \EliasHaeussler\Typo3Warming\Controller\CacheWarmupController::class . '::legacyWarmupAction', + 'target' => \EliasHaeussler\Typo3Warming\Controller\CacheWarmupLegacyController::class, ], 'tx_warming_fetch_sites' => [ 'path' => '/warming/fetch-sites', - 'target' => \EliasHaeussler\Typo3Warming\Controller\CacheWarmupController::class . '::fetchSitesAction', + 'target' => \EliasHaeussler\Typo3Warming\Controller\FetchSitesController::class, ], ]; diff --git a/Configuration/Icons.php b/Configuration/Icons.php new file mode 100644 index 00000000..f97ed73e --- /dev/null +++ b/Configuration/Icons.php @@ -0,0 +1,37 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +return [ + 'cache-warmup-page' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:warming/Resources/Public/Icons/cache-warmup-page.svg', + ], + 'cache-warmup-site' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:warming/Resources/Public/Icons/cache-warmup-site.svg', + ], + 'cache-warmup-toolbar' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:warming/Resources/Public/Icons/cache-warmup-toolbar.svg', + ], +]; diff --git a/Resources/Private/Frontend/src/scripts/lib/Enums/WarmupRequestType.ts b/Configuration/JavaScriptModules.php similarity index 70% rename from Resources/Private/Frontend/src/scripts/lib/Enums/WarmupRequestType.ts rename to Configuration/JavaScriptModules.php index 769bfce1..d762b795 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Enums/WarmupRequestType.ts +++ b/Configuration/JavaScriptModules.php @@ -1,9 +1,11 @@ -'use strict' + + * Copyright (C) 2023 Elias Häußler * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,15 +21,12 @@ * along with this program. If not, see . */ -/** - * Concrete request types that can handle cache warmup. - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -enum WarmupRequestType { - EventSource, - Ajax, -} - -export default WarmupRequestType; +return [ + 'dependencies' => [ + 'backend', + 'core', + ], + 'imports' => [ + '@eliashaeussler/typo3-warming/' => 'EXT:warming/Resources/Public/JavaScript/', + ], +]; diff --git a/Configuration/Services.php b/Configuration/Services.php index 042b01f4..ab59be3c 100644 --- a/Configuration/Services.php +++ b/Configuration/Services.php @@ -21,15 +21,20 @@ * along with this program. If not, see . */ -namespace EliasHaeussler\Typo3Warming\DependencyInjection; - +use EliasHaeussler\CacheWarmup; use EliasHaeussler\Typo3Warming\Sitemap; -use Symfony\Component\DependencyInjection as SymfonyDI; +use Symfony\Component\DependencyInjection; return static function ( - SymfonyDI\Loader\Configurator\ContainerConfigurator $containerConfigurator, - SymfonyDI\ContainerBuilder $container + DependencyInjection\Loader\Configurator\ContainerConfigurator $containerConfigurator, + DependencyInjection\ContainerBuilder $container, ): void { - $container->registerForAutoconfiguration(Sitemap\Provider\ProviderInterface::class) + $container->registerForAutoconfiguration(CacheWarmup\Crawler\Strategy\CrawlingStrategy::class) + ->addTag('warming.crawling_strategy'); + $container->registerForAutoconfiguration(Sitemap\Provider\Provider::class) ->addTag('warming.sitemap_provider'); + + // External services + $services = $containerConfigurator->services(); + $services->set(CacheWarmup\Crawler\CrawlerFactory::class); }; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 5344c719..847f8c16 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -6,20 +6,36 @@ services: EliasHaeussler\Typo3Warming\: resource: '../Classes/*' + exclude: + - '../Classes/DependencyInjection/*' + - '../Classes/Enums/*' + - '../Classes/Exception/*' + - '../Classes/Http/Message/Event/*' + - '../Classes/Http/Message/EventStream.php' + - '../Classes/Http/Message/Handler/*' + - '../Classes/Result/*' + - '../Classes/Sitemap/SiteAwareSitemap.php' + - '../Classes/ValueObject/*' - EliasHaeussler\Typo3Warming\Backend\ToolbarItems\CacheWarmupToolbarItem: - public: true - EliasHaeussler\Typo3Warming\Cache\CacheManager: + EliasHaeussler\Typo3Warming\Cache\SitemapsCache: arguments: - $cache: '@cache.core' + $cache: '@cache.warming' EliasHaeussler\Typo3Warming\Configuration\Configuration: public: true EliasHaeussler\Typo3Warming\Controller\CacheWarmupController: public: true + EliasHaeussler\Typo3Warming\Controller\CacheWarmupLegacyController: + public: true + EliasHaeussler\Typo3Warming\Controller\FetchSitesController: + public: true + EliasHaeussler\Typo3Warming\Crawler\Strategy\CrawlingStrategyFactory: + arguments: + $strategies: !tagged_locator { tag: 'warming.crawling_strategy', default_index_method: 'getName' } + EliasHaeussler\Typo3Warming\Http\Client\ClientFactory: + public: true EliasHaeussler\Typo3Warming\Service\CacheWarmupService: public: true EliasHaeussler\Typo3Warming\Sitemap\SitemapLocator: - public: true arguments: $providers: !tagged_iterator { tag: 'warming.sitemap_provider', default_priority_method: 'getPriority' } @@ -35,6 +51,16 @@ services: command: warming:cachewarmup description: 'Warm up Frontend caches of single pages and/or whole sites using their XML sitemaps.' - cache.core: + CuyZ\Valinor\Mapper\TreeMapper: + factory: ['@EliasHaeussler\Typo3Warming\Mapper\MapperFactory', 'get'] + + # Default crawling strategies + EliasHaeussler\CacheWarmup\Crawler\Strategy\SortByChangeFrequencyStrategy: + EliasHaeussler\CacheWarmup\Crawler\Strategy\SortByLastModificationDateStrategy: + EliasHaeussler\CacheWarmup\Crawler\Strategy\SortByPriorityStrategy: + + cache.warming: + class: 'TYPO3\CMS\Core\Cache\Frontend\PhpFrontend' factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] - arguments: ['core'] + arguments: + - !php/const EliasHaeussler\Typo3Warming\Extension::KEY diff --git a/Configuration/SiteConfiguration/Overrides/site.php b/Configuration/SiteConfiguration/Overrides/site.php index 6b89cf25..4c5a1738 100644 --- a/Configuration/SiteConfiguration/Overrides/site.php +++ b/Configuration/SiteConfiguration/Overrides/site.php @@ -20,8 +20,8 @@ */ $GLOBALS['SiteConfiguration']['site']['columns']['xml_sitemap_path'] = [ - 'label' => 'LLL:EXT:warming/Resources/Private/Language/locallang_db.xlf:sites.xml_sitemap_path.label', - 'description' => 'LLL:EXT:warming/Resources/Private/Language/locallang_db.xlf:sites.xml_sitemap_path.description', + 'label' => \EliasHaeussler\Typo3Warming\Configuration\Localization::translate('sites.xml_sitemap_path.label', type: 'db'), + 'description' => \EliasHaeussler\Typo3Warming\Configuration\Localization::translate('sites.xml_sitemap_path.description', type: 'db'), 'config' => [ 'type' => 'input', 'valuePicker' => [ @@ -39,5 +39,5 @@ $GLOBALS['SiteConfiguration']['site']['types']['0']['showitem'] = str_replace( 'base,', 'base, xml_sitemap_path,', - $GLOBALS['SiteConfiguration']['site']['types']['0']['showitem'] + (string)$GLOBALS['SiteConfiguration']['site']['types']['0']['showitem'], ); diff --git a/Configuration/SiteConfiguration/Overrides/site_language.php b/Configuration/SiteConfiguration/Overrides/site_language.php index ab80e5e8..42875f7b 100644 --- a/Configuration/SiteConfiguration/Overrides/site_language.php +++ b/Configuration/SiteConfiguration/Overrides/site_language.php @@ -20,8 +20,8 @@ */ $GLOBALS['SiteConfiguration']['site_language']['columns']['xml_sitemap_path'] = [ - 'label' => 'LLL:EXT:warming/Resources/Private/Language/locallang_db.xlf:sites.xml_sitemap_path.label', - 'description' => 'LLL:EXT:warming/Resources/Private/Language/locallang_db.xlf:sites.xml_sitemap_path.description', + 'label' => \EliasHaeussler\Typo3Warming\Configuration\Localization::translate('sites.xml_sitemap_path.label', type: 'db'), + 'description' => \EliasHaeussler\Typo3Warming\Configuration\Localization::translate('sites.xml_sitemap_path.description', type: 'db'), 'displayCond' => 'FIELD:languageId:>:0', 'config' => [ 'type' => 'input', @@ -40,5 +40,5 @@ $GLOBALS['SiteConfiguration']['site_language']['types']['1']['showitem'] = str_replace( '--palette--;;default,', '--palette--;;default, xml_sitemap_path,', - $GLOBALS['SiteConfiguration']['site_language']['types']['1']['showitem'] + (string)$GLOBALS['SiteConfiguration']['site_language']['types']['1']['showitem'], ); diff --git a/Documentation/Configuration/ExtensionConfiguration.rst b/Documentation/Configuration/ExtensionConfiguration.rst index fdfdc9a5..21202203 100644 --- a/Documentation/Configuration/ExtensionConfiguration.rst +++ b/Documentation/Configuration/ExtensionConfiguration.rst @@ -8,19 +8,10 @@ Extension configuration The extension currently provides the following configuration options: -.. _extconf-limit: - -.. confval:: limit - - :type: integer - :Default: 250 +.. _extension-configuration-crawler: - Allows to limit the amount of crawled pages in one iteration. - - .. tip:: - - Can be set to :typoscript:`0` to crawl all available pages in - XML sitemaps. +Crawler +======= .. _extconf-crawler: @@ -67,11 +58,62 @@ The extension currently provides the following configuration options: :type: string (JSON) - JSON-encoded string of custom crawler options for the verbose - :ref:`crawler `. Applies only to crawlers implementing the - :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\ConfigurableCrawlerInterface`. + JSON-encoded string of custom crawler options for the + :ref:`verbose crawler `. Applies only to crawlers implementing + the :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\ConfigurableCrawlerInterface`. For more information read :ref:`configurable-crawlers`. +.. _extension-configuration-options: + +Options +======= + +.. _extconf-limit: + +.. confval:: limit + + :type: integer + :Default: 250 + + Allows to limit the number of crawled pages in one iteration. + + .. tip:: + + Can be set to :typoscript:`0` to crawl all available pages in + XML sitemaps. + +.. _extconf-exclude: + +.. confval:: exclude + + :type: string (comma-separated list) + + Comma-separated list of exclude patterns to exclude URLs from cache + warmup. The following formats are currently supported: + + - Regular expressions with delimiter :php:`#`, e.g. :php:`#(no_cache|no_warming)=1#` + - Any pattern processable by the native PHP function `fnmatch `__, + e.g. :php:`*no_cache=1*` + +.. _extconf-strategy: + +.. confval:: strategy + + :type: string + + Name of an available crawling strategy to use for cache warmup. Crawling + strategies are used to prepare URLs before actually crawling them. This can + be helpful to prioritize crawling of important URLs. + + .. seealso:: + + Read more at :ref:`crawling-strategies`. + +.. _extension-configuration-page-tree: + +Page tree +========= + .. _extconf-enablePageTree: .. confval:: enablePageTree @@ -79,8 +121,8 @@ The extension currently provides the following configuration options: :type: boolean :Default: 1 - Enable cache warmup in the :ref:`page tree ` context menu. This affects - all users, including administrators. + Enable cache warmup in the :ref:`page tree ` context menu. This setting + affects all users, including administrators. .. _extconf-supportedDoktypes: @@ -94,6 +136,11 @@ The extension currently provides the following configuration options: :php:`1` only. If your project implements custom doktypes, you can add them here to support cache warmup from the context menu. +.. _extension-configuration-toolbar: + +Toolbar +======= + .. _extconf-enableToolbar: .. confval:: enableToolbar @@ -101,5 +148,5 @@ The extension currently provides the following configuration options: :type: boolean :Default: 1 - Enable cache warmup in the :ref:`backend toolbar `. This affects - all users, including administrators. + Enable cache warmup in the :ref:`backend toolbar `. This setting + affects all users, including administrators. diff --git a/Documentation/Configuration/SiteConfiguration.rst b/Documentation/Configuration/SiteConfiguration.rst index c48db01a..0aaa5de9 100644 --- a/Documentation/Configuration/SiteConfiguration.rst +++ b/Documentation/Configuration/SiteConfiguration.rst @@ -11,9 +11,9 @@ Site configuration Caches can be warmed up only if the entry point of the site contains the full domain name. -The cache warmup is based of the configured sites in the TYPO3 installation. -Therefore, in order to control the cache warmup behavior, the site configuration can -be used. The following configuration options are available for this purpose: +Cache warmup is based on the configured sites in the TYPO3 installation. Therefore, +in order to control the cache warmup behavior, the site configuration can be used. +The following configuration options are available for this purpose: .. confval:: xml_sitemap_path (site) diff --git a/Documentation/Contributing/Index.rst b/Documentation/Contributing/Index.rst index 7e2b9663..c0b2e344 100644 --- a/Documentation/Contributing/Index.rst +++ b/Documentation/Contributing/Index.rst @@ -14,7 +14,9 @@ The development of this extension follows the official `TYPO3 coding standards `__. To ensure the stability and cleanliness of the code, various code quality tools are used and most components are covered with test -cases. +cases. In addition, we use `DDEV `__ +for local development. Make sure to set it up as described below. For +continuous integration, we use GitHub Actions. .. _create-an-issue-first: @@ -48,17 +50,26 @@ Clone the repository first: git clone https://github.com/eliashaeussler/typo3-warming.git cd typo3-warming -Now install all Composer dependencies: +Now start DDEV: .. code-block:: bash - composer install + ddev start -Next, install all Node dependencies: +Next, install all dependencies: .. code-block:: bash - yarn --cwd Resources/Private/Frontend + ddev composer install + ddev frontend install + +You can access the DDEV site at https://typo3-ext-warming.ddev.site/. + +.. tip:: + + There's also a dedicated DDEV command to manage TER libraries located at + :file:`Resources/Private/Libs/Build`. Run :bash:`ddev libs ` with + any available Composer command, e.g. :bash:`ddev libs install`. .. _check-code-quality: @@ -75,20 +86,27 @@ TYPO3 .. code-block:: bash - # Run all linters - composer lint + # All linters + ddev composer lint + + # Specific linters + ddev composer lint:composer + ddev composer lint:editorconfig + ddev composer lint:php - # Run Composer linter only - composer lint:composer + # Fix all CGL issues + ddev composer fix - # Run PHP linter only - composer lint:php + # Fix specific CGL issues + ddev composer fix:composer + ddev composer fix:editorconfig + ddev composer fix:php - # Run TypoScript linter only - composer lint:typoscript + # All static code analyzers + ddev composer sca - # Run PHP static code analysis - composer sca + # Specific static code analyzers + ddev composer sca:php .. _cgl-frontend: @@ -97,17 +115,19 @@ Frontend .. code-block:: bash - # Run all linters - yarn --cwd Resources/Private/Frontend lint - yarn --cwd Resources/Private/Frontend lint:fix + # All linters + ddev frontend lint - # Run SCSS linter only - yarn --cwd Resources/Private/Frontend lint:scss - yarn --cwd Resources/Private/Frontend lint:scss:fix + # Specific linters + ddev frontend lint:scss + ddev frontend lint:ts - # Run TypeScript linter only - yarn --cwd Resources/Private/Frontend lint:ts - yarn --cwd Resources/Private/Frontend lint:ts:fix + # Fix all CGL issues + ddev frontend fix + + # Fix specific CGL issues + ddev frontend fix:scss + ddev frontend fix:ts .. _run-tests: @@ -117,20 +137,36 @@ Run tests .. image:: https://github.com/eliashaeussler/typo3-warming/actions/workflows/tests.yaml/badge.svg :target: https://github.com/eliashaeussler/typo3-warming/actions/workflows/tests.yaml +.. rst-class:: d-inline-block mb-3 + .. image:: https://codecov.io/gh/eliashaeussler/typo3-warming/branch/main/graph/badge.svg?token=7M3UXACCKA :target: https://codecov.io/gh/eliashaeussler/typo3-warming -.. rst-class:: mt-3 - .. code-block:: bash - # Run tests - composer test + # All tests + ddev composer test - # Run tests with code coverage - composer test:ci + # Specific tests + ddev composer test:functional + ddev composer test:unit -The code coverage reports will be stored in :file:`.Build/log/coverage`. + # All tests with code coverage + ddev composer test:coverage + + # Specific tests with code coverage + ddev composer test:coverage:functional + ddev composer test:coverage:unit + + # Merge code coverage of all test suites + ddev composer test:coverage:merge + +Code coverage reports are written to :file:`.Build/coverage`. You can +open the last merged HTML report like follows: + +.. code-block:: bash + + open .Build/coverage/html/_merged/index.html .. _build-documentation: @@ -155,5 +191,10 @@ The built docs will be stored in :file:`.Build/docs`. Pull Request ------------ -When you have finished developing your contribution, simply submit a -pull request on GitHub: https://github.com/eliashaeussler/typo3-warming/pulls +Once you have finished your work, please **submit a pull request** and describe +what you've done: https://github.com/eliashaeussler/typo3-warming/pulls + +Ideally, your PR references an issue describing the problem +you're trying to solve. All described code quality tools are automatically +executed on each pull request for all currently supported PHP versions and TYPO3 +versions. diff --git a/Documentation/DeveloperCorner/AccessUtility.rst b/Documentation/DeveloperCorner/AccessUtility.rst index c2897760..3addd51e 100644 --- a/Documentation/DeveloperCorner/AccessUtility.rst +++ b/Documentation/DeveloperCorner/AccessUtility.rst @@ -22,6 +22,7 @@ caches of specific sites or pages. :param int $pageId: ID of the page to be checked. :param int $languageId: Optional language ID to be included in the check. + :returntype: bool .. php:method:: canWarmupCacheOfSite($site, $languageId = null) @@ -29,6 +30,7 @@ caches of specific sites or pages. :param TYPO3\\CMS\\Core\\Site\\Entity\\Site $site: The site to be checked. :param int $languageId: Optional language ID to be included in the check. + :returntype: bool .. seealso:: diff --git a/Documentation/DeveloperCorner/Caching.rst b/Documentation/DeveloperCorner/Caching.rst index 697038e8..488aad9f 100644 --- a/Documentation/DeveloperCorner/Caching.rst +++ b/Documentation/DeveloperCorner/Caching.rst @@ -8,27 +8,26 @@ Caching Once a sitemap is located by a :ref:`sitemap provider `, the path to the XML sitemap is cached. This speeds up following -warmup requests. Caching happens with the `core` cache which defaults -to a filesystem cached located at :file:`var/cache/code/core/tx_warming.php`. +warmup requests. Caching happens with a custom `warming` cache +which defaults to a filesystem cache located at :file:`var/cache/code/warming/sitemaps.php`. .. php:namespace:: EliasHaeussler\Typo3Warming\Cache -.. php:class:: CacheManager +.. php:class:: SitemapsCache - Manager to read and write the core cache `tx_warming`. + Read and write sitemap cache entries from custom `warming` cache. - .. php:method:: get($site = null, $siteLanguage = null) + .. php:method:: get($site, $siteLanguage = null) - Get all located sitemaps or the located sitemap of a given site - and/or site language. + Get the located sitemap of a given site. - :param TYPO3\\CMS\\Core\\Site\\Entity\\Site $site: The sitemap's site object or :php:`NULL` to lookup all sitemaps. - :param TYPO3\\CMS\\Core\\Site\\Entity\\SiteLanguage $siteLanguage: An optional site language - :returns: Either an array of all located sitemaps or the located sitemap of a given site. + :param TYPO3\\CMS\\Core\\Site\\Entity\\Site $site: The sitemap's site object. + :param TYPO3\\CMS\\Core\\Site\\Entity\\SiteLanguage $siteLanguage: An optional site language. + :returns: Located sitemap of a given site. .. php:method:: set($sitemap) - Add the located sitemap to the `tx_warming` cache. + Add the located sitemap to the `warming` cache. :param EliasHaeussler\\Typo3Warming\\Sitemap\\SiteAwareSitemap $sitemap: The located sitemap to be cached. @@ -36,4 +35,4 @@ to a filesystem cached located at :file:`var/cache/code/core/tx_warming.php`. View the sources on GitHub: - - `CacheManager `__ + - `SitemapsCache `__ diff --git a/Documentation/DeveloperCorner/ConfigurationAPI.rst b/Documentation/DeveloperCorner/ConfigurationAPI.rst index c32a3d21..341419b1 100644 --- a/Documentation/DeveloperCorner/ConfigurationAPI.rst +++ b/Documentation/DeveloperCorner/ConfigurationAPI.rst @@ -16,17 +16,11 @@ an appropriate class method. API to access all available extension configuration options. - .. php:method:: getLimit() - - Get the configured :ref:`crawler limit `. - - :returntype: int - .. php:method:: getCrawler() Get the configured :ref:`crawler class `. - :returntype: class-string + :returntype: :php:`class-string` .. php:method:: getCrawlerOptions() @@ -38,7 +32,7 @@ an appropriate class method. Get the configured :ref:`verbose crawler class `. - :returntype: class-string + :returntype: :php:`class-string` .. php:method:: getVerboseCrawlerOptions() @@ -46,17 +40,48 @@ an appropriate class method. :returntype: array - .. php:method:: getUserAgent() + .. php:method:: getLimit() - Get the calculated user-agent. + Get the configured :ref:`crawler limit `. - :returntype: string + :returntype: int - .. php:method:: getAll() + .. php:method:: getExcludePatterns() - Get all extension configuration options of this extension. + Get the configured :ref:`exclude patterns `. - :returntype: array + :returntype: :php:`list` + + .. php:method:: getStrategy() + + Get the configured :ref:`crawling strategy `. + + :returntype: string|null + + .. php:method:: isEnabledInPageTree() + + Check whether cache warmup from :ref:`page tree ` is enabled. + + :returntype: bool + + .. php:method:: getSupportedDoktypes() + + Get all :ref:`doktypes ` that support cache warmup from + page tree. + + :returntype: :php:`list` + + .. php:method:: isEnabledInToolbar() + + Check whether cache warmup from :ref:`toolbar ` is enabled. + + :returntype: bool + + .. php:method:: getUserAgent() + + Get the calculated user-agent. + + :returntype: string .. seealso:: diff --git a/Documentation/DeveloperCorner/Crawlers.rst b/Documentation/DeveloperCorner/Crawlers.rst index 7bd6bbc3..73b4ba01 100644 --- a/Documentation/DeveloperCorner/Crawlers.rst +++ b/Documentation/DeveloperCorner/Crawlers.rst @@ -22,18 +22,7 @@ implement a custom crawler on this page. Crawl a given list of URLs. :param array $urls: List of URLs to be crawled. - - .. php:method:: getSuccessfulUrls() - - Return all successfully crawled URLs. - - :returns: A list of :php:class:`EliasHaeussler\\CacheWarmup\\CrawlingState` instances - - .. php:method:: getFailedUrls() - - Return all failing crawled URLs. - - :returns: A list of :php:class:`EliasHaeussler\\CacheWarmup\\CrawlingState` instances + :returntype: EliasHaeussler\\CacheWarmup\\Result\\CacheWarmupResult .. _default-crawlers: @@ -53,10 +42,9 @@ warmup requests from the statistics of analysis tools, for example. The header is generated by a HMAC hash of the string `TYPO3/tx_warming_crawler`. -The generated header value can be copied in the dropdown of the -toolbar item in the backend. Alternatively, a command -`warming:showuseragent` is available which can be used to read the -current `User-Agent` header. +The generated header value can be copied form the cache warmup modal +in the TYPO3 backend. Alternatively, a command `warming:showuseragent` +is available which can be used to read the current `User-Agent` header. .. _implement-a-custom-crawler: @@ -83,8 +71,7 @@ that redirects user-oriented output to an instance of Configurable crawlers --------------------- -Since version 0.7.13 of `eliashaeussler/cache-warmup`, custom -crawlers can also implement the +Custom crawlers can also implement the :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\ConfigurableCrawlerInterface`, allowing users to configure warmup requests themselves. @@ -108,14 +95,6 @@ Steps to implement a new crawler - :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\VerboseCrawlerInterface` - :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\ConfigurableCrawlerInterface` - .. tip:: - - This extension provides some additional traits which you can - use for your new crawler: - - - :php:trait:`EliasHaeussler\\Typo3Warming\\Crawler\\RequestAwareTrait` - - :php:trait:`EliasHaeussler\\Typo3Warming\\Crawler\\UseAgentTrait` - 2. Configure the new crawler Add the new crawler to the :ref:`extension configuration `. @@ -127,3 +106,12 @@ Steps to implement a new crawler Finally, flush all system caches to ensure the correct crawler class is used for further cache warmup requests. + +.. seealso:: + View the sources on GitHub: + + - `CrawlerInterface `__ + - `ConfigurableCrawlerInterface `__ + - `VerboseCrawlerInterface `__ + - `ConcurrentUserAgentCrawler `__ + - `OutputtingUserAgentCrawler `__ diff --git a/Documentation/DeveloperCorner/CrawlingStrategies.rst b/Documentation/DeveloperCorner/CrawlingStrategies.rst new file mode 100644 index 00000000..561ca386 --- /dev/null +++ b/Documentation/DeveloperCorner/CrawlingStrategies.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _crawling-strategies: + +=================== +Crawling strategies +=================== + +Before URLs are crawled by a :ref:`crawler `, they can be +prepared by a specific strategy. This, for example, allows to +prioritize specific URLs or provide additional information to URLs. + +.. php:namespace:: EliasHaeussler\CacheWarmup\Crawler\Strategy + +.. php:interface:: CrawlingStrategy + + Interface for crawling strategy to prepare URLs before crawling. + + .. php:method:: prepareUrls($urls) + + Prepare given URLs for crawling. + + :param array $urls: List of URLs to be prepared for crawling. + :returntype: :php:`list` + + .. php:staticmethod:: getName() + + Get name of crawling strategy for use as identifier. + + :returntype: string + +.. _shipped-crawling-strategies: + +Shipped crawling strategies +=========================== + +The extension ships with the following crawling strategies: + +- :php:`sort-by-changefreq`: Sorts given URLs by their + `changefreq `__ + node value. +- :php:`sort-by-lastmod`: Sorts given URLs by their + `lastmod `__ + node value. +- :php:`sort-by-priority`: Sorts given URLs by their + `priority `__ + node value. + +.. _implement-a-custom-strategy: + +Implement a custom strategy +=========================== + +.. rst-class:: bignums + +1. Create a new crawling strategy + + The new strategy must implement the + :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\Strategy\\CrawlingStrategy` + interface. Make sure to properly implement the :php:meth:`EliasHaeussler\\CacheWarmup\\Crawler\\Strategy\\CrawlingStrategy::getName` + method to identify the crawling strategy. + +2. Configure the new crawling strategy + + Add the new strategy to the :ref:`extension configuration `. + Use the strategy's name as configuration value. + +3. Flush system caches + + Finally, flush all system caches to ensure the correct crawling + strategy is used for further cache warmup requests. + +.. seealso:: + View the sources on GitHub: + + - `CrawlingStrategy `__ + - `SortByChangeFrequencyStrategy `__ + - `SortByLastModificationDateStrategy `__ + - `SortByPriorityStrategy `__ diff --git a/Documentation/DeveloperCorner/Index.rst b/Documentation/DeveloperCorner/Index.rst index 431902e0..2cbafb8d 100644 --- a/Documentation/DeveloperCorner/Index.rst +++ b/Documentation/DeveloperCorner/Index.rst @@ -15,6 +15,7 @@ into the cache warmup process. :maxdepth: 3 Crawlers + CrawlingStrategies SitemapProviders Caching ConfigurationAPI diff --git a/Documentation/DeveloperCorner/SitemapProviders.rst b/Documentation/DeveloperCorner/SitemapProviders.rst index 7ecb1cfa..cca2782d 100644 --- a/Documentation/DeveloperCorner/SitemapProviders.rst +++ b/Documentation/DeveloperCorner/SitemapProviders.rst @@ -12,7 +12,7 @@ priority) to localize XML sitemaps according to certain criteria. .. php:namespace:: EliasHaeussler\Typo3Warming\Sitemap\Provider -.. php:interface:: ProviderInterface +.. php:interface:: Provider Interface for sitemap providers used to locate the path to an XML sitemap of a given site. @@ -25,7 +25,7 @@ priority) to localize XML sitemaps according to certain criteria. :param TYPO3\\CMS\\Core\\Site\\Entity\\SiteLanguage $siteLanguage: An optional site language to include while locating the XML sitemap path. :returns: An instance of :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\SiteAwareSitemap` or :php:`null`. - .. php:method:: getPriority() + .. php:staticmethod:: getPriority() Get the provider's priority. The higher the returned value, the earlier the provider will be executed in the sitemap @@ -42,7 +42,13 @@ By default, the path to an XML sitemap is determined in three steps: .. rst-class:: bignums -1. Site configuration +1. Page type + + When using the :ref:`XML sitemap ` from TYPO3's + SEO extension, the configured path to the XML sitemap can be determined + by the appropriate page type. + +2. Site configuration Within the Sites module, one can explicitly define the path to the XML sitemap of a site. @@ -50,7 +56,7 @@ By default, the path to an XML sitemap is determined in three steps: .. image:: ../Images/site-configuration.png :alt: Configuration of XML sitemap path within the Sites module -2. `robots.txt` +3. `robots.txt` If no path is defined in the site configuration, a possible `robots.txt` file is parsed for a valid `Sitemap` specification. @@ -69,7 +75,7 @@ By default, the path to an XML sitemap is determined in three steps: Sitemap: https://www.example.com/our-sitemap.xml Sitemap: https://www.example.com/our-other-sitemap.xml -3. Default path +4. Default path If none of the above methods are successful, the default path `sitemap.xml` is used. @@ -80,20 +86,22 @@ Implement a custom provider =========================== To develop your own sitemap provider, it is only necessary to -implement the :php:interface:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\ProviderInterface`. -In addition, the :php:`getPriority()` method must be used to define -when the provider is executed. +implement the :php:interface:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\Provider` +interface. In addition, the :php:`getPriority()` method must be +used to define when the provider is executed. The order of the providers provided by default is as follows: +---------------------------------------------------------------------------------+---------------------+ | Sitemap provider | Priority | +=================================================================================+=====================+ +| :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\PageTypeProvider` | 300 | ++---------------------------------------------------------------------------------+---------------------+ | :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\SiteProvider` | 200 | +---------------------------------------------------------------------------------+---------------------+ | :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\RobotsTxtProvider` | 100 | +---------------------------------------------------------------------------------+---------------------+ -| :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\DefaultProvider` | :php:`PHP_INT_MIN` | +| :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\DefaultProvider` | :php:`PHP_INT_MIN` | +---------------------------------------------------------------------------------+---------------------+ Once your custom provider is ready, make sure to clear the DI @@ -102,7 +110,8 @@ caches in order to rebuild the service container properly. .. seealso:: View the sources on GitHub: - - `ProviderInterface `__ + - `Provider `__ + - `PageTypeProvider `__ - `SiteProvider `__ - `RobotsTxtProvider `__ - `DefaultProvider `__ diff --git a/Documentation/Images/context-menu.png b/Documentation/Images/context-menu.png index e2e2983a..01e89c94 100644 Binary files a/Documentation/Images/context-menu.png and b/Documentation/Images/context-menu.png differ diff --git a/Documentation/Images/sites-modal.png b/Documentation/Images/sites-modal.png new file mode 100644 index 00000000..ffc7ef68 Binary files /dev/null and b/Documentation/Images/sites-modal.png differ diff --git a/Documentation/Images/toolbar-item.png b/Documentation/Images/toolbar-item.png index 75772175..397888a2 100644 Binary files a/Documentation/Images/toolbar-item.png and b/Documentation/Images/toolbar-item.png differ diff --git a/Documentation/Includes.rst.txt b/Documentation/Includes.rst.txt index 9ea9699c..b06192d1 100644 --- a/Documentation/Includes.rst.txt +++ b/Documentation/Includes.rst.txt @@ -1,17 +1,35 @@ -.. This is 'Includes.rst.txt'. It is included at the very top of each and - every ReST source file in THIS documentation project (= manual). +.. More information about this file: + https://docs.typo3.org/m/typo3/docs-how-to-document/main/en-us/GeneralConventions/FileStructure.html#includes-rst-txt -.. role:: aspect (emphasis) +.. ---------- +.. text roles +.. ---------- + +.. role:: aspect(emphasis) +.. role:: bash(code) +.. role:: css(code) .. role:: html(code) .. role:: js(code) .. role:: php(code) -.. role:: sep (strong) +.. role:: rst(code) +.. role:: sep(strong) .. role:: sql(code) -.. role:: typoscript(code) -.. role:: yaml(code) -.. role:: ts(typoscript) +.. role:: tsconfig(code) :class: typoscript +.. role:: typoscript(code) +.. role:: xml(code) + :class: html + +.. role:: yaml(code) + .. default-role:: code + +.. --------- +.. highlight +.. --------- + +.. By default, code blocks use PHP syntax highlighting + .. highlight:: php diff --git a/Documentation/Index.rst b/Documentation/Index.rst index b3e92a70..d8f7c189 100644 --- a/Documentation/Index.rst +++ b/Documentation/Index.rst @@ -51,6 +51,7 @@ command. It supports multiple languages and custom crawler implementations. Configuration/Index Usage/Index DeveloperCorner/Index + Migration/Index Contributing/Index .. toctree:: diff --git a/Documentation/Installation/Index.rst b/Documentation/Installation/Index.rst index bb570d86..5e502e8f 100644 --- a/Documentation/Installation/Index.rst +++ b/Documentation/Installation/Index.rst @@ -11,8 +11,8 @@ Installation Requirements ============ -- PHP 7.4 - 8.2 -- TYPO3 10.4 LTS - 11.5 LTS +- PHP 8.1 - 8.2 +- TYPO3 12.4 LTS .. _steps: @@ -33,16 +33,18 @@ Or download it from the Version matrix ============== -+--------------------+-------------------------+---------------+ -| Extension versions | TYPO3 versions | PHP versions | -+====================+=========================+===============+ -| **since 0.5.0** | **10.4 LTS - 11.5 LTS** | **7.4 - 8.2** | -+--------------------+-------------------------+---------------+ -| 0.3.14 - 0.4.8 | 10.4 LTS - 11.5 LTS | 7.1 - 8.1 | -+--------------------+-------------------------+---------------+ -| 0.2.4 - 0.3.13 | 10.4 LTS - 11.5 LTS | 7.1 - 8.0 | -+--------------------+-------------------------+---------------+ -| 0.2.0 - 0.2.3 | 10.4 LTS - 11.2 | 7.1 - 8.0 | -+--------------------+-------------------------+---------------+ -| 0.1.x | 10.4 LTS - 11.1 | 7.1 - 8.0 | -+--------------------+-------------------------+---------------+ ++--------------------+---------------------+---------------+ +| Extension versions | TYPO3 versions | PHP versions | ++====================+=====================+===============+ +| **since 1.0.0** | **12.4 LTS** | **8.1 - 8.2** | ++--------------------+---------------------+---------------+ +| 0.5.0 - 0.5.2 | 10.4 LTS - 11.5 LTS | 7.4 - 8.2 | ++--------------------+---------------------+---------------+ +| 0.3.14 - 0.4.8 | 10.4 LTS - 11.5 LTS | 7.1 - 8.1 | ++--------------------+---------------------+---------------+ +| 0.2.4 - 0.3.13 | 10.4 LTS - 11.5 LTS | 7.1 - 8.0 | ++--------------------+---------------------+---------------+ +| 0.2.0 - 0.2.3 | 10.4 LTS - 11.2 | 7.1 - 8.0 | ++--------------------+---------------------+---------------+ +| 0.1.x | 10.4 LTS - 11.1 | 7.1 - 8.0 | ++--------------------+---------------------+---------------+ diff --git a/Documentation/Introduction/Index.rst b/Documentation/Introduction/Index.rst index b274c2b6..15d122ec 100644 --- a/Documentation/Introduction/Index.rst +++ b/Documentation/Introduction/Index.rst @@ -11,10 +11,10 @@ Introduction What does it do? ================ -The extension provides a service to warm up Frontend caches based on an XML -sitemap. Cache warmup can be triggered in various ways: +The extension provides a service to warm up Frontend caches based on XML +sitemaps. Cache warmup can be triggered in various ways: -- Via the :ref:`TYPO3 backend ` +- From the :ref:`TYPO3 backend ` - Using a :ref:`console command ` - Directly with the :ref:`PHP API ` @@ -34,9 +34,10 @@ Features - Support of various :ref:`sitemap providers ` (e.g. `robots.txt` or custom location) - Multi-language support of configured sites -- Support for :ref:`custom crawlers ` +- Support for :ref:`custom crawlers ` and + :ref:`crawling strategies ` - :ref:`Console commands ` -- Compatible with TYPO3 10.4 LTS and 11.5 LTS +- Compatible with TYPO3 12.4 LTS (see :ref:`version matrix `) .. _support: diff --git a/Documentation/Migration/Index.rst b/Documentation/Migration/Index.rst new file mode 100644 index 00000000..6df84606 --- /dev/null +++ b/Documentation/Migration/Index.rst @@ -0,0 +1,127 @@ +.. include:: /Includes.rst.txt + +.. _migration: + +========= +Migration +========= + +This page lists all notable changes and required migrations when +upgrading to a new major version of this extension. + +.. _version-1.0.0: + +Version 1.0.0 +============= + +Default crawlers +---------------- + +- Default crawlers are now :php:`final`. Custom crawlers can no + longer extend default crawlers. Implement + :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\CrawlerInterface` + or :php:interface:`EliasHaeussler\\CacheWarmup\\Crawler\\VerboseCrawlerInterface` + instead. +- :php:`CrawlerFactory` from `eliashaeussler/cache-warmup` library + is now used to instantiate crawlers. Dependency injection is no + longer possible. +- :php:trait:`EliasHaeussler\\Typo3Warming\\Crawler\\ConfigurableClientTrait` + was removed. Use + :php:meth:`EliasHaeussler\\Typo3Warming\\Http\\Client\\ClientFactory::get` + instead. +- :php:interface:`EliasHaeussler\\Typo3Warming\\Crawler\\RequestAwareInterface` + and :php:trait:`EliasHaeussler\\Typo3Warming\\Crawler\\RequestAwareTrait` + were removed. Use + :php:interface:`EliasHaeussler\\Typo3Warming\\Crawler\\StreamableCrawler` + in combination with + :php:class:`EliasHaeussler\\Typo3Warming\\Http\\Message\\Handler\\StreamResponseHandler` + instead. +- :php:trait:`EliasHaeussler\\Typo3Warming\\Crawler\\UserAgentTrait` + was removed. Provide an own implementation that calls + :php:meth:`EliasHaeussler\\Typo3Warming\\Configuration\\Configuration::getUserAgent` + instead. + +Warmup request handling +----------------------- + +- :php:class:`EliasHaeussler\\Typo3Warming\\ValueObject\\Request\\WarmupRequest` + is now :php:`final`. +- :php:attr:`EliasHaeussler\\Typo3Warming\\ValueObject\\Request\\WarmupRequest::$updateCallback` + was removed. Streamed warmup requests must now be handled by using + :php:class:`EliasHaeussler\\Typo3Warming\\Http\\Message\\Handler\\StreamResponseHandler` + in a custom crawler instead. +- Crawling result handling within + :php:class:`EliasHaeussler\\Typo3Warming\\ValueObject\\Request\\WarmupRequest` + was removed. Use the returned + :php:class:`EliasHaeussler\\Typo3Warming\\Result\\CacheWarmupResult` + from :php:meth:`EliasHaeussler\\Typo3Warming\\Service\\CacheWarmupService::warmup` + instead. +- :php:meth:`EliasHaeussler\\Typo3Warming\\Service\\CacheWarmupService::warmupPages` + and :php:meth:`EliasHaeussler\\Typo3Warming\\Service\\CacheWarmupService::warmupSites` + were combined to a new method + :php:meth:`EliasHaeussler\\Typo3Warming\\Service\\CacheWarmupService::warmup`. + Use this method with dedicated instances of + :php:class:`EliasHaeussler\\Typo3Warming\\ValueObject\\Request\\SiteWarmupRequest` and + :php:class:`EliasHaeussler\\Typo3Warming\\ValueObject\\Request\\PageWarmupRequest`. + +Sitemap providers +----------------- + +- :php:interface:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\ProviderInterface` + was renamed to + :php:interface:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\Provider`. +- :php:trait:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\AbstractProvider` + was removed. Custom sitemap providers must now implement + :php:interface:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\Provider` + directly. The previously available trait method is now available within + :php:meth:`EliasHaeussler\\Typo3Warming\\Utility\\HttpUtility::getSiteUrlWithPath`. +- A new sitemap provider + :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\Provider\\PageTypeProvider` + was added. It is configured with highest priority. Read more at + :ref:`sitemap-providers`. + +Language handling +----------------- + +- :php:class:`EliasHaeussler\\Typo3Warming\\Sitemap\\SiteAwareSitemap` + now requires a site language to be set. +- Page uri generation now respects configured language + overlays and is moved to + :php:meth:`EliasHaeussler\\Typo3Warming\\Utility\\HttpUtility::generateUri`. + +Extension configuration +----------------------- + +- Extension configuration `exclude` was added. Read more at + :ref:`exclude `. +- Extension configuration `strategy` was added. Read more at + :ref:`strategy `. + +Command options +--------------- + +- New command option `--format` was added. Read more at + :ref:`warming-cachewarmup`. +- New command option `--strategy` was added. Read more at + :ref:`warming-cachewarmup`. + +Template paths +-------------- + +- Template paths were rewritten: + + + :file:`CacheWarmupToolbarItem.html` was rewritten + to :file:`Toolbar/CacheWarmupToolbarItem.html` + + :file:`CacheWarmupToolbarItemActions.html` was rewritten + to :file:`Modal/SitesModal.html` + +- Partial paths were rewritten: + + + :file:`ToolbarItem.html` was inlined to template + :file:`Toolbar/CacheWarmupToolbarItem.html` + + :file:`ToolbarItemAction.html` was split into + :file:`Modal/Sites/SiteGroup.html` and :file:`Modal/Sites/SiteGroupItem.html` + + :file:`ToolbarItemMissing.html` was rewritten to + :file:`Modal/Alert/NoSites.html` + + :file:`ToolbarItemPlaceholder.html` was removed + + :file:`ToolbarItemUserAgent.html` was removed diff --git a/Documentation/Usage/BackendToolbar.rst b/Documentation/Usage/BackendToolbar.rst index 0b056878..3366e857 100644 --- a/Documentation/Usage/BackendToolbar.rst +++ b/Documentation/Usage/BackendToolbar.rst @@ -12,18 +12,42 @@ Backend toolbar Read how to give non-admin users access to the toolbar item at :ref:`permissions`. +.. _toolbar.item: + +Toolbar item +============ + As soon as the extension is installed, a new toolbar item in your TYPO3 -backend should appear. You can click on the toolbar item to get a list -of all sites. If a site does not provide an XML sitemap, it cannot be -used to warm up caches. +backend should appear. .. image:: ../Images/toolbar-item.png :alt: Cache warmup toolbar item within the TYPO3 backend +.. _cache-warmup-modal: + +Cache warmup modal +================== + +You can click on the toolbar item to open a modal with all available +sites listed. If a site does not provide an XML sitemap, it cannot be +used to warm up caches. + +Select all sites whose caches should be warmed up and run cache warmup +by clicking on the :guilabel:`Start` button. The button is hidden by +default and will be shown once a site is selected. + +.. note:: + + Non-admins cannot see the :guilabel:`Settings` section within the + cache warmup modal. + +.. image:: ../Images/sites-modal.png + :alt: Modal with available sites for cache warmup within the TYPO3 backend + .. tip:: - The toolbar item additionally outputs information about the - `User-Agent` header used during the cache warmup. By clicking on + The modal additionally outputs information about the `User-Agent` + header used during the cache warmup. By clicking on :guilabel:`Copy to clipboard`, it can be copied to the clipboard, for example to exclude cache warmup requests from analyses in statistics tools. Take a look at the console command diff --git a/Documentation/Usage/ConsoleCommands.rst b/Documentation/Usage/ConsoleCommands.rst index 5228395b..3f43f62b 100644 --- a/Documentation/Usage/ConsoleCommands.rst +++ b/Documentation/Usage/ConsoleCommands.rst @@ -136,6 +136,49 @@ The following command options are available: from :ref:`extension configuration ` is used. + Example: + + .. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 warming:cachewarmup --limit 100 + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 warming:cachewarmup --limit 100 + +.. confval:: --strategy + + :Required: false + :type: string + :Default: :typoscript:`strategy` value from :ref:`extension configuration ` + :Multiple allowed: false + + Name of an available crawling strategy to use for cache warmup. If + this option is omitted, the :typoscript:`strategy` value from + :ref:`extension configuration ` is used. + + Example: + + .. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 warming:cachewarmup --strategy sort-by-priority + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 warming:cachewarmup --strategy sort-by-priority + .. confval:: -x|--strict :Required: false @@ -146,6 +189,22 @@ The following command options are available: Exit with a non-zero status code in case cache warmup fails or errors occur during cache warmup. + Example: + + .. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 warming:cachewarmup --strict + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 warming:cachewarmup --strict + .. _warming-showuseragent: `warming:showuseragent` diff --git a/Documentation/Usage/UsingTheAPI.rst b/Documentation/Usage/UsingTheAPI.rst index 6c09a80b..f3b6192e 100644 --- a/Documentation/Usage/UsingTheAPI.rst +++ b/Documentation/Usage/UsingTheAPI.rst @@ -16,29 +16,15 @@ warmup directly in PHP code. Service to run cache warmup for sites and pages. - .. php:method:: warmupSites($sites, $request) + .. php:method:: warmup($sites, $pages, $limit, $strategy) - Run cache warmup for given list of sites. + Run cache warmup for given sites and pages. - :param array $sites: List of sites to be warmed up. - :param EliasHaeussler\\Typo3Warming\\Request\\WarmupRequest $request: Additional cache warmup request parameters. - :returntype: EliasHaeussler\\CacheWarmup\\Crawler\\CrawlerInterface - - .. php:method:: warmupPages($pageIds, $request) - - Run cache warmup for given list of pages. - - :param array $pageIds: List of pages to be warmed up. - :param EliasHaeussler\\Typo3Warming\\Request\\WarmupRequest $request: Additional cache warmup request parameters. - :returntype: EliasHaeussler\\CacheWarmup\\Crawler\\CrawlerInterface - - .. php:method:: generateUri($pageId, $languageId = null) - - Generate uri for given page and optional language. - - :param int $pageId: ID of the page for which the uri is to be generated. - :param int $languageId: Optional language ID to respect when generating the uri. - :returntype: Psr\\Http\\Message\\UriInterface + :param array $sites: List of site warmup requests. + :param array $pages: List of page warmup requests. + :param int $limit: Optional cache warmup limit. + :param string $strategy: Optional crawling strategy. + :returntype: EliasHaeussler\\Typo3Warming\\Result\\CacheWarmupResult .. php:method:: getCrawler() @@ -60,22 +46,36 @@ Example :: + use EliasHaeussler\CacheWarmup; use EliasHaeussler\Typo3Warming; use TYPO3\CMS\Core; $cacheWarmupService = Core\Utility\GeneralUtility::makeInstance(Typo3Warming\Service\CacheWarmupService::class); - $request = new Typo3Warming\Request\WarmupRequest(); - - // Get all sites $siteFinder = Core\Utility\GeneralUtility::makeInstance(Core\Site\SiteFinder::class); - $sites = $siteFinder->getAllSites(); - // Run cache warmup for all sites - $crawler = $cacheWarmupService->warmupSites($sites, $request); + $sites = []; + $pages = []; + + // Create site warmup requests + foreach ($siteFinder->getAllSites() as $site) { + $sites[] = new Typo3Warming\ValueObject\Request\SiteWarmupRequest($site); + } + + // Create page warmup requests + foreach ([1, 2, 3] as $page) { + $pages[] = new Typo3Warming\ValueObject\Request\PageWarmupRequest($page); + } + + // Define optional cache warmup options + $limit = 100; + $strategy = CacheWarmup\Crawler\Strategy\SortByPriorityStrategy::getName(); + + // Run cache warmup for sites and pages + $result = $cacheWarmupService->warmup($sites, $pages, $limit, $strategy); - // Run cache warmup for single pages only - $crawler = $cacheWarmupService->warmupPages([1, 2, 3], $request); + // Fetch crawling states + $failedUrls = $result->getResult()->getFailed(); + $successfulUrls = $result->getResult()->getSuccessful(); - // Evaluate crawling states - $failedUrls = $crawler->getFailedUrls(); - $successfulUrls = $crawler->getSuccessfulUrls(); + // Fetch excluded URLs + $excludedUrls = $result->getExcludedUrls(); diff --git a/README.md b/README.md index 51e967e8..f0916b59 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,21 @@ # TYPO3 extension `warming` -[![Coverage](https://codecov.io/gh/eliashaeussler/typo3-warming/branch/main/graph/badge.svg?token=7M3UXACCKA)](https://codecov.io/gh/eliashaeussler/typo3-warming) -[![Maintainability](https://api.codeclimate.com/v1/badges/2f55fa181559fdda4cc1/maintainability)](https://codeclimate.com/github/eliashaeussler/typo3-warming/maintainability) -[![Tests](https://github.com/eliashaeussler/typo3-warming/actions/workflows/tests.yaml/badge.svg)](https://github.com/eliashaeussler/typo3-warming/actions/workflows/tests.yaml) -[![CGL](https://github.com/eliashaeussler/typo3-warming/actions/workflows/cgl.yaml/badge.svg)](https://github.com/eliashaeussler/typo3-warming/actions/workflows/cgl.yaml) -[![Release](https://github.com/eliashaeussler/typo3-warming/actions/workflows/release.yaml/badge.svg)](https://github.com/eliashaeussler/typo3-warming/actions/workflows/release.yaml) -[![License](http://poser.pugx.org/eliashaeussler/typo3-warming/license)](LICENSE.md)\ -[![Version](https://shields.io/endpoint?url=https://typo3-badges.dev/badge/warming/version/shields)](https://extensions.typo3.org/extension/warming) -[![Downloads](https://shields.io/endpoint?url=https://typo3-badges.dev/badge/warming/downloads/shields)](https://extensions.typo3.org/extension/warming) +[![Coverage](https://img.shields.io/codecov/c/github/eliashaeussler/typo3-warming?logo=codecov&token=7M3UXACCKA)](https://codecov.io/gh/eliashaeussler/typo3-warming) +[![Maintainability](https://img.shields.io/codeclimate/maintainability/eliashaeussler/typo3-warming?logo=codeclimate)](https://codeclimate.com/github/eliashaeussler/typo3-warming/maintainability) +[![CGL](https://img.shields.io/github/actions/workflow/status/eliashaeussler/typo3-warming/cgl.yaml?label=cgl&logo=github)](https://github.com/eliashaeussler/typo3-warming/actions/workflows/cgl.yaml) +[![Tests](https://img.shields.io/github/actions/workflow/status/eliashaeussler/typo3-warming/tests.yaml?label=tests&logo=github)](https://github.com/eliashaeussler/typo3-warming/actions/workflows/tests.yaml) +[![Supported PHP Versions](https://img.shields.io/packagist/dependency-v/eliashaeussler/typo3-warming/php?logo=php)](https://packagist.org/packages/eliashaeussler/typo3-warming) [![Supported TYPO3 versions](https://shields.io/endpoint?url=https://typo3-badges.dev/badge/warming/typo3/shields)](https://extensions.typo3.org/extension/warming) -[![Extension stability](https://shields.io/endpoint?url=https://typo3-badges.dev/badge/warming/stability/shields)](https://extensions.typo3.org/extension/warming) [![Slack](https://img.shields.io/badge/slack-%23ext--warming-4a154b?logo=slack)](https://typo3.slack.com/archives/C0400CSGWAY) -**:orange_book: [Documentation](https://docs.typo3.org/p/eliashaeussler/typo3-warming/main/en-us/)** | -:package: [Packagist](https://packagist.org/packages/eliashaeussler/typo3-warming) | -:hatched_chick: [TYPO3 extension repository](https://extensions.typo3.org/extension/warming) | -:floppy_disk: [Repository](https://github.com/eliashaeussler/typo3-warming) | -:bug: [Issue tracker](https://github.com/eliashaeussler/typo3-warming/issues) - An extension for TYPO3 CMS that warms up Frontend caches based on an XML sitemap. Cache warmup can be triggered via TYPO3 backend or using a console command. It supports multiple languages and custom crawler implementations. -## :rocket: Features +## 🚀 Features * Warmup of Frontend caches from pages or XML sitemap * Integration in TYPO3 backend toolbar and page tree @@ -36,20 +26,33 @@ It supports multiple languages and custom crawler implementations. * Multi-language support * Support for custom crawlers * Console command -* Compatible with TYPO3 10.4 LTS and 11.5 LTS +* Compatible with TYPO3 12.4 LTS + +## 🔥 Installation -## :fire: Installation +### Composer -Via Composer: +[![Packagist](https://img.shields.io/packagist/v/eliashaeussler/typo3-warming?label=version&logo=packagist)](https://packagist.org/packages/eliashaeussler/typo3-warming) +[![Packagist Downloads](https://img.shields.io/packagist/dt/eliashaeussler/typo3-warming?color=brightgreen)](https://packagist.org/packages/eliashaeussler/typo3-warming) ```bash composer require eliashaeussler/typo3-warming ``` -Or download the zip file from +### TER + +[![TER version](https://typo3-badges.dev/badge/warming/version/shields.svg)](https://extensions.typo3.org/extension/warming) +[![TER downloads](https://typo3-badges.dev/badge/warming/downloads/shields.svg)](https://extensions.typo3.org/extension/warming) + +Download the zip file from [TYPO3 extension repository (TER)](https://extensions.typo3.org/extension/warming). -## :gem: Credits +## 📙 Documentation + +Please have a look at the +[official extension documentation](https://docs.typo3.org/p/eliashaeussler/typo3-warming/main/en-us/). + +## 💎 Credits The extension icon ("rocket") as well as the icons for cache warmup actions are modified versions of the original @@ -57,6 +60,6 @@ modified versions of the original icon from TYPO3 core which is originally licensed under [MIT License](https://github.com/TYPO3/TYPO3.Icons/blob/main/LICENSE). -## :star: License +## ⭐ License This project is licensed under [GNU General Public License 2.0 (or later)](LICENSE.md). diff --git a/Resources/Private/Frontend/.babelrc b/Resources/Private/Frontend/.babelrc deleted file mode 100644 index f5af04a8..00000000 --- a/Resources/Private/Frontend/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@babel/preset-typescript", "@babel/preset-env"], - "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-transform-runtime"] -} diff --git a/Resources/Private/Frontend/.eslintignore b/Resources/Private/Frontend/.eslintignore deleted file mode 100644 index 6de001d8..00000000 --- a/Resources/Private/Frontend/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -webpack.config.js diff --git a/Resources/Private/Frontend/.gitignore b/Resources/Private/Frontend/.gitignore index 033833d9..a1602120 100644 --- a/Resources/Private/Frontend/.gitignore +++ b/Resources/Private/Frontend/.gitignore @@ -1,2 +1,3 @@ +/.rollup.cache /node_modules /yarn-error.log diff --git a/Resources/Private/Frontend/.stylelintrc b/Resources/Private/Frontend/.stylelintrc index f93ae04c..04213919 100644 --- a/Resources/Private/Frontend/.stylelintrc +++ b/Resources/Private/Frontend/.stylelintrc @@ -1,7 +1,6 @@ { "extends": "stylelint-config-sass-guidelines", "rules": { - "indentation": 4, - "selector-class-pattern": "^[a-z0-9\\-_]+$" + "max-nesting-depth": 2 } } diff --git a/Resources/Private/Frontend/package.json b/Resources/Private/Frontend/package.json index 42f0367f..2a9f3129 100644 --- a/Resources/Private/Frontend/package.json +++ b/Resources/Private/Frontend/package.json @@ -1,57 +1,49 @@ { - "name": "@eliashaeussler/typo3-warming", - "description": "Frontend for EXT:warming, an extension for TYPO3 CMS that warms up Frontend caches based on an XML sitemap", - "version": "0.5.2", - "license": "GPL-2.0-or-later", - "scripts": { - "build": "cross-env NODE_ENV=production webpack", - "start": "cross-env NODE_ENV=development webpack --watch", - "lint": "npm-run-all lint:ts lint:scss", - "lint:fix": "npm-run-all lint:ts:fix lint:scss:fix", - "lint:ts": "eslint 'src/scripts/**/*.{ts,tsx}'", - "lint:ts:fix": "eslint 'src/scripts/**/*.{ts,tsx}' --fix", - "lint:scss": "stylelint 'src/styles/**/*.scss'", - "lint:scss:fix": "stylelint 'src/styles/**/*.scss' --fix" - }, - "author": { - "name": "Elias Häußler", - "email": "elias@haeussler.dev", - "url": "https://haeussler.dev" - }, - "devDependencies": { - "@babel/core": "^7.22.5", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-transform-runtime": "^7.22.5", - "@babel/preset-env": "^7.22.5", - "@babel/preset-typescript": "^7.22.5", - "@types/jquery": "^3.5.16", - "@types/requirejs": "^2.1.32", - "@types/select2": "^4.0.57", - "@types/uuid": "^9.0.2", - "@types/webpack-env": "^1.18.1", - "@typescript-eslint/eslint-plugin": "^4.28.2", - "@typescript-eslint/parser": "^4.28.2", - "babel-loader": "^9.1.2", - "clean-webpack-plugin": "^4.0.0", - "clipboard-polyfill": "^4.0.0", - "cross-env": "^7.0.3", - "css-loader": "^6.8.1", - "eslint": "^7.30.0", - "eslint-plugin-sonarjs": "^0.19.0", - "ignore-emit-webpack-plugin": "^2.0.6", - "mini-css-extract-plugin": "^2.7.6", - "npm-run-all": "^4.1.5", - "sass": "^1.63.6", - "sass-loader": "^13.3.2", - "source-map-loader": "^4.0.1", - "stylelint": "^14.16.1", - "stylelint-config-sass-guidelines": "^9.0.0", - "typescript": "^5.1.6", - "webpack": "^5.88.1", - "webpack-cli": "^5.1.4" - }, - "dependencies": { - "select2": "^4.1.0-rc.0", - "uuid": "^9.0.0" - } + "name": "@eliashaeussler/typo3-warming", + "description": "Frontend for EXT:warming, an extension for TYPO3 CMS that warms up Frontend caches based on an XML sitemap", + "version": "0.5.2", + "type": "module", + "private": true, + "license": "GPL-2.0-or-later", + "scripts": { + "build": "cross-env NODE_ENV=production rollup -c", + "start": "cross-env NODE_ENV=development rollup -c --watch", + "lint": "npm-run-all lint:scss lint:ts", + "lint:scss": "stylelint 'src/styles/**/*.scss'", + "lint:ts": "eslint 'src/scripts/**/*.{ts,tsx}'", + "fix": "npm-run-all fix:scss fix:ts", + "fix:scss": "stylelint 'src/styles/**/*.scss' --fix", + "fix:ts": "eslint 'src/scripts/**/*.{ts,tsx}' --fix" + }, + "author": { + "name": "Elias Häußler", + "email": "elias@haeussler.dev", + "url": "https://haeussler.dev" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^15.0.2", + "@rollup/plugin-terser": "^0.4.1", + "@rollup/plugin-typescript": "^11.1.0", + "@types/jquery": "^3.5.16", + "@types/uuid": "^9.0.1", + "@typescript-eslint/eslint-plugin": "^5.59.1", + "@typescript-eslint/parser": "^5.59.1", + "cross-env": "^7.0.3", + "eslint": "^8.39.0", + "eslint-plugin-sonarjs": "^0.19.0", + "npm-run-all": "^4.1.5", + "postcss": "^8.4.24", + "rollup": "^3.21.2", + "rollup-plugin-delete": "^2.0.0", + "rollup-plugin-no-emit": "^0.0.1", + "rollup-plugin-postcss": "^4.0.2", + "sass": "^1.63.3", + "stylelint": "^15.6.0", + "stylelint-config-sass-guidelines": "^10.0.0", + "typescript": "^5.0.4" + }, + "dependencies": { + "clipboard-polyfill": "^4.0.0", + "uuid": "^9.0.0" + } } diff --git a/Resources/Private/Frontend/rollup.config.js b/Resources/Private/Frontend/rollup.config.js new file mode 100644 index 00000000..291b8d88 --- /dev/null +++ b/Resources/Private/Frontend/rollup.config.js @@ -0,0 +1,88 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2021 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import del from 'rollup-plugin-delete'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import noEmit from 'rollup-plugin-no-emit'; +import postcss from 'rollup-plugin-postcss'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; + +const isDev = process.env.NODE_ENV !== 'production'; + +export default [ + { + input: [ + 'src/scripts/backend/context-menu-action.ts', + 'src/scripts/backend/toolbar-menu.ts', + ], + output: { + dir: '../../Public/JavaScript/backend', + format: 'esm', + sourcemap: isDev ? 'inline' : false, + }, + plugins: [ + del({ + targets: '../../Public/JavaScript/backend/*', + force: true, + }), + nodeResolve(), + terser({ + format: { + comments: false, + }, + }), + typescript({ + outputToFilesystem: true, + }), + ], + external: [ + 'jquery', + /^@typo3\//, + ], + }, + { + input: [ + 'src/styles/modal.scss', + ], + output: { + dir: '../../Public/Css', + }, + plugins: [ + del({ + targets: '../../Public/Css/*', + force: true, + }), + nodeResolve({ + extensions: ['.css'], + }), + postcss({ + extract: 'backend.css', + minimize: !isDev, + sourceMap: isDev ? 'inline' : false, + use: ['sass'], + }), + noEmit({ + match: (fileName) => fileName.match(/\.js$/), + }), + ], + } +]; diff --git a/Resources/Private/Frontend/src/@types/typo3.stub.d.ts b/Resources/Private/Frontend/src/@types/typo3.stub.d.ts index 2247d79e..931becfd 100644 --- a/Resources/Private/Frontend/src/@types/typo3.stub.d.ts +++ b/Resources/Private/Frontend/src/@types/typo3.stub.d.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + /* * This file is part of the TYPO3 CMS extension "warming". * @@ -17,56 +19,61 @@ * along with this program. If not, see . */ -/* eslint-disable @typescript-eslint/no-explicit-any */ declare const TYPO3: any; declare const nothing: any; -/* eslint-enable @typescript-eslint/no-explicit-any */ /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/ActionButton/ImmediateAction.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/backend/action-button/immediate-action.ts + */ +declare module '@typo3/backend/action-button/immediate-action.js' { + export default nothing; +} + +/** + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/backend/icons.ts */ -declare module 'TYPO3/CMS/Backend/ActionButton/ImmediateAction' { +declare module '@typo3/backend/icons.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Icons.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/backend/modal.ts */ -declare module 'TYPO3/CMS/Backend/Icons' { +declare module '@typo3/backend/modal.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Modal.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/backend/notification.ts */ -declare module 'TYPO3/CMS/Backend/Modal' { +declare module '@typo3/backend/notification.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Notification.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/core/ajax/ajax-request.ts */ -declare module 'TYPO3/CMS/Backend/Notification' { +declare module '@typo3/core/ajax/ajax-request.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/core/ajax/ajax-response.ts */ -declare module 'TYPO3/CMS/Backend/Viewport' { +declare module '@typo3/core/ajax/ajax-response.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxRequest.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/core/document-service.ts */ -declare module 'TYPO3/CMS/Core/Ajax/AjaxRequest' { +declare module '@typo3/core/document-service.js' { export default nothing; } /** - * @see https://github.com/TYPO3/TYPO3.CMS/blob/11.5/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxResponse.ts + * @see https://github.com/TYPO3/typo3/blob/v12.4.0/Build/Sources/TypeScript/core/event/regular-event.ts */ -declare module 'TYPO3/CMS/Core/Ajax/AjaxResponse' { +declare module '@typo3/core/event/regular-event.js' { export default nothing; } diff --git a/Resources/Private/Frontend/src/scripts/backend/context-menu-action.ts b/Resources/Private/Frontend/src/scripts/backend/context-menu-action.ts new file mode 100644 index 00000000..5ec627d3 --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/backend/context-menu-action.ts @@ -0,0 +1,104 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2021 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import {CacheWarmer, PageWarmupRequest, SiteWarmupRequest} from '@eliashaeussler/typo3-warming/cache-warmer'; +import {MissingSiteIdentifierException} from '@eliashaeussler/typo3-warming/exception/missing-site-identifier-exception'; + +/** + * Run cache warmup from the SVG tree context menu. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export class ContextMenuAction { + /** + * Trigger cache warmup for a specific page, identified by the given UID. + * + * @param table {string} Table name associated to the triggered SVG tree + * @param uid {number} UID of the associated element within the triggered SVG tree + * @param data {object} Additional data attributes from the original context menu item + */ + public warmupPageCache(table: string, uid: number, data: object): void { + if ('pages' === table) { + const languageId: number = ContextMenuAction.determineLanguage(data); + + const pages: PageWarmupRequest = {}; + pages[uid] = [languageId]; + + (new CacheWarmer()).warmupCache({}, pages); + } + } + + /** + * Trigger cache warmup for a specific site, identified by the given UID. + * + * @param table {string} Table name associated to the triggered SVG tree + * @param uid {number} UID of the associated element within the triggered SVG tree + * @param data {object} Additional data attributes from the original context menu item + */ + public warmupSiteCache(table: string, uid: number, data: object): void { + if ('pages' === table) { + const languageId: number = ContextMenuAction.determineLanguage(data); + const siteIdentifier: string = ContextMenuAction.determineSiteIdentifier(data); + + const sites: SiteWarmupRequest = {}; + sites[siteIdentifier] = [languageId]; + + (new CacheWarmer()).warmupCache(sites, {}); + } + } + + /** + * Determine requested language ID from context menu action. + * + * Tests whether a language ID is defined in the current context menu + * action and returns it, otherwise `NULL` is returned. The language ID + * is defined as `data-language-id` attribute in the context menu action. + * + * @param data {object} Additional data attributes from the original context menu item + * @returns {number|null} The resolved language ID or `NULL` + * @private + */ + private static determineLanguage(data: object): number | null { + if (!('languageId' in data) || typeof data.languageId !== 'string') { + return null; + } + + return parseInt(data.languageId); + } + + /** + * Determine current site identifier from context menu action. + * + * @param data {object} Additional data attributes from the original context menu item + * @returns {string} The resolved site identifier + * @private + */ + private static determineSiteIdentifier(data: object): string { + if (!('siteIdentifier' in data) || typeof data.siteIdentifier !== 'string') { + throw MissingSiteIdentifierException.create(); + } + + return data.siteIdentifier; + } +} + +export default new ContextMenuAction(); diff --git a/Resources/Private/Frontend/src/scripts/backend/modal/dto/site-selection.ts b/Resources/Private/Frontend/src/scripts/backend/modal/dto/site-selection.ts new file mode 100644 index 00000000..691661ee --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/backend/modal/dto/site-selection.ts @@ -0,0 +1,81 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2023 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import {InvalidSiteSelectionException} from '@eliashaeussler/typo3-warming/exception/invalid-site-selection-exception'; + +/** + * Site group or site group item within sites modal. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export class SiteSelection { + constructor( + private readonly site: string, + private readonly language: number|null, + private readonly group: string|null, + ) { + } + + /** + * @throws InvalidSiteSelectionException + */ + public static fromJson(json: string): SiteSelection { + const values = JSON.parse(json); + + if (typeof values !== 'object') { + throw InvalidSiteSelectionException.create(); + } + if (!('site' in values)) { + throw InvalidSiteSelectionException.create(); + } + + return new SiteSelection( + values.site, + values.language ?? null, + values.group ?? null, + ) + } + + public getSiteIdentifier(): string { + return this.site; + } + + public getLanguageId(): number|null { + return this.language; + } + + public getGroupName(): string|null { + return this.group; + } + + public isWithinGroup(): boolean { + return this.group !== null; + } + + public isGroupRoot(): boolean { + return this.isWithinGroup() && this.language === null; + } + + public isGroupItem(): boolean { + return this.isWithinGroup() && this.language !== null; + } +} diff --git a/Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupProgressModal.ts b/Resources/Private/Frontend/src/scripts/backend/modal/progress-modal.ts similarity index 55% rename from Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupProgressModal.ts rename to Resources/Private/Frontend/src/scripts/backend/modal/progress-modal.ts index d96843d1..d4d7b14e 100644 --- a/Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupProgressModal.ts +++ b/Resources/Private/Frontend/src/scripts/backend/modal/progress-modal.ts @@ -19,39 +19,33 @@ * along with this program. If not, see . */ -import IconIdentifiers from '../../../lib/Enums/IconIdentifiers'; -import LanguageKeys from '../../../lib/Enums/LanguageKeys'; -import Util from '../../../lib/Util'; -import WarmupProgress from '../../../lib/WarmupProgress'; - -// Modules import $ from 'jquery'; -import Modal from 'TYPO3/CMS/Backend/Modal'; +import Modal from '@typo3/backend/modal.js'; + +import {CrawlingProgress, WarmupProgress} from '@eliashaeussler/typo3-warming/request/warmup-progress'; +import {IconIdentifiers} from '@eliashaeussler/typo3-warming/enums/icon-identifiers'; +import {LanguageKeys} from '@eliashaeussler/typo3-warming/enums/language-keys'; +import {ReportModal} from '@eliashaeussler/typo3-warming/backend/modal/report-modal'; +import {StringHelper} from '@eliashaeussler/typo3-warming/helper/string-helper'; +import {WarmupState} from '@eliashaeussler/typo3-warming/enums/warmup-state'; -/** - * Button names within cache warmup progress modal. - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ enum CacheWarmupProgressModalButtonNames { reportButton = 'tx-warming-open-report', retryButton = 'tx-warming-retry', } /** - * AMD module that shows a modal with the progress of the current cache warmup. - * - * Module: TYPO3/CMS/Warming/Backend/Modal/CacheWarmupProgressModal + * Modal with a progress bar, displaying the current cache warmup progress. * * @author Elias Häußler * @license GPL-2.0-or-later */ -class CacheWarmupProgressModal { +export class ProgressModal { private $modal!: JQuery; private $progressBar!: JQuery; private $allCounter!: JQuery; private $failedCounter!: JQuery; + private $currentUrl!: JQuery; /** * Create modal with progress bar. @@ -65,20 +59,20 @@ class CacheWarmupProgressModal { * is hidden as long as cache warmup is in progress since it contains several * actions that require cache warmup to be finished. */ - public createModal(): void { - const $content = this.buildInitialModalContent(); - - // Build initial modal or apply content to existing modal - if (Modal.currentModal) { - this.$modal = Modal.currentModal; - this.$modal.show(); - this.$modal.find('.modal-body').empty().append($content); - } else { - this.$modal = this.createModalWithContent($content); - } + public static createModal(): ProgressModal { + const modal: ProgressModal = new this(); + const $content: JQuery = modal.buildInitialModalContent(); + + // Ensure all other modals are closed + Modal.dismiss(); + + // Create new modal + modal.$modal = modal.createModalWithContent($content); // Hide footer until cache warmup is finished - this.$modal.find('.modal-footer').hide(); + modal.$modal.find('.modal-footer').hide(); + + return modal; } /** @@ -90,9 +84,10 @@ class CacheWarmupProgressModal { * @param progress {WarmupProgress} An object holding data about the progress of the current cache warmup */ public updateProgress(progress: WarmupProgress): void { - const percent = progress.getProgressInPercent(); - const failedCount = progress.getNumberOfFailedUrls(); - const {current, total} = progress.progress; + const currentUrl: string = progress.getCurrentUrl(); + const percent: number = progress.getProgressInPercent(); + const failedCount: number = progress.getNumberOfFailedUrls(); + const {current, total}: CrawlingProgress = progress.progress; this.$progressBar.addClass('progress-bar-animated active'); this.$progressBar.attr('aria-valuenow', current); @@ -103,19 +98,45 @@ class CacheWarmupProgressModal { if (failedCount > 0) { this.$progressBar.addClass('progress-bar-warning bg-warning'); this.$failedCounter.show().html( - Util.formatString(TYPO3.lang[LanguageKeys.modalProgressFailedCounter], failedCount.toString()) + StringHelper.formatString(TYPO3.lang[LanguageKeys.modalProgressFailedCounter], failedCount.toString()), ); } this.$allCounter.html( - Util.formatString(TYPO3.lang[LanguageKeys.modalProgressAllCounter], current.toString(), total.toString()) + StringHelper.formatString(TYPO3.lang[LanguageKeys.modalProgressAllCounter], current.toString(), total.toString()), ); + if (currentUrl !== '') { + this.$currentUrl.html(currentUrl); + } + if (progress.isFinished()) { this.$progressBar .removeClass('progress-bar-animated active') .removeClass('progress-bar-warning bg-warning') - .addClass(failedCount > 0 ? 'progress-bar-danger bg-danger' : 'progress-bar-success bg-success'); + .addClass(failedCount > 0 ? 'progress-bar-danger bg-danger' : 'progress-bar-success bg-success') + ; + this.$currentUrl.remove(); + } + } + + public finishProgress(progress: WarmupProgress, retryFunction: () => Promise): void { + // Build report modal on click on "open report" button + this.getReportButton() + .removeClass('hidden') + .off('click') + .on('click', (): void => { + ReportModal.createModal(progress, retryFunction) + }) + ; + + // Apply trigger function to "retry" button of progress modal + if (progress.state !== WarmupState.Aborted) { + this.getRetryButton() + .removeClass('hidden') + .off('click') + .on('click', retryFunction) + ; } } @@ -146,13 +167,6 @@ class CacheWarmupProgressModal { return this.$modal.find(`button[name=${CacheWarmupProgressModalButtonNames.retryButton}]`); } - /** - * Dismiss current modal. - */ - public dismiss(): void { - Modal.dismiss(); - } - /** * Build initial modal content and return its wrapper. * @@ -163,23 +177,27 @@ class CacheWarmupProgressModal { * @private */ private buildInitialModalContent(): JQuery { - const $content = $('
'); + const $content: JQuery = $('
'); this.$progressBar = $('
') .attr('role', 'progressbar') .attr('aria-valuemin', 0) .attr('aria-valuemax', 0) - .attr('aria-valuenow', 0); + .attr('aria-valuenow', 0) + ; this.$allCounter = $('
').html(TYPO3.lang[LanguageKeys.modalProgressPlaceholder]); this.$failedCounter = $('
'); + this.$currentUrl = $('
'); // Hide failed counter until any URL fails to be warmed up this.$failedCounter.hide(); - // Append progress bar and counter + // Append progress bar, counter and current url $content - .append($('
').append(this.$progressBar)) - .append($('
').append(this.$allCounter, this.$failedCounter)); + .append($('
').append(this.$progressBar)) + .append($('
').append(this.$allCounter, this.$failedCounter)) + .append(this.$currentUrl) + ; return $content; } @@ -192,33 +210,34 @@ class CacheWarmupProgressModal { * @private */ private createModalWithContent($content: JQuery): JQuery { - return Modal.advanced({ - title: TYPO3.lang[LanguageKeys.modalProgressTitle], - content: $content, - size: Modal.sizes.small, - buttons: [ - { - text: TYPO3.lang[LanguageKeys.modalProgressButtonReport], - icon: IconIdentifiers.listAlternative, - // Trigger is defined by external module, button is hidden in the meantime - btnClass: 'btn-primary hidden', - name: CacheWarmupProgressModalButtonNames.reportButton, - }, - { - text: TYPO3.lang[LanguageKeys.modalProgressButtonRetry], - icon: IconIdentifiers.refresh, - // Trigger is defined by external module, button is hidden in the meantime - btnClass: 'btn-default hidden', - name: CacheWarmupProgressModalButtonNames.retryButton, - }, - { - text: TYPO3.lang[LanguageKeys.modalProgressButtonClose], - btnClass: 'btn-default', - trigger: (): void => Modal.dismiss(), - }, - ] - }); + return $( + Modal.advanced({ + title: TYPO3.lang[LanguageKeys.modalProgressTitle], + content: $content, + size: Modal.sizes.small, + staticBackdrop: true, + buttons: [ + { + text: TYPO3.lang[LanguageKeys.modalProgressButtonReport], + icon: IconIdentifiers.listAlternative, + // Trigger is defined by external module, button is hidden in the meantime + btnClass: 'btn-primary hidden', + name: CacheWarmupProgressModalButtonNames.reportButton, + }, + { + text: TYPO3.lang[LanguageKeys.modalProgressButtonRetry], + icon: IconIdentifiers.refresh, + // Trigger is defined by external module, button is hidden in the meantime + btnClass: 'btn-default hidden', + name: CacheWarmupProgressModalButtonNames.retryButton, + }, + { + text: TYPO3.lang[LanguageKeys.modalProgressButtonClose], + btnClass: 'btn-default', + trigger: (): void => Modal.dismiss(), + }, + ], + }) + ); } } - -export default new CacheWarmupProgressModal(); diff --git a/Resources/Private/Frontend/src/scripts/backend/modal/report-modal.ts b/Resources/Private/Frontend/src/scripts/backend/modal/report-modal.ts new file mode 100644 index 00000000..12ff0084 --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/backend/modal/report-modal.ts @@ -0,0 +1,352 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2021 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import $ from 'jquery'; +import Icons from '@typo3/backend/icons.js'; +import ImmediateAction from '@typo3/backend/action-button/immediate-action.js'; +import Modal from '@typo3/backend/modal.js'; + +import {IconIdentifiers} from '@eliashaeussler/typo3-warming/enums/icon-identifiers'; +import {LanguageKeys} from '@eliashaeussler/typo3-warming/enums/language-keys'; +import {NotificationAction} from '@eliashaeussler/typo3-warming/cache-warmer'; +import {WarmupProgress} from '@eliashaeussler/typo3-warming/request/warmup-progress'; + +/** + * Modal with report about a finished cache warmup. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export class ReportModal { + private panelCount = 0; + + constructor( + private readonly progress: WarmupProgress, + ) { + } + + /** + * Create action for a new cache warmup report modal. + * + * @param progress {WarmupProgress} Progress of a cache warmup that is passed to the new modal + * @param retryFunction {() => Promise} Function to retry cache warmup + * @returns {NotificationAction} An object representing the created modal action + */ + public static createModalAction( + progress: WarmupProgress, + retryFunction: () => Promise + ): NotificationAction { + return { + label: TYPO3.lang[LanguageKeys.notificationShowReport], + action: new ImmediateAction((): void => { + ReportModal.createModal(progress, retryFunction); + }), + }; + } + + /** + * Create modal with cache warmup report. + * + * Creates a new modal that contains information about a finished cache warmup + * derived from the given warmup progress. + * + * @param progress {WarmupProgress} Progress of a finished cache warmup to be shown in the modal + * @param retryFunction {() => Promise} Function to retry cache warmup + */ + public static createModal( + progress: WarmupProgress, + retryFunction: () => Promise, + ): ReportModal { + const modal: ReportModal = new ReportModal(progress); + + Promise.all([ + Icons.getIcon(IconIdentifiers.readonly, Icons.sizes.medium), + Icons.getIcon(IconIdentifiers.approved, Icons.sizes.medium), + Icons.getIcon(IconIdentifiers.warning, Icons.sizes.medium), + Icons.getIcon(IconIdentifiers.viewPage, Icons.sizes.small), + ]) + .then(([readonlyIcon, approvedIcon, warningIcon, viewPageIcon]): void => { + // Ensure all other modals are closed + Modal.dismiss(); + + // Build content + const $content: JQuery = modal.buildModalContent(readonlyIcon, approvedIcon, warningIcon, viewPageIcon); + + // Get number of totally crawled pages + const totalText = progress.progress.current > 0 + ? `${TYPO3.lang[LanguageKeys.modalReportTotal]} ${progress.progress.current}` + : TYPO3.lang[LanguageKeys.modalReportNoUrlsCrawled]; + + // Open modal with crawling report + Modal.advanced({ + title: TYPO3.lang[LanguageKeys.modalReportTitle], + content: $content, + size: Modal.sizes.large, + buttons: [ + { + text: totalText, + icon: IconIdentifiers.exclamationCircle, + btnClass: 'disabled border-0', + }, + { + text: TYPO3.lang[LanguageKeys.modalProgressButtonRetry], + icon: IconIdentifiers.refresh, + btnClass: 'btn-default', + trigger: retryFunction, + }, + { + text: TYPO3.lang[LanguageKeys.modalProgressButtonClose], + btnClass: 'btn-default', + trigger: (): void => Modal.dismiss(), + }, + ], + }); + }); + + return modal; + } + + /** + * Create new panel for given URLs. + * + * Returns a panel that is integrated in the modal content. Each panel contains + * a title with several URLs. The given state is used as class name. + * + * @param title {string} Panel title + * @param state {string} Panel state, is applied as class name + * @param urls {string[]} Set of URLs to be listed in the panel + * @param viewPageIcon {string} Rendered "view page" icon that is appended to the panel + * @returns {JQuery} A {@link JQuery} object with the created panel + * @private + */ + private createPanel(title: string, state: string, urls: string[], viewPageIcon: string): JQuery { + // Create unique ID to toggle panel + const collapseId = `tx-warming-panel-${this.panelCount++}`; + + return $('
') + .addClass(`panel panel-${state}`) + .append( + $('
') + .addClass('panel-heading') + .append( + $('

') + .addClass('panel-title') + .append( + $('') + .addClass('collapsed') + .attr('href', `#${collapseId}`) + .attr('data-bs-toggle', 'collapse') + .attr('aria-controls', collapseId) + .attr('aria-expanded', 'false') + .append( + $('').addClass('caret'), + $('').text(` ${title} (${urls.length})`), + ) + ) + ), + $('
') + .attr('id', collapseId) + .addClass('panel-collapse collapse') + .append( + $('
') + .addClass('table-fit') + .append( + $('') + .addClass('table table-striped table-hover') + .append( + $('').append( + urls.map((url: string): JQuery => { + return $('').append( + $('
').text(url), + $('') + .addClass('col-control nowrap') + .append( + $('
') + .addClass('btn-group') + .append( + $('') + .attr('href', url) + .attr('target', '_blank') + .addClass('btn btn-default btn-sm nowrap') + .html(`${viewPageIcon} ${TYPO3.lang[LanguageKeys.modalReportActionView]}`) + ) + ) + ); + }), + ) + ) + ) + ) + ); + } + + private createSummaryCard( + title: string, + body: string, + state: string, + icon: string, + current: number, + total: number = null, + ): JQuery { + return $('
') + .addClass('col-4') + .append( + $('
') + .addClass(`card card-${state} h-100`) + .append( + $('
') + .addClass('card-header') + .append( + $('
') + .addClass('card-icon') + .html(icon), + $('
') + .addClass('card-header-body') + .append( + $('

') + .addClass('card-title') + .text(title), + $('') + .addClass('card-subtitle') + .html(total !== null ? `${current}/${total}` : current.toString()) + ) + ), + $('
') + .addClass('card-body') + .append( + $('

') + .addClass('card-text') + .text(body) + ) + ) + ); + } + + /** + * Build content for modal with panels for failed and successful URLs. + * + * @param readonlyIcon {string} Rendered "readonly" icon + * @param approvedIcon {string} Rendered "approved" icon + * @param warningIcon {string} Rendered "warning" icon + * @param viewPageIcon {string} Rendered "view page" icon + * @returns {JQuery} The modal content as {@link JQuery} object + * @private + */ + private buildModalContent( + readonlyIcon: string, + approvedIcon: string, + warningIcon: string, + viewPageIcon: string, + ): JQuery { + // Reset count of panels in report + this.panelCount = 0; + + // Count all excluded URLs and sitemaps + const excluded: number = this.progress.getNumberOfExcludedSitemaps() + this.progress.getNumberOfExcludedUrls(); + + // Initialize content container + const $cardContainer: JQuery = $('

').addClass('card-container'); + const $content: JQuery = $('
').append($cardContainer); + + // Add summary cards + if (this.progress.getNumberOfFailedUrls() > 0) { + $cardContainer.append( + this.createSummaryCard( + TYPO3.lang[LanguageKeys.modalReportPanelFailed], + TYPO3.lang[LanguageKeys.modalReportPanelFailedSummary], + 'danger', + readonlyIcon, + this.progress.getNumberOfFailedUrls(), + this.progress.progress.current, + ), + ); + } + if (this.progress.getNumberOfSuccessfulUrls() > 0) { + $cardContainer.append( + this.createSummaryCard( + TYPO3.lang[LanguageKeys.modalReportPanelSuccessful], + TYPO3.lang[LanguageKeys.modalReportPanelSuccessfulSummary], + 'success', + approvedIcon, + this.progress.getNumberOfSuccessfulUrls(), + this.progress.progress.current, + ), + ); + } + if (excluded > 0) { + $cardContainer.append( + this.createSummaryCard( + TYPO3.lang[LanguageKeys.modalReportPanelExcluded], + TYPO3.lang[LanguageKeys.modalReportPanelExcludedSummary], + 'warning', + warningIcon, + excluded, + ), + ); + } + + // Build panels from crawled URLs and the appropriate crawling states + if (this.progress.getNumberOfFailedUrls() > 0) { + $content.append( + this.createPanel( + TYPO3.lang[LanguageKeys.modalReportPanelFailed], + 'danger', + this.progress.urls.failed, + viewPageIcon, + ), + ); + } + if (this.progress.getNumberOfSuccessfulUrls() > 0) { + $content.append( + this.createPanel( + TYPO3.lang[LanguageKeys.modalReportPanelSuccessful], + 'success', + this.progress.urls.successful, + viewPageIcon, + ), + ); + } + + // Build panels from excluded sitemaps and URLs + if (this.progress.getNumberOfExcludedSitemaps() > 0) { + $content.append( + this.createPanel( + TYPO3.lang[LanguageKeys.modalReportPanelExcludedSitemaps], + 'warning', + this.progress.excluded.sitemaps, + viewPageIcon, + ), + ); + } + if (this.progress.getNumberOfExcludedUrls() > 0) { + $content.append( + this.createPanel( + TYPO3.lang[LanguageKeys.modalReportPanelExcludedUrls], + 'warning', + this.progress.excluded.urls, + viewPageIcon, + ), + ); + } + + return $content; + } +} diff --git a/Resources/Private/Frontend/src/scripts/backend/modal/sites-modal.ts b/Resources/Private/Frontend/src/scripts/backend/modal/sites-modal.ts new file mode 100644 index 00000000..8deaeb83 --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/backend/modal/sites-modal.ts @@ -0,0 +1,414 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2023 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import $ from 'jquery'; +import * as clipboard from 'clipboard-polyfill'; +import Icons from '@typo3/backend/icons.js'; +import Modal from '@typo3/backend/modal.js'; +import Notification from '@typo3/backend/notification.js'; + +import {CacheWarmer, SiteWarmupRequest, WarmingConfiguration} from '@eliashaeussler/typo3-warming/cache-warmer'; +import {IconIdentifiers} from '@eliashaeussler/typo3-warming/enums/icon-identifiers'; +import {LanguageKeys} from '@eliashaeussler/typo3-warming/enums/language-keys'; +import {SiteSelection} from '@eliashaeussler/typo3-warming/backend/modal/dto/site-selection'; + +enum SitesModalSelectors { + form = '.tx-warming-sites-modal', + siteCheckbox = '.tx-warming-sites-group-selector > input', + siteCheckboxAll = '.tx-warming-sites-group-selector > input[data-select-all]', + useragentCopy = 'button.tx-warming-user-agent-copy-action', + useragentCopyIcon = '.t3js-icon', + useragentCopyText = '.tx-warming-user-agent-copy-text', +} + +enum SitesModalButtonNames { + startButton = 'tx-warming-start-warmup', +} + +type FormValues = { + configuration: WarmingConfiguration, + sites: SiteWarmupRequest, +}; + +/** + * Modal with site selections used to start a new cache warmup. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export class SitesModal { + private modal!: HTMLElement; + + private readonly cacheWarmer: CacheWarmer; + + constructor() { + this.cacheWarmer = new CacheWarmer(); + this.createModal(); + } + + /** + * Create modal with available sites. + * + * Creates a new modal with content fetched as AJAX request. The modal contains + * all available sites used to start a new cache warmup. In addition, it shows + * the current user-agent and some adjustable cache warmup settings. + * + * Next to the modal content, a footer with a "start" button is added. The footer + * is hidden as long as no site is actively selected. + */ + private createModal(): void { + const url: URL = new URL(TYPO3.settings.ajaxUrls.tx_warming_fetch_sites, window.location.origin); + + // Ensure all other modals are closed + Modal.dismiss(); + + // Create new modal + this.modal = Modal.advanced({ + type: Modal.types.ajax, + content: url.toString(), + title: TYPO3.lang[LanguageKeys.modalSitesTitle], + size: Modal.sizes.medium, + buttons: [ + { + text: TYPO3.lang[LanguageKeys.modalSitesButtonStart], + icon: IconIdentifiers.rocket, + btnClass: 'btn-primary disabled', + name: SitesModalButtonNames.startButton, + trigger: (): void => { + $(this.modal).find(SitesModalSelectors.form).submit(); + }, + }, + ], + ajaxCallback: (element: HTMLElement): void => this.initializeSites(element), + }); + } + + /** + * Initialize sites within given modal body. + * + * @param modalBody {HTMLElement} Element referencing the modal body + * @private + */ + private initializeSites(modalBody: HTMLElement): void { + // Hide footer until site is selected + $(this.modal).find('.modal-footer').addClass('tx-warming-modal-footer').addClass('visually-hidden'); + + // Run cache warmup + $(modalBody).on('submit', SitesModalSelectors.form, (event: JQuery.TriggeredEvent): false => { + event.preventDefault(); + + this.performCacheWarmup(event.target); + + return false; + }); + + // Handle checked sites + $(modalBody).on('input', SitesModalSelectors.siteCheckbox, (event: JQuery.TriggeredEvent): void => { + $(this.modal).find('.modal-footer').removeClass('visually-hidden'); + this.toggleInputs(event.target); + }); + + // Copy user agent to clipboard in case the copy button is clicked + $(modalBody).on('click', SitesModalSelectors.useragentCopy, (event: JQuery.TriggeredEvent): void => { + event.preventDefault(); + event.stopImmediatePropagation(); + + const userAgent: string|undefined = $(event.currentTarget).attr('data-text'); + if (userAgent) { + SitesModal.copyUserAgentToClipboard(userAgent); + } + }); + } + + /** + * Start a new cache warmup request from the given form. + * + * @param form {HTMLFormElement} The form with selected sites and settings + * @private + */ + private performCacheWarmup(form: HTMLFormElement): void { + // Early return if no sites are selected + if (!this.areSitesSelected()) { + Notification.warning( + TYPO3.lang[LanguageKeys.notificationNoSitesSelectedTitle], + TYPO3.lang[LanguageKeys.notificationNoSitesSelectedMessage], + 15, + ); + + return; + } + + const {configuration, sites} = this.parseFormValues(form); + + this.cacheWarmer.warmupCache(sites, [], configuration); + } + + /** + * Parse values of given form. + * + * @param form {HTMLFormElement} The form whose values are to be parsed + * @returns {FormValues} Parsed form values + * @private + */ + private parseFormValues(form: HTMLFormElement): FormValues { + const formValues = $(form).serializeArray(); + const configuration: WarmingConfiguration = {}; + const sites: SiteWarmupRequest = {}; + + formValues.forEach(({name, value}) => { + switch (name) { + case 'site': + try { + const selection = SiteSelection.fromJson(value); + const site = selection.getSiteIdentifier(); + + if (!selection.isGroupRoot()) { + if (!(site in sites)) { + sites[site] = []; + } + sites[site].push(selection.getLanguageId()); + } + } catch (InvalidSiteSelectionException) { + // Continue with next input field. + } + break; + + case 'limit': + configuration.limit = parseInt(value); + break; + + case 'strategy': + configuration.strategy = value; + break; + } + }); + + return {configuration, sites}; + } + + /** + * Toggle site selection input fields, based on the given input element. + * + * @param element {HTMLInputElement} The element used to toggle other input fields + * @private + */ + private toggleInputs(element: HTMLInputElement): void { + if (element.dataset.selectAll) { + // Toggle all inputs + this.toggleAll(element.checked); + } else { + const selection: SiteSelection = SiteSelection.fromJson(element.value); + + // Toggle input groups + if (selection.isWithinGroup()) { + this.toggleGroup(selection, element); + } + + // Toggle select all + this.toggleSelectAll(element) + } + + // Toggle submit button + if (this.areSitesSelected()) { + this.getStartButton().removeClass('disabled'); + } else { + this.getStartButton().addClass('disabled'); + } + } + + /** + * Check if any input field of a site selection is checked. + * + * @private + */ + private areSitesSelected(): boolean { + let sitesAreSelected = false; + + this.getCheckboxes(true).each(function (): false|void { + if ((this as HTMLInputElement).checked) { + sitesAreSelected = true; + return false; + } + }); + + return sitesAreSelected; + } + + /** + * Toggle all site selections within a given group, identified by the given + * site selection and corresponding element. + * + * @param siteSelection {SiteSelection} Site selection whose input elements should be toggled + * @param element {HTMLInputElement} Current element used as reference for toggling other input fields + * @private + */ + private toggleGroup(siteSelection: SiteSelection, element: HTMLInputElement): void { + let checked = element.checked; + + if (siteSelection.getLanguageId() === null) { + this.getCheckboxesByGroup(siteSelection.getGroupName()).each(function (): void { + this.checked = checked; + }); + } else { + if (checked) { + this.getCheckboxesByGroup(siteSelection.getGroupName()).each(function (): false|void { + if (this.id !== element.id && !this.checked) { + checked = false; + return false; + } + }); + } + + this.getCheckboxGroupRoot(siteSelection.getGroupName()).checked = checked; + } + } + + /** + * Toggle all site selection input fields, based on the given state. + * + * @param checked {boolean} `true` if all input fields should be checked, `false` otherwise + * @private + */ + private toggleAll(checked: boolean): void { + this.getCheckboxes().each(function (): void { + this.checked = checked; + }); + } + + /** + * Toggle "select all" input field, based on the state of the given element. + * + * @param element {HTMLInputElement} Input element used as reference to toggle "select all" input field + * @private + */ + private toggleSelectAll(element: HTMLInputElement): void { + let checked = element.checked; + + if (checked) { + this.getCheckboxes(true).each(function (): false|void { + if (this.id !== element.id && !this.checked) { + checked = false; + return false; + } + }); + } + + this.getSelectAllCheckbox().checked = checked; + } + + /** + * Get all available checkboxes. + * + * @param excludeSelectAll {boolean} `true` if "select all" input field should be excluded, `false` otherwise + * @returns {JQuery} All queried checkboxes + * @private + */ + private getCheckboxes(excludeSelectAll = false): JQuery { + let selector = SitesModalSelectors.siteCheckbox + ':enabled'; + + if (excludeSelectAll) { + selector += ':not([data-select-all])'; + } + + return $(this.modal).find(selector) as JQuery; + } + + /** + * Get "select all" checkbox. + * + * @returns {HTMLInputElement | undefined} Reference to "select all" checkbox if available, `undefined` otherwise + * @private + */ + private getSelectAllCheckbox(): HTMLInputElement | undefined { + return ($(this.modal).find(SitesModalSelectors.siteCheckboxAll) as JQuery).get(0); + } + + /** + * Get all checkboxes of given group. + * + * @param groupName {string} Name of the group to query. + * @returns {JQuery<>HTMLInputElement>} List of checkboxes of the given group. + * @private + */ + private getCheckboxesByGroup(groupName: string): JQuery { + return $(this.modal).find(`input[data-group="${groupName}"]:enabled`) as JQuery; + } + + /** + * Get root checkbox of given group. + * + * @param groupName {string} Name of the group to query. + * @returns {HTMLInputElement | undefined} Reference to root checkbox of given group if available, `undefined` otherwise + * @private + */ + private getCheckboxGroupRoot(groupName: string): HTMLInputElement | undefined { + return ($(this.modal).find(`input[data-group-root="${groupName}"]:enabled`) as JQuery).get(0); + } + + /** + * Get "start" button within modal footer. + * + * @returns {JQuery} Reference to "start" button + * @private + */ + private getStartButton(): JQuery { + return $(this.modal).find(`button[name=${SitesModalButtonNames.startButton}]`); + } + + /** + * Copy given User-Agent header to clipboard. + * + * @param userAgent {string} User-Agent header to be copied to clipboard + * @private + */ + private static copyUserAgentToClipboard(userAgent: string): void { + const $copyIcon: JQuery = $(SitesModalSelectors.useragentCopyIcon, SitesModalSelectors.useragentCopy); + const $existingIcon: JQuery = $copyIcon.clone(); + + // Show spinner when copying user agent + Icons.getIcon(IconIdentifiers.spinner, Icons.sizes.small).then((spinner: string): void => { + $copyIcon.replaceWith(spinner); + }); + + // Copy user agent to clipboard + Promise.all([ + (navigator.clipboard ?? clipboard).writeText(userAgent), + Icons.getIcon(IconIdentifiers.check, Icons.sizes.small), + ]) + .then( + async ([, icon]): Promise => { + const existingText = $(SitesModalSelectors.useragentCopyText).text(); + $(SitesModalSelectors.useragentCopyText).text(TYPO3.lang[LanguageKeys.modalSitesUserAgentActionSuccessful]); + $(SitesModalSelectors.useragentCopyIcon, SitesModalSelectors.useragentCopy).replaceWith(icon); + + // Restore copy button after 3 seconds + window.setTimeout((): void => { + $(SitesModalSelectors.useragentCopyIcon, SitesModalSelectors.useragentCopy).replaceWith($existingIcon); + $(SitesModalSelectors.useragentCopyText).text(existingText); + $(SitesModalSelectors.useragentCopy).trigger('blur'); + }, 3000); + }, + (): void => { + $(SitesModalSelectors.useragentCopyIcon, SitesModalSelectors.useragentCopy).replaceWith($existingIcon); + } + ); + } +} diff --git a/Resources/Private/Frontend/src/scripts/backend/toolbar-menu.ts b/Resources/Private/Frontend/src/scripts/backend/toolbar-menu.ts new file mode 100644 index 00000000..25cc5308 --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/backend/toolbar-menu.ts @@ -0,0 +1,47 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2021 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import DocumentService from '@typo3/core/document-service.js'; +import RegularEvent from '@typo3/core/event/regular-event.js'; + +import {SitesModal} from '@eliashaeussler/typo3-warming/backend/modal/sites-modal'; + +enum CacheWarmupMenuSelectors { + container = '#eliashaeussler-typo3warming-backend-toolbaritems-cachewarmuptoolbaritem', +} + +/** + * Handle cache warmup from the Backend toolbar. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +class ToolbarMenu { + constructor() { + DocumentService.ready().then((): void => { + new RegularEvent('click', (): void => { + new SitesModal(); + }).delegateTo(document, CacheWarmupMenuSelectors.container); + }); + } +} + +export default new ToolbarMenu(); diff --git a/Resources/Private/Frontend/src/scripts/cache-warmer.ts b/Resources/Private/Frontend/src/scripts/cache-warmer.ts new file mode 100644 index 00000000..3f5dd2fd --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/cache-warmer.ts @@ -0,0 +1,248 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2023 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import {v4 as uuidv4} from 'uuid'; +import ImmediateAction from '@typo3/backend/action-button/immediate-action.js'; +import Notification from '@typo3/backend/notification.js'; + +import {AjaxRequestHandler} from '@eliashaeussler/typo3-warming/request/handler/ajax-request-handler'; +import {EventSourceRequestHandler} from '@eliashaeussler/typo3-warming/request/handler/event-source-request-handler'; +import {LanguageKeys} from '@eliashaeussler/typo3-warming/enums/language-keys'; +import {ReportModal} from '@eliashaeussler/typo3-warming/backend/modal/report-modal'; +import {RequestHandler} from '@eliashaeussler/typo3-warming/request/handler/request-handler'; +import {WarmupProgress} from '@eliashaeussler/typo3-warming/request/warmup-progress'; +import {WarmupState} from '@eliashaeussler/typo3-warming/enums/warmup-state'; + +/** + * Action for use within notifications. + */ +export type NotificationAction = { + label: string, + action: typeof ImmediateAction, +} + +/** + * Request object for cache warmup of sites. + */ +export type SiteWarmupRequest = {[key: string]: (number|null)[]}; + +/** + * Request object for cache warmup of pages. + */ +export type PageWarmupRequest = {[key: number]: (number|null)[]}; + +/** + * Optional configuration for cache warmup. + */ +export type WarmingConfiguration = { + limit?: number; + strategy?: string; +}; + +/** + * Perform cache warmup from TYPO3 backend. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export class CacheWarmer { + private readonly handler: RequestHandler; + + constructor() { + this.handler = this.initializeRequestHandler(); + } + + /** + * Trigger cache warmup for given sites and pages. + * + * Starts cache warmup using the configured {@link RequestHandler}. Once + * cache warmup is finished, a notification is shown. If it fails, an error + * notification is shown instead. + * + * @param sites {SiteWarmupRequest} Collection of site <> language combinations to be warmed up + * @param pages {PageWarmupRequest} Collection of page <> language combinations to be warmed up + * @param configuration {WarmingConfiguration} Optional configuration used for cache warmup + */ + public warmupCache( + sites: SiteWarmupRequest, + pages: PageWarmupRequest, + configuration: WarmingConfiguration = {}, + ): Promise { + const queryParams: URLSearchParams = this.buildQueryParams(sites, pages, configuration); + const retryFunction = () => this.warmupCache(sites, pages, configuration); + + return this.handler.startRequestWithQueryParams(queryParams, retryFunction) + .then( + // Success + (progress: WarmupProgress): WarmupProgress => { + let action: NotificationAction; + + // Add option to restart cache warmup if it has been aborted + if (progress.state === WarmupState.Aborted) { + action = { + label: TYPO3.lang[LanguageKeys.notificationActionRetry], + action: new ImmediateAction(retryFunction), + }; + } + + CacheWarmer.showNotification(progress, retryFunction, action); + + return progress; + }, + + // Error + (progress: WarmupProgress): WarmupProgress => { + CacheWarmer.errorNotification(); + + return progress; + }, + ); + } + + /** + * Create and return a supported request handler. + * + * @returns {RequestHandler} An instantiated request handler that supports the request type of this warmup request + * @private + */ + private initializeRequestHandler(): RequestHandler { + if (EventSourceRequestHandler.isSupported()) { + return new EventSourceRequestHandler(); + } + + return new AjaxRequestHandler(); + } + + /** + * Return set of query params to be used fo cache warmup requests. + * + * @param sites {SiteWarmupRequest} Collection of site <> language combinations to be warmed up + * @param pages {PageWarmupRequest} Collection of page <> language combinations to be warmed up + * @param configuration {WarmingConfiguration} Optional configuration used for cache warmup + * @returns {URLSearchParams} Set of query params to be used for cache warmup requests + * @private + */ + private buildQueryParams( + sites: SiteWarmupRequest, + pages: PageWarmupRequest, + configuration: WarmingConfiguration = {}, + ): URLSearchParams { + const queryParams: URLSearchParams = new URLSearchParams({ + requestId: CacheWarmer.generateRequestId(), + }); + + let siteCount = 0; + let pageCount = 0; + + for (const [site, languages] of Object.entries(sites)) { + const index = siteCount++; + + queryParams.set(`sites[${index}][site]`, site); + + languages.forEach((language: number) => queryParams.set(`sites[${index}][languageIds][]`, (language ?? 0).toString())); + } + + for (const [page, languages] of Object.entries(pages)) { + const index = pageCount++; + + queryParams.set(`pages[${index}][page]`, page.toString()); + + languages.forEach((language: number) => queryParams.set(`pages[${index}][languageIds][]`, (language ?? 0).toString())); + } + + for (const [key, value] of Object.entries(configuration)) { + queryParams.set(`configuration[${key}]`, value.toString()); + } + + return queryParams; + } + + /** + * Generate unique request ID. + * + * @returns {string} Unique request ID + * @private + */ + private static generateRequestId(): string { + return uuidv4(); + } + + /** + * Show notification for given cache warmup progress. + * + * @param progress {WarmupProgress} Progress of the cache warmup a notification is built for + * @param retryFunction {() => Promise} Function to retry cache warmup + * @param additionalAction {NotificationAction|null} Additional action to be used for the generated notification + * @private + */ + private static showNotification( + progress: WarmupProgress, + retryFunction: () => Promise, + additionalAction?: NotificationAction, + ): void { + let {title, message} = progress.response; + + // Create action to open full report as modal + const reportAction: NotificationAction = ReportModal.createModalAction(progress, retryFunction); + + // Define modal actions + const actions: NotificationAction[] = [reportAction]; + if (additionalAction) { + actions.push(additionalAction); + } + + // Show notification + switch (progress.state) { + case WarmupState.Failed: + Notification.error(title, message, 0, actions); + break; + case WarmupState.Warning: + Notification.warning(title, message, 0, actions); + break; + case WarmupState.Success: + Notification.success(title, message, 15, actions); + break; + case WarmupState.Aborted: + title = TYPO3.lang[LanguageKeys.notificationAbortedTitle]; + message = TYPO3.lang[LanguageKeys.notificationAbortedMessage]; + Notification.info(title, message, 15, actions); + break; + case WarmupState.Unknown: + Notification.notice(title, message, 15); + break; + default: + CacheWarmer.errorNotification(); + break; + } + } + + /** + * Show error notification on erroneous cache warmup. + * + * @private + */ + private static errorNotification(): void { + Notification.error( + TYPO3.lang[LanguageKeys.notificationErrorTitle], + TYPO3.lang[LanguageKeys.notificationErrorMessage], + ); + } +} diff --git a/Resources/Private/Frontend/src/scripts/lib/Enums/IconIdentifiers.ts b/Resources/Private/Frontend/src/scripts/enums/icon-identifiers.ts similarity index 83% rename from Resources/Private/Frontend/src/scripts/lib/Enums/IconIdentifiers.ts rename to Resources/Private/Frontend/src/scripts/enums/icon-identifiers.ts index bafe70ef..e95e56a2 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Enums/IconIdentifiers.ts +++ b/Resources/Private/Frontend/src/scripts/enums/icon-identifiers.ts @@ -25,13 +25,15 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -enum IconIdentifiers { +export enum IconIdentifiers { + approved = 'overlay-approved', check = 'actions-check', - info = 'content-info', + exclamationCircle = 'actions-exclamation-circle-alt', listAlternative = 'actions-list-alternative', + readonly = 'overlay-readonly', refresh = 'actions-refresh', + rocket = 'actions-rocket', spinner = 'spinner-circle-light', viewPage = 'actions-view-page', + warning = 'overlay-warning', } - -export default IconIdentifiers; diff --git a/Resources/Private/Frontend/src/scripts/enums/language-keys.ts b/Resources/Private/Frontend/src/scripts/enums/language-keys.ts new file mode 100644 index 00000000..a6623812 --- /dev/null +++ b/Resources/Private/Frontend/src/scripts/enums/language-keys.ts @@ -0,0 +1,66 @@ +'use strict' + +/* + * This file is part of the TYPO3 CMS extension "warming". + * + * Copyright (C) 2021 Elias Häußler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Several language keys that are used in custom modules. + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +export enum LanguageKeys { + // Notification + notificationShowReport = 'warming.notification.action.showReport', + notificationActionRetry = 'warming.notification.action.retry', + notificationAbortedTitle = 'warming.notification.aborted.title', + notificationAbortedMessage = 'warming.notification.aborted.message', + notificationErrorTitle = 'warming.notification.error.title', + notificationErrorMessage = 'warming.notification.error.message', + notificationNoSitesSelectedTitle = 'warming.notification.noSitesSelected.title', + notificationNoSitesSelectedMessage = 'warming.notification.noSitesSelected.message', + + // Progress Modal + modalProgressTitle = 'warming.modal.progress.title', + modalProgressButtonReport = 'warming.modal.progress.button.report', + modalProgressButtonRetry = 'warming.modal.progress.button.retry', + modalProgressButtonClose = 'warming.modal.progress.button.close', + modalProgressFailedCounter = 'warming.modal.progress.failedCounter', + modalProgressAllCounter = 'warming.modal.progress.allCounter', + modalProgressPlaceholder = 'warming.modal.progress.placeholder', + + // Report Modal + modalReportTitle = 'warming.modal.report.title', + modalReportPanelFailed = 'warming.modal.report.panel.failed', + modalReportPanelFailedSummary = 'warming.modal.report.panel.failed.summary', + modalReportPanelSuccessful = 'warming.modal.report.panel.successful', + modalReportPanelSuccessfulSummary = 'warming.modal.report.panel.successful.summary', + modalReportPanelExcluded = 'warming.modal.report.panel.excluded', + modalReportPanelExcludedSummary = 'warming.modal.report.panel.excluded.summary', + modalReportPanelExcludedSitemaps = 'warming.modal.report.panel.excluded.sitemaps', + modalReportPanelExcludedUrls = 'warming.modal.report.panel.excluded.urls', + modalReportActionView = 'warming.modal.report.action.view', + modalReportTotal = 'warming.modal.report.message.total', + modalReportNoUrlsCrawled = 'warming.modal.report.message.noUrlsCrawled', + + // Sites Modal + modalSitesTitle = 'warming.modal.sites.title', + modalSitesUserAgentActionSuccessful = 'warming.modal.sites.userAgent.action.successful', + modalSitesButtonStart = 'warming.modal.sites.button.start', +} diff --git a/Resources/Private/Frontend/src/scripts/lib/Enums/WarmupState.ts b/Resources/Private/Frontend/src/scripts/enums/warmup-state.ts similarity index 95% rename from Resources/Private/Frontend/src/scripts/lib/Enums/WarmupState.ts rename to Resources/Private/Frontend/src/scripts/enums/warmup-state.ts index 6914e4f6..f54e1b65 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Enums/WarmupState.ts +++ b/Resources/Private/Frontend/src/scripts/enums/warmup-state.ts @@ -25,12 +25,10 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -enum WarmupState { +export enum WarmupState { Failed = 'failed', Warning = 'warning', Success = 'success', Aborted = 'aborted', Unknown = 'unknown', } - -export default WarmupState; diff --git a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingState.ts b/Resources/Private/Frontend/src/scripts/exception/invalid-site-selection-exception.ts similarity index 73% rename from Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingState.ts rename to Resources/Private/Frontend/src/scripts/exception/invalid-site-selection-exception.ts index ee4dadb7..3612d005 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingState.ts +++ b/Resources/Private/Frontend/src/scripts/exception/invalid-site-selection-exception.ts @@ -1,9 +1,9 @@ -'use strict' +'use strict'; /* * This file is part of the TYPO3 CMS extension "warming". * - * Copyright (C) 2021 Elias Häußler + * Copyright (C) 2023 Elias Häußler * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,12 +20,11 @@ */ /** - * Interface describing a crawling state, containing all processed URLs. - * * @author Elias Häußler * @license GPL-2.0-or-later */ -export default interface CrawlingState { - failed: string[]; - successful: string[]; +export class InvalidSiteSelectionException extends Error { + public static create(): InvalidSiteSelectionException { + return new InvalidSiteSelectionException('The given site selection object is invalid.'); + } } diff --git a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingProgress.ts b/Resources/Private/Frontend/src/scripts/exception/missing-site-identifier-exception.ts similarity index 80% rename from Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingProgress.ts rename to Resources/Private/Frontend/src/scripts/exception/missing-site-identifier-exception.ts index cb5471f6..55cc448e 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Crawler/CrawlingProgress.ts +++ b/Resources/Private/Frontend/src/scripts/exception/missing-site-identifier-exception.ts @@ -1,4 +1,4 @@ -'use strict' +'use strict'; /* * This file is part of the TYPO3 CMS extension "warming". @@ -20,12 +20,11 @@ */ /** - * Interface describing the current crawling progress. - * * @author Elias Häußler * @license GPL-2.0-or-later */ -export default interface CrawlingProgress { - current: number; - total: number; +export class MissingSiteIdentifierException extends Error { + public static create(): MissingSiteIdentifierException { + return new MissingSiteIdentifierException('No site identifier found.'); + } } diff --git a/Resources/Private/Frontend/src/scripts/lib/Util.ts b/Resources/Private/Frontend/src/scripts/helper/string-helper.ts similarity index 67% rename from Resources/Private/Frontend/src/scripts/lib/Util.ts rename to Resources/Private/Frontend/src/scripts/helper/string-helper.ts index ae24d2e8..0c6bad20 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Util.ts +++ b/Resources/Private/Frontend/src/scripts/helper/string-helper.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + 'use strict' /* @@ -20,28 +22,12 @@ */ /** - * Collection of utility functions. + * Collection of string utility functions. * * @author Elias Häußler * @license GPL-2.0-or-later */ -export default class Util { - /** - * Append given query parameters to query parameters of given URL. - * - * @param url {URL} URL whose query params should be extended - * @param queryParams {URLSearchParams} Collection of query parameters to be appended to the given URL - * @returns {URL} The modified URL - */ - public static mergeUrlWithQueryParams(url: URL, queryParams: URLSearchParams): URL { - for (const [name, value] of queryParams.entries()) { - url.searchParams.append(name, value); - } - - return url; - } - - /* eslint-disable @typescript-eslint/no-explicit-any */ +export class StringHelper { /** * Get formatted string. * @@ -55,5 +41,4 @@ export default class Util { public static formatString(format: string, ...values: any[]): string { return values.reduce((p: string, c: any, index: number): string => p.replace(new RegExp(`\\{${index}}`), c), format); } - /* eslint-enable @typescript-eslint/no-explicit-any */ } diff --git a/Resources/Private/Frontend/src/scripts/lib/Exception/UnsupportedRequestTypeException.ts b/Resources/Private/Frontend/src/scripts/helper/url-helper.ts similarity index 61% rename from Resources/Private/Frontend/src/scripts/lib/Exception/UnsupportedRequestTypeException.ts rename to Resources/Private/Frontend/src/scripts/helper/url-helper.ts index 8ff37352..e13da0e5 100644 --- a/Resources/Private/Frontend/src/scripts/lib/Exception/UnsupportedRequestTypeException.ts +++ b/Resources/Private/Frontend/src/scripts/helper/url-helper.ts @@ -19,16 +19,25 @@ * along with this program. If not, see . */ -import WarmupRequestType from '../Enums/WarmupRequestType'; - /** - * Exception describing an unsupported warmup request type. + * Collection of URL utility functions. * * @author Elias Häußler * @license GPL-2.0-or-later */ -export default class UnsupportedRequestTypeException extends Error { - public static create(requestType: WarmupRequestType): UnsupportedRequestTypeException { - return new this(`The given request type "${requestType}" is not supported.`); +export class UrlHelper { + /** + * Append given query parameters to query parameters of given URL. + * + * @param url {URL} URL whose query params should be extended + * @param queryParams {URLSearchParams} Collection of query parameters to be appended to the given URL + * @returns {URL} The modified URL + */ + public static mergeUrlWithQueryParams(url: URL, queryParams: URLSearchParams): URL { + for (const [name, value] of queryParams.entries()) { + url.searchParams.append(name, value); + } + + return url; } } diff --git a/Resources/Private/Frontend/src/scripts/lib/Enums/LanguageKeys.ts b/Resources/Private/Frontend/src/scripts/lib/Enums/LanguageKeys.ts deleted file mode 100644 index e22e1fc9..00000000 --- a/Resources/Private/Frontend/src/scripts/lib/Enums/LanguageKeys.ts +++ /dev/null @@ -1,60 +0,0 @@ -'use strict' - -/* - * This file is part of the TYPO3 CMS extension "warming". - * - * Copyright (C) 2021 Elias Häußler - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/** - * Several language keys that are used in custom modules. - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -enum LanguageKeys { - // Toolbar - toolbarSitemapMissing = 'cacheWarmup.toolbar.sitemap.missing', - toolbarSitemapPlaceholder = 'cacheWarmup.toolbar.sitemap.placeholder', - toolbarCopySuccessful = 'cacheWarmup.toolbar.copy.successful', - - // Notification - notificationShowReport = 'cacheWarmup.notification.action.showReport', - notificationActionRetry = 'cacheWarmup.notification.action.retry', - notificationAbortedTitle = 'cacheWarmup.notification.aborted.title', - notificationAbortedMessage = 'cacheWarmup.notification.aborted.message', - notificationErrorTitle = 'cacheWarmup.notification.error.title', - notificationErrorMessage = 'cacheWarmup.notification.error.message', - - // Report Modal - modalReportTitle = 'cacheWarmup.modal.report.title', - modalReportPanelFailed = 'cacheWarmup.modal.report.panel.failed', - modalReportPanelSuccessful = 'cacheWarmup.modal.report.panel.successful', - modalReportActionView = 'cacheWarmup.modal.report.action.view', - modalReportTotal = 'cacheWarmup.modal.report.message.total', - modalReportNoUrlsCrawled = 'cacheWarmup.modal.report.message.noUrlsCrawled', - - // Progress Modal - modalProgressTitle = 'cacheWarmup.modal.progress.title', - modalProgressButtonReport = 'cacheWarmup.modal.progress.button.report', - modalProgressButtonRetry = 'cacheWarmup.modal.progress.button.retry', - modalProgressButtonClose = 'cacheWarmup.modal.progress.button.close', - modalProgressFailedCounter = 'cacheWarmup.modal.progress.failedCounter', - modalProgressAllCounter = 'cacheWarmup.modal.progress.allCounter', - modalProgressPlaceholder = 'cacheWarmup.modal.progress.placeholder', -} - -export default LanguageKeys; diff --git a/Resources/Private/Frontend/src/scripts/lib/RequestHandler/AjaxRequestHandler.ts b/Resources/Private/Frontend/src/scripts/lib/RequestHandler/AjaxRequestHandler.ts deleted file mode 100644 index a3d664a2..00000000 --- a/Resources/Private/Frontend/src/scripts/lib/RequestHandler/AjaxRequestHandler.ts +++ /dev/null @@ -1,58 +0,0 @@ -'use strict' - -/* - * This file is part of the TYPO3 CMS extension "warming". - * - * Copyright (C) 2021 Elias Häußler - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import RequestHandlerInterface from './RequestHandlerInterface'; -import Util from '../Util'; -import WarmupProgress from '../WarmupProgress'; - -// Modules -import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest'; -import AjaxResponse from 'TYPO3/CMS/Core/Ajax/AjaxResponse'; - -/** - * Cache warmup request handler using AJAX requests. - * - * This class represents an request handler for cache warmup requests, handled - * by an AJAX request. It should only be used if the preferred request handler, - * {@link EventSourceRequestHandler}, is not available since it is not able to - * display the current progress of a concrete warmup request. - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -export default class AjaxRequestHandler implements RequestHandlerInterface { - public startRequestWithQueryParams(queryParams:URLSearchParams): Promise { - return (new AjaxRequest(this.getUrl(queryParams).toString())) - .post({}) - .then( - async (response: typeof AjaxResponse): Promise => { - const data = await response.resolve(); - return new WarmupProgress(data); - } - ); - } - - public getUrl(queryParams: URLSearchParams): URL { - const url = new URL(TYPO3.settings.ajaxUrls.tx_warming_cache_warmup_legacy, window.location.origin); - - return Util.mergeUrlWithQueryParams(url, queryParams); - } -} diff --git a/Resources/Private/Frontend/src/scripts/lib/WarmupRequest.ts b/Resources/Private/Frontend/src/scripts/lib/WarmupRequest.ts deleted file mode 100644 index 033e5cc6..00000000 --- a/Resources/Private/Frontend/src/scripts/lib/WarmupRequest.ts +++ /dev/null @@ -1,122 +0,0 @@ -'use strict' - -/* - * This file is part of the TYPO3 CMS extension "warming". - * - * Copyright (C) 2021 Elias Häußler - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import {v4 as uuidv4} from 'uuid'; - -import AjaxRequestHandler from './RequestHandler/AjaxRequestHandler'; -import EventSourceRequestHandler from './RequestHandler/EventSourceRequestHandler'; -import RequestHandlerInterface from "./RequestHandler/RequestHandlerInterface"; -import UnsupportedRequestTypeException from './Exception/UnsupportedRequestTypeException'; -import WarmupRequestMode from './Enums/WarmupRequestMode'; -import WarmupRequestType from './Enums/WarmupRequestType'; -import WarmupProgress from './WarmupProgress'; - -/** - * Request to process a new cache warmup for a given page or site. - * - * This class represents a complete request for cache warmup of a given page or site. - * It uses a concrete {@link RequestHandlerInterface}, depending on the availability - * of the concrete handlers. - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -export default class WarmupRequest { - public readonly requestType: WarmupRequestType; - private readonly requestId: string; - private readonly pageId: number; - private readonly mode: WarmupRequestMode; - private readonly languageId: number | null; - - constructor(pageId: number, mode: WarmupRequestMode = WarmupRequestMode.Site, languageId: number | null = null) { - this.requestType = EventSourceRequestHandler.isSupported() ? WarmupRequestType.EventSource : WarmupRequestType.Ajax; - this.requestId = WarmupRequest.generateRequestId(); - this.pageId = pageId; - this.mode = mode; - this.languageId = languageId; - } - - /** - * Trigger new cache warmup using a concrete request handler. - * - * Uses a concrete request handler to trigger a new cache warmup request on the - * server. The warmup progress is returned as Promise that resolves to a concrete - * {@link WarmupProgress}. - * - * @returns {Promise} A promise for the the current request that resolves to an instance of {@link WarmupProgress} - */ - public runWarmup(): Promise { - const handler = this.initializeRequestHandler(); - - return handler.startRequestWithQueryParams(this.getQueryParams()); - } - - /** - * Create and return a supported request handler. - * - * @returns {RequestHandlerInterface} An instantiated request handler that supports the request type of this warmup request - * @throws {UnsupportedRequestTypeException} if the request type of this warmup request is not supported by any request handler - * @private - */ - private initializeRequestHandler(): RequestHandlerInterface { - switch (this.requestType) { - case WarmupRequestType.EventSource: - return new EventSourceRequestHandler(); - - case WarmupRequestType.Ajax: - return new AjaxRequestHandler(); - - default: - throw UnsupportedRequestTypeException.create(this.requestType); - } - } - - /** - * Return set of query params to be used fo cache warmup requests. - * - * @returns {URLSearchParams} Set of query params to be used for cache warmup requests - * @private - */ - private getQueryParams(): URLSearchParams { - const queryParams: { [key: string]: string } = { - pageId: this.pageId.toString(), - mode: this.mode, - requestId: this.requestId, - }; - - // Add language ID only if it's explicitly set (default language is used otherwise) - if (null !== this.languageId) { - queryParams.languageId = this.languageId.toString(); - } - - return new URLSearchParams(queryParams); - } - - /** - * Generate unique request ID. - * - * @returns {string} Unique request ID - * @private - */ - private static generateRequestId(): string { - return uuidv4(); - } -} diff --git a/Resources/Private/Frontend/src/scripts/modules/Backend/ContextMenu/CacheWarmupContextMenuAction.ts b/Resources/Private/Frontend/src/scripts/modules/Backend/ContextMenu/CacheWarmupContextMenuAction.ts deleted file mode 100644 index 573c1167..00000000 --- a/Resources/Private/Frontend/src/scripts/modules/Backend/ContextMenu/CacheWarmupContextMenuAction.ts +++ /dev/null @@ -1,89 +0,0 @@ -'use strict' - -/* - * This file is part of the TYPO3 CMS extension "warming". - * - * Copyright (C) 2021 Elias Häußler - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import WarmupRequestMode from '../../../lib/Enums/WarmupRequestMode'; - -// Modules -import $ from 'jquery'; -import CacheWarmupMenu from '../Toolbar/CacheWarmupMenu'; - -/** - * AMD module that allows running cache warmup from the SVG tree context menu. - * - * Module: TYPO3/CMS/Warming/Backend/ContextMenu/CacheWarmupContextMenuAction - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -class CacheWarmupContextMenuAction { - /** - * Trigger cache warmup for a specific page, identified by the given UID. - * - * @param table {string} Table name associated to the triggered SVG tree - * @param uid {number} UID of the associated element within the triggered SVG tree - */ - public static warmupPageCache(table: string, uid: number): void { - if ('pages' === table) { - const languageId = CacheWarmupContextMenuAction.determineLanguage($(this) as unknown as JQuery); - CacheWarmupMenu.warmupCache(uid, WarmupRequestMode.Page, languageId); - } - } - - /** - * Trigger cache warmup for a specific site, identified by the given UID. - * - * @param table {string} Table name associated to the triggered SVG tree - * @param uid {number} UID of the associated element within the triggered SVG tree - */ - public static warmupSiteCache(table: string, uid: number): void { - if ('pages' === table) { - const languageId = CacheWarmupContextMenuAction.determineLanguage($(this) as unknown as JQuery); - CacheWarmupMenu.warmupCache(uid, WarmupRequestMode.Site, languageId); - } - } - - /** - * Determine requested language ID from current context. - * - * Tests whether a language ID is defined in the current context and - * returns it, otherwise `NULL` is returned. The language ID is defined - * as `data-language-id` attribute in the current context. - * - * @param {JQuery} $context Current context to be evaluated - * @returns {number|null} The resolved language ID or `NULL` - * @private - */ - private static determineLanguage($context: JQuery): number|null { - if ('undefined' === typeof $context.data('language-id')) { - return null; - } - - return parseInt($context.data('language-id')); - } -} - -export default new CacheWarmupContextMenuAction(); - -// We need to export the static methods separately to ensure those functions -// can be properly triggered by ContextMenu.ts from sysext EXT:backend, see -// https://github.com/TYPO3/TYPO3.CMS/blob/bb831f2272815cae672dd382161f0bb9e6123b8e/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/ContextMenu.ts#L200 -export const warmupPageCache = CacheWarmupContextMenuAction.warmupPageCache; -export const warmupSiteCache = CacheWarmupContextMenuAction.warmupSiteCache; diff --git a/Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupReportModal.ts b/Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupReportModal.ts deleted file mode 100644 index 1174dbf6..00000000 --- a/Resources/Private/Frontend/src/scripts/modules/Backend/Modal/CacheWarmupReportModal.ts +++ /dev/null @@ -1,184 +0,0 @@ -'use strict' - -/* - * This file is part of the TYPO3 CMS extension "warming". - * - * Copyright (C) 2021 Elias Häußler - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import IconIdentifiers from '../../../lib/Enums/IconIdentifiers'; -import LanguageKeys from '../../../lib/Enums/LanguageKeys'; -import WarmupProgress from '../../../lib/WarmupProgress'; - -// Modules -import $ from 'jquery'; -import ImmediateAction from 'TYPO3/CMS/Backend/ActionButton/ImmediateAction'; -import Icons from 'TYPO3/CMS/Backend/Icons'; -import Modal from 'TYPO3/CMS/Backend/Modal'; - - -/** - * AMD module that shows a modal with report about a finished cache warmup. - * - * Module: TYPO3/CMS/Warming/Backend/Modal/CacheWarmupReportModal - * - * @author Elias Häußler - * @license GPL-2.0-or-later - */ -class CacheWarmupReportModal { - private progress!: WarmupProgress; - private panelCount = 0; - - /** - * Create action for a new cache warmup report modal. - * - * @param progress {WarmupProgress} Progress of a cache warmup that is passed to the new modal - * @returns {object} An object representing the created modal action - */ - public createModalAction(progress: WarmupProgress): { label: string, action: typeof ImmediateAction } { - return { - label: TYPO3.lang[LanguageKeys.notificationShowReport], - action: new ImmediateAction((): void => this.createModal(progress)), - }; - } - - /** - * Create modal with cache warmup report. - * - * Creates a new modal that contains information about a finished cache warmup - * derived from the given warmup progress. - * - * @param progress {WarmupProgress} Progress of a finished cache warmup to be shown in the modal - */ - public createModal(progress: WarmupProgress): void { - this.progress = progress; - - Promise.all([ - Icons.getIcon(IconIdentifiers.viewPage, Icons.sizes.small), - Icons.getIcon(IconIdentifiers.info, Icons.sizes.small), - ]) - .then(([viewPageIcon, infoIcon]): void => { - // Ensure all other modals are closed - Modal.dismiss(); - - // Build content - const $content = this.buildModalContent(viewPageIcon, infoIcon); - - // Open modal with crawling report - Modal.advanced({ - title: TYPO3.lang[LanguageKeys.modalReportTitle], - content: $content, - size: Modal.sizes.large, - }); - }); - } - - /** - * Create new panel for given URLs. - * - * Returns a panel that is integrated in the modal content. Each panel contains - * a title with several URLs. The given state is used as class name. - * - * @param title {string} Panel title - * @param state {string} Panel state, is applied as class name - * @param urls {string[]} Set of URLs to be listed in the panel - * @param viewPageIcon {string} Rendered "view page" icon that is appended to the panel - * @returns {JQuery} A {@link JQuery} object with the created panel - * @private - */ - private createPanel(title: string, state: string, urls: string[], viewPageIcon: string): JQuery { - this.panelCount++; - - return $('