[FEATURE] Auto slug update and redirect creation on slug change 13/61613/50
authorFrank Naegler <frank.naegler@typo3.org>
Sat, 28 Sep 2019 13:51:54 +0000 (15:51 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Sun, 29 Sep 2019 08:58:56 +0000 (10:58 +0200)
If EXT:redirects is installed and a slug is updated by a backend user,
a redirect from the old URL to the new URL will be created.
All sub pages are checked too and the slugs will be updated.

Resolves: #89115
Releases: master
Change-Id: Id0b09cb22681aa6b2704b4f7cbc47d8b747e56d4
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61613
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
19 files changed:
Build/Sources/TypeScript/redirects/Resources/Public/TypeScript/EventHandler.ts [new file with mode: 0644]
Build/tsconfig.json
typo3/sysext/core/Documentation/Changelog/master/Feature-89115-Auto-createRedirectsOnSlugChanges.rst [new file with mode: 0644]
typo3/sysext/redirects/Classes/Controller/RecordHistoryRollbackController.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/EventListener/RecordHistoryRollbackEventsListener.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Hooks/BackendControllerHook.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Hooks/DataHandlerSlugUpdateHook.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Service/SlugService.php [new file with mode: 0644]
typo3/sysext/redirects/Configuration/Backend/AjaxRoutes.php [new file with mode: 0644]
typo3/sysext/redirects/Configuration/Services.yaml
typo3/sysext/redirects/Resources/Private/Language/locallang.xlf [new file with mode: 0644]
typo3/sysext/redirects/Resources/Private/Language/locallang_slug_service.xlf [new file with mode: 0644]
typo3/sysext/redirects/Resources/Public/JavaScript/EventHandler.js [new file with mode: 0644]
typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test1.xml [new file with mode: 0644]
typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test2.xml [new file with mode: 0644]
typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test3.xml [new file with mode: 0644]
typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php [new file with mode: 0644]
typo3/sysext/redirects/composer.json
typo3/sysext/redirects/ext_localconf.php

diff --git a/Build/Sources/TypeScript/redirects/Resources/Public/TypeScript/EventHandler.ts b/Build/Sources/TypeScript/redirects/Resources/Public/TypeScript/EventHandler.ts
new file mode 100644 (file)
index 0000000..bbd01d0
--- /dev/null
@@ -0,0 +1,68 @@
+import * as $ from 'jquery';
+import NotificationService = require('TYPO3/CMS/Backend/Notification');
+import DeferredAction = require('TYPO3/CMS/Backend/ActionButton/DeferredAction');
+
+class EventHandler {
+  public constructor() {
+    document.addEventListener(
+      'typo3:redirects:slugChanged',
+      (evt: CustomEvent) => this.onSlugChanged(evt.detail),
+    );
+  }
+
+  public onSlugChanged(detail: any): void {
+    let actions: any = [];
+    const correlations = detail.correlations;
+
+    if (detail.autoUpdateSlugs) {
+      actions.push({
+        label: TYPO3.lang['notification.redirects.button.revert_update'],
+        action: new DeferredAction(() => this.revert([
+          correlations.correlationIdSlugUpdate,
+          correlations.correlationIdRedirectCreation,
+        ])),
+      });
+    }
+    if (detail.autoCreateRedirects) {
+      actions.push({
+        label: TYPO3.lang['notification.redirects.button.revert_redirect'],
+        action: new DeferredAction(() => this.revert([
+          correlations.correlationIdRedirectCreation,
+        ])),
+      });
+    }
+
+    let title = TYPO3.lang['notification.slug_only.title'];
+    let message = TYPO3.lang['notification.slug_only.message'];
+    if (detail.autoCreateRedirects) {
+      title = TYPO3.lang['notification.slug_and_redirects.title'];
+      message = TYPO3.lang['notification.slug_and_redirects.message'];
+    }
+    NotificationService.info(
+      title,
+      message,
+      0,
+      actions,
+    );
+  }
+
+  private revert(correlationIds: string[]): void {
+    $.ajax({
+      url: TYPO3.settings.ajaxUrls.redirects_revert_correlation,
+      data: {
+        correlation_ids: correlationIds,
+      },
+    }).done((json: any) => {
+      if (json.status === 'ok') {
+        NotificationService.success(json.title, json.message);
+      }
+      if (json.status === 'error') {
+        NotificationService.error(json.title, json.message);
+      }
+    }).fail(() => {
+      NotificationService.error(TYPO3.lang.redirects_error_title, TYPO3.lang.redirects_error_message);
+    });
+  }
+}
+
+export = new EventHandler();
index fbc25e3..6838f62 100644 (file)
                 "../typo3/sysext/recycler/Resources/Public/JavaScript/*",
                 "recycler/Resources/Public/TypeScript/*"
             ],
+            "TYPO3/CMS/Redirects/*": [
+                "../typo3/sysext/redirects/Resources/Public/JavaScript/*",
+                "redirects/Resources/Public/TypeScript/*"
+            ],
             "TYPO3/CMS/RteCkeditor/*": [
                 "../typo3/sysext/rte_ckeditor/Resources/Public/JavaScript/*",
                 "rte_ckeditor/Resources/Public/TypeScript/*"
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89115-Auto-createRedirectsOnSlugChanges.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89115-Auto-createRedirectsOnSlugChanges.rst
new file mode 100644 (file)
index 0000000..60552c8
--- /dev/null
@@ -0,0 +1,53 @@
+.. include:: ../../Includes.txt
+
+=======================================================================
+Feature: #89115 - Auto slug update and redirect creation on slug change
+=======================================================================
+
+See :issue:`89115`
+
+Description
+===========
+
+If EXT:redirects is installed and a slug is updated by a backend user,
+a redirect from the old URL to the new URL will be created.
+All sub pages are checked too and the slugs will be updated.
+
+After the creation of the redirects a notification will be shown to the user.
+
+The notification contains two possible actions:
+
+* revert the complete slug update and remove the redirects
+* or only remove the redirects
+
+This new behaviour can be configured by site configuration (Example for your :file:`config.yaml`):
+
+.. code-block:: yaml
+
+   settings:
+      redirects:
+        # Automatically update slugs of all sub pages
+        # (default: true)
+        autoUpdateSlugs: true
+        # Automatically create redirects for pages with a new slug (works only in LIVE workspace)
+        # (default: true)
+        autoCreateRedirects: true
+        # Time To Live in days for redirect records to be created - `0` disables TTL, no expiration
+        # (default: 0)
+        redirectTTL: 30
+        # HTTP status code for the redirect, see
+        # https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections
+        # (default: 307)
+        httpStatusCode: 307
+
+.. warning::
+
+   This API is considered experimental and may change anytime until declared being stable.
+   For example there exists plans for moving the settings out of the :file:`config.yaml` file.
+
+.. warning::
+
+   For any changes within a non live workspace, the redirect creation is disabled.
+   The :yaml:`settings.redirect.autoCreateRedirects` setting is overwritten in this case.
+
+.. index:: Backend, ext:redirects
diff --git a/typo3/sysext/redirects/Classes/Controller/RecordHistoryRollbackController.php b/typo3/sysext/redirects/Classes/Controller/RecordHistoryRollbackController.php
new file mode 100644 (file)
index 0000000..0e55788
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Controller;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\History\RecordHistory;
+use TYPO3\CMS\Backend\History\RecordHistoryRollback;
+use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
+use TYPO3\CMS\Core\Http\JsonResponse;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Redirects\Service\SlugService;
+
+/**
+ * @internal
+ */
+class RecordHistoryRollbackController
+{
+    /**
+     * @var LanguageService
+     */
+    protected $languageService;
+
+    public function __construct(LanguageService $languageService)
+    {
+        $this->languageService = $languageService;
+    }
+
+    /**
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function revertCorrelation(ServerRequestInterface $request): ResponseInterface
+    {
+        $revertedCorrelationTypes = [];
+        $correlationIds = $request->getQueryParams()['correlation_ids'] ?? [];
+        /** @var CorrelationId[] $correlationIds */
+        $correlationIds = array_map(
+            function (string $correlationId) {
+                return CorrelationId::fromString($correlationId);
+            },
+            $correlationIds
+        );
+        foreach ($correlationIds as $correlationId) {
+            $aspects = $correlationId->getAspects();
+            if (count($aspects) < 2 || $aspects[0] !== SlugService::CORRELATION_ID_IDENTIFIER) {
+                continue;
+            }
+            $revertedCorrelationTypes[] = $correlationId->getAspects()[1];
+            $this->rollBackCorrelation($correlationId);
+        }
+        $result = [
+            'status' => 'error',
+            'title' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:redirects_error_title'),
+            'message' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:redirects_error_message')
+        ];
+        if (in_array('redirect', $revertedCorrelationTypes, true)) {
+            $result = [
+                'status' => 'ok',
+                'title' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_redirects_success_title'),
+                'message' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_redirects_success_message')
+            ];
+            if (in_array('slug', $revertedCorrelationTypes, true)) {
+                $result = [
+                    'status' => 'ok',
+                    'title' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_update_success_title'),
+                    'message' => $this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_update_success_message')
+                ];
+            }
+        }
+        return (new JsonResponse())->setPayload($result);
+    }
+
+    protected function rollBackCorrelation(CorrelationId $correlationId): void
+    {
+        $recordHistoryRollback = GeneralUtility::makeInstance(RecordHistoryRollback::class);
+        foreach (GeneralUtility::makeInstance(RecordHistory::class)->findEventsForCorrelation((string)$correlationId) as $recordHistoryEntry) {
+            $element = $recordHistoryEntry['tablename'] . ':' . $recordHistoryEntry['recuid'];
+            $tempRecordHistory = GeneralUtility::makeInstance(RecordHistory::class, $element);
+            $tempRecordHistory->setLastHistoryEntryNumber($recordHistoryEntry['uid']);
+            $recordHistoryRollback->performRollback('ALL', $tempRecordHistory->getDiff($tempRecordHistory->getChangeLog()));
+        }
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/EventListener/RecordHistoryRollbackEventsListener.php b/typo3/sysext/redirects/Classes/EventListener/RecordHistoryRollbackEventsListener.php
new file mode 100644 (file)
index 0000000..49367a9
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\EventListener;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+class RecordHistoryRollbackEventsListener
+{
+    public function afterHistoryRollbackFinishedEvent(): void
+    {
+        // Re-Enable hook to after rollback finished
+        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] =
+            \TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook::class;
+    }
+
+    public function beforeHistoryRollbackStartEvent(): void
+    {
+        // Disable hook to prevent slug change again on rollback
+        unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Hooks/BackendControllerHook.php b/typo3/sysext/redirects/Classes/Hooks/BackendControllerHook.php
new file mode 100644 (file)
index 0000000..e2bee58
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Hooks;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Page\PageRenderer;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+class BackendControllerHook
+{
+    public function registerClientSideEventHandler(): void
+    {
+        $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
+        $pageRenderer->loadRequireJsModule('TYPO3/CMS/Redirects/EventHandler');
+        $pageRenderer->addInlineLanguageLabelFile('EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf');
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Hooks/DataHandlerSlugUpdateHook.php b/typo3/sysext/redirects/Classes/Hooks/DataHandlerSlugUpdateHook.php
new file mode 100644 (file)
index 0000000..ae108d8
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Hooks;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\DataHandling\DataHandler;
+use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Redirects\Service\SlugService;
+
+/**
+ * @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
+ */
+class DataHandlerSlugUpdateHook
+{
+    /**
+     * @var SlugService
+     */
+    protected $slugService;
+
+    /**
+     * Persisted slug values per record UID
+     * e.g. `[13 => 'slug-a', 14 => 'slug-x/example']`
+     *
+     * @var string[]
+     */
+    protected $persistedSlugValues;
+
+    /**
+     * @param SlugService $slugService
+     */
+    public function __construct(SlugService $slugService)
+    {
+        $this->slugService = $slugService;
+    }
+
+    /**
+     * Collects slugs of persisted records before having been updated.
+     *
+     * @param array $incomingFieldArray
+     * @param string $table
+     * @param string|int $id (id could be string, for this reason no type hint)
+     * @param DataHandler $dataHandler
+     */
+    public function processDatamap_preProcessFieldArray(array $incomingFieldArray, string $table, $id, DataHandler $dataHandler): void
+    {
+        if (
+            $table !== 'pages'
+            || empty($incomingFieldArray['slug'])
+            || $this->isNestedHookInvocation($dataHandler)
+            || !MathUtility::canBeInterpretedAsInteger($id)
+            || !$dataHandler->checkRecordUpdateAccess($table, $id, $incomingFieldArray)
+        ) {
+            return;
+        }
+
+        $record = BackendUtility::getRecordWSOL($table, $id, 'slug');
+        $this->persistedSlugValues[(int)$id] = $record['slug'];
+    }
+
+    /**
+     * Acts on potential slug changes.
+     *
+     * Hook `processDatamap_postProcessFieldArray` is executed after `DataHandler::fillInFields` which
+     * ensure access to pages.slug field and applies possible evaluations (`eval => 'trim,...`).
+     *
+     * @param string $status
+     * @param string $table
+     * @param string|int $id
+     * @param array $fieldArray
+     * @param DataHandler $dataHandler
+     */
+    public function processDatamap_postProcessFieldArray(string $status, string $table, $id, array $fieldArray, DataHandler $dataHandler): void
+    {
+        $persistedSlugValue = $this->persistedSlugValues[(int)$id] ?? null;
+
+        if (
+            $table !== 'pages'
+            || $status !== 'update'
+            || empty($fieldArray['slug'])
+            || $persistedSlugValue === null
+            || $persistedSlugValue === $fieldArray['slug']
+            || $this->isNestedHookInvocation($dataHandler)
+        ) {
+            return;
+        }
+
+        $this->slugService->rebuildSlugsForSlugChange($id, $persistedSlugValue, $fieldArray['slug'], $dataHandler->getCorrelationId());
+    }
+
+    /**
+     * Determines whether our identifier is part of correlation id aspects.
+     * In that case it would be a nested call which has to be ignored.
+     *
+     * @param DataHandler $dataHandler
+     * @return bool
+     */
+    protected function isNestedHookInvocation(DataHandler $dataHandler): bool
+    {
+        $correlationId = $dataHandler->getCorrelationId();
+        $correlationIdAspects = $correlationId ? $correlationId->getAspects() ?? [] : [];
+        return in_array(SlugService::CORRELATION_ID_IDENTIFIER, $correlationIdAspects, true);
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Service/SlugService.php b/typo3/sysext/redirects/Classes/Service/SlugService.php
new file mode 100644 (file)
index 0000000..f7e4ca6
--- /dev/null
@@ -0,0 +1,347 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Service;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Doctrine\DBAL\Connection;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\DateTimeAspect;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\QueryBuilder;
+use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
+use TYPO3\CMS\Core\DataHandling\DataHandler;
+use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
+use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
+use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
+use TYPO3\CMS\Core\DataHandling\SlugHelper;
+use TYPO3\CMS\Core\Domain\Repository\PageRepository;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Page\PageRenderer;
+use TYPO3\CMS\Core\Site\Entity\SiteInterface;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal Due to some possible refactorings in TYPO3 v10
+ */
+class SlugService implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    /**
+     * `dechex(1569615472)` (similar to timestamps used with exceptions, but in hex)
+     */
+    public const CORRELATION_ID_IDENTIFIER = '5d8e6e70';
+
+    /**
+     * @var Context
+     */
+    protected $context;
+
+    /**
+     * @var LanguageService
+     */
+    protected $languageService;
+
+    /**
+     * @var SiteInterface
+     */
+    protected $site;
+
+    /**
+     * @var SiteFinder
+     */
+    protected $siteFinder;
+
+    /**
+     * @var PageRepository
+     */
+    protected $pageRepository;
+
+    /**
+     * @var CorrelationId
+     */
+    protected $correlationIdRedirectCreation = '';
+
+    /**
+     * @var CorrelationId
+     */
+    protected $correlationIdSlugUpdate = '';
+
+    /**
+     * @var bool
+     */
+    protected $autoUpdateSlugs;
+
+    /**
+     * @var bool
+     */
+    protected $autoCreateRedirects;
+
+    /**
+     * @var int
+     */
+    protected $redirectTTL;
+
+    /**
+     * @var int
+     */
+    protected $httpStatusCode;
+
+    public function __construct(Context $context, LanguageService $languageService, SiteFinder $siteFinder, PageRepository $pageRepository)
+    {
+        $this->context = $context;
+        $this->languageService = $languageService;
+        $this->siteFinder = $siteFinder;
+        $this->pageRepository = $pageRepository;
+    }
+
+    public function rebuildSlugsForSlugChange(int $pageId, string $currentSlug, string $newSlug, CorrelationId $correlationId): void
+    {
+        $currentPageRecord = BackendUtility::getRecord('pages', $pageId);
+        $this->initializeSettings($pageId);
+        if ($this->autoUpdateSlugs || $this->autoCreateRedirects) {
+            $this->createCorrelationIds($pageId, $correlationId);
+            if ($this->autoCreateRedirects) {
+                $this->createRedirect($currentSlug, $newSlug, (int)$currentPageRecord['sys_language_uid']);
+            }
+            if ($this->autoUpdateSlugs) {
+                $this->checkSubPages($currentPageRecord, $currentSlug, $newSlug);
+            }
+            $this->sendNotification();
+        }
+    }
+
+    protected function initializeSettings(int $pageId): void
+    {
+        $this->site = $this->siteFinder->getSiteByPageId($pageId);
+        $settings = $this->site->getConfiguration()['settings']['redirects'] ?? [];
+        $this->autoUpdateSlugs = $settings['autoUpdateSlugs'] ?? true;
+        $this->autoCreateRedirects = $settings['autoCreateRedirects'] ?? true;
+        if (!$this->context->getPropertyFromAspect('workspace', 'isLive')) {
+            $this->autoCreateRedirects = false;
+        }
+        $this->redirectTTL = (int)($settings['redirectTTL'] ?? 0);
+        $this->httpStatusCode = (int)($settings['httpStatusCode'] ?? 307);
+    }
+
+    protected function createCorrelationIds(int $pageId, CorrelationId $correlationId): void
+    {
+        if ($correlationId->getSubject() === null) {
+            $subject = md5('pages:' . $pageId);
+            $correlationId = $correlationId->withSubject($subject);
+        }
+
+        $this->correlationIdRedirectCreation = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'redirect');
+        $this->correlationIdSlugUpdate = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'slug');
+    }
+
+    protected function createRedirect(string $originalSlug, string $newSlug, int $languageId): void
+    {
+        $basePath = rtrim($this->site->getLanguageById($languageId)->getBase()->getPath(), '/');
+
+        /** @var DateTimeAspect $date */
+        $date = $this->context->getAspect('date');
+        $endtime = $date->getDateTime()->modify('+' . $this->redirectTTL . ' days');
+        $record = [
+            'pid' => 0,
+            'updatedon' => $date->get('timestamp'),
+            'createdon' => $date->get('timestamp'),
+            'createdby' => $this->context->getPropertyFromAspect('backend.user', 'id'),
+            'deleted' => 0,
+            'disabled' => 0,
+            'starttime' => 0,
+            'endtime' => $this->redirectTTL > 0 ? $endtime->getTimestamp() : 0,
+            'source_host' => $this->site->getBase()->getHost() ?: '*',
+            'source_path' => $basePath . $originalSlug,
+            'is_regexp' => 0,
+            'force_https' => 0,
+            'respect_query_parameters' => 0,
+            'target' => $basePath . $newSlug,
+            'target_statuscode' => $this->httpStatusCode,
+            'hitcount' => 0,
+            'lasthiton' => 0,
+            'disable_hitcount' => 0,
+        ];
+        $connection = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getConnectionForTable('sys_redirect');
+        $connection->insert('sys_redirect', $record);
+        $id = (int)$connection->lastInsertId('sys_redirect');
+        $record['uid'] = $id;
+        $this->getRecordHistoryStore()->addRecord('sys_redirect', $id, $record, $this->correlationIdRedirectCreation);
+    }
+
+    protected function checkSubPages(array $currentPageRecord, string $oldSlugOfParentPage, string $newSlugOfParentPage): void
+    {
+        $languageUid = (int)$currentPageRecord['sys_language_uid'];
+        // resolveSubPages needs the page id of the default language
+        $pageId = $languageUid === 0 ? (int)$currentPageRecord['uid'] : (int)$currentPageRecord['l10n_parent'];
+        $subPageRecords = $this->resolveSubPages($pageId, $languageUid);
+        foreach ($subPageRecords as $subPageRecord) {
+            $newSlug = $this->updateSlug($subPageRecord, $oldSlugOfParentPage, $newSlugOfParentPage);
+            if ($newSlug !== null && $this->autoCreateRedirects) {
+                $this->createRedirect($subPageRecord['slug'], $newSlug, $languageUid);
+            }
+        }
+    }
+
+    protected function resolveSubPages(int $id, int $languageUid): array
+    {
+        // First resolve all sub-pages in default language
+        $queryBuilder = $this->getQueryBuilderForPages();
+        $subPages = $queryBuilder
+            ->select('*')
+            ->from('pages')
+            ->where(
+                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)),
+                $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
+            )
+            ->orderBy('uid', 'ASC')
+            ->execute()
+            ->fetchAll();
+
+        // if the language is not the default language, resolve the language related records.
+        if ($languageUid > 0) {
+            $queryBuilder = $this->getQueryBuilderForPages();
+            $subPages = $queryBuilder
+                ->select('*')
+                ->from('pages')
+                ->where(
+                    $queryBuilder->expr()->in('l10n_parent', $queryBuilder->createNamedParameter(array_column($subPages, 'uid'), Connection::PARAM_INT_ARRAY)),
+                    $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter($languageUid, \PDO::PARAM_INT))
+                )
+                ->orderBy('uid', 'ASC')
+                ->execute()
+                ->fetchAll();
+        }
+        $results = [];
+        if (!empty($subPages)) {
+            $subPages = $this->pageRepository->getPagesOverlay($subPages, $languageUid);
+            foreach ($subPages as $subPage) {
+                $results[] = $subPage;
+                // resolveSubPages needs the page id of the default language
+                $pageId = $languageUid === 0 ? (int)$subPage['uid'] : (int)$subPage['l10n_parent'];
+                foreach ($this->resolveSubPages($pageId, $languageUid) as $page) {
+                    $results[] = $page;
+                }
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Update a slug by given record, old parent page slug and new parent page slug.
+     * In case no update is required, the method returns null else the new slug.
+     *
+     * @param array $subPageRecord
+     * @param string $oldSlugOfParentPage
+     * @param string $newSlugOfParentPage
+     * @return string|null
+     */
+    protected function updateSlug(array $subPageRecord, string $oldSlugOfParentPage, string $newSlugOfParentPage): ?string
+    {
+        if (strpos($subPageRecord['slug'], $oldSlugOfParentPage) !== 0) {
+            return null;
+        }
+
+        $newSlug = rtrim($newSlugOfParentPage, '/') . '/'
+            . substr($subPageRecord['slug'], strlen(rtrim($oldSlugOfParentPage, '/') . '/'));
+        $state = RecordStateFactory::forName('pages')
+            ->fromArray($subPageRecord, $subPageRecord['pid'], $subPageRecord['uid']);
+        $fieldConfig = $GLOBALS['TCA']['pages']['columns']['slug']['config'] ?? [];
+        $slugHelper = GeneralUtility::makeInstance(SlugHelper::class, 'pages', 'slug', $fieldConfig);
+
+        if (!$slugHelper->isUniqueInSite($newSlug, $state)) {
+            $newSlug = $slugHelper->buildSlugForUniqueInSite($newSlug, $state);
+        }
+
+        $this->persistNewSlug((int)$subPageRecord['uid'], $newSlug);
+        return $newSlug;
+    }
+
+    /**
+     * @param int $uid
+     * @param string $newSlug
+     */
+    protected function persistNewSlug(int $uid, string $newSlug): void
+    {
+        $this->disableHook();
+        $data['pages'][$uid]['slug'] = $newSlug;
+        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+        $dataHandler->start($data, []);
+        $dataHandler->setCorrelationId($this->correlationIdSlugUpdate);
+        $dataHandler->process_datamap();
+        $this->enabledHook();
+    }
+
+    protected function sendNotification(): void
+    {
+        $data = [
+            'componentName' => 'redirects',
+            'eventName' => 'slugChanged',
+            'correlations' => [
+                'correlationIdSlugUpdate' => $this->correlationIdSlugUpdate,
+                'correlationIdRedirectCreation' => $this->correlationIdRedirectCreation,
+            ],
+            'autoUpdateSlugs' => (bool)$this->autoUpdateSlugs,
+            'autoCreateRedirects' => (bool)$this->autoCreateRedirects,
+        ];
+        GeneralUtility::makeInstance(PageRenderer::class)->loadRequireJsModule(
+            'TYPO3/CMS/Backend/BroadcastService',
+            sprintf('function(service) { service.post(%s); }', json_encode($data))
+        );
+    }
+
+    protected function getRecordHistoryStore(): RecordHistoryStore
+    {
+        $backendUser = $GLOBALS['BE_USER'];
+        return GeneralUtility::makeInstance(
+            RecordHistoryStore::class,
+            RecordHistoryStore::USER_BACKEND,
+            $backendUser->user['uid'],
+            $backendUser->user['ses_backuserid'] ?? null,
+            $this->context->getPropertyFromAspect('date', 'timestamp'),
+            $backendUser->workspace ?? 0
+        );
+    }
+
+    protected function getQueryBuilderForPages(): QueryBuilder
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getQueryBuilderForTable('pages');
+        /** @noinspection PhpStrictTypeCheckingInspection */
+        $queryBuilder
+            ->getRestrictions()
+            ->removeAll()
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
+            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->context->getPropertyFromAspect('workspace', 'id')));
+        return $queryBuilder;
+    }
+
+    protected function enabledHook(): void
+    {
+        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] =
+            \TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook::class;
+    }
+
+    protected function disableHook(): void
+    {
+        unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
+    }
+}
diff --git a/typo3/sysext/redirects/Configuration/Backend/AjaxRoutes.php b/typo3/sysext/redirects/Configuration/Backend/AjaxRoutes.php
new file mode 100644 (file)
index 0000000..8be0651
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+use TYPO3\CMS\Redirects\Controller;
+
+/**
+ * Definitions for routes provided by EXT:backend
+ * Contains all AJAX-based routes for entry points
+ *
+ * Currently the "access" property is only used so no token creation + validation is made
+ * but will be extended further.
+ */
+return [
+
+    // Revert Correlation
+    'redirects_revert_correlation' => [
+        'path' => '/redirects/revert/correlation',
+        'target' => Controller\RecordHistoryRollbackController::class . '::revertCorrelation'
+    ],
+
+];
index f0fde48..54b28f9 100644 (file)
@@ -6,3 +6,20 @@ services:
 
   TYPO3\CMS\Redirects\:
     resource: '../Classes/*'
+
+  TYPO3\CMS\Redirects\Controller\RecordHistoryRollbackController:
+    public: true
+
+  TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook:
+    public: true
+
+  TYPO3\CMS\Redirects\EventListener\RecordHistoryRollbackEventsListener:
+    tags:
+      - { name: event.listener,
+          identifier: 'redirects-disable-hook',
+          event: TYPO3\CMS\Backend\History\Event\BeforeHistoryRollbackStartEvent,
+          method: 'beforeHistoryRollbackStartEvent' }
+      - { name: event.listener,
+          identifier: 'redirects-enable-hook',
+          event: TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent,
+          method: 'afterHistoryRollbackFinishedEvent' }
diff --git a/typo3/sysext/redirects/Resources/Private/Language/locallang.xlf b/typo3/sysext/redirects/Resources/Private/Language/locallang.xlf
new file mode 100644 (file)
index 0000000..c820f1d
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+       <file t3:id="1568292127" source-language="en" datatype="plaintext" original="messages" date="2017-12-29T20:22:14Z" product-name="redirects">
+               <header/>
+               <body>
+                       <trans-unit id="redirects">
+                               <source>Redirect Settings</source>
+                       </trans-unit>
+                       <trans-unit id="redirects.autoUpdateSlugs">
+                               <source>Update the slugs of all sub pages automatically</source>
+                       </trans-unit>
+                       <trans-unit id="redirects.autoCreateRedirects">
+                               <source>Create redirects for pages with a new slug automatically</source>
+                       </trans-unit>
+                       <trans-unit id="redirects.redirectTTL">
+                               <source>Time To Live of redirect records in days</source>
+                       </trans-unit>
+                       <trans-unit id="redirects.httpStatusCode">
+                               <source>HTTP Status Code for the redirect, 301 is the default</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>
diff --git a/typo3/sysext/redirects/Resources/Private/Language/locallang_slug_service.xlf b/typo3/sysext/redirects/Resources/Private/Language/locallang_slug_service.xlf
new file mode 100644 (file)
index 0000000..281a1b7
--- /dev/null
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+       <file t3:id="1567686180" source-language="en" datatype="plaintext" original="messages" date="2017-12-29T20:23:34Z" product-name="redirects">
+               <header/>
+               <body>
+                       <trans-unit id="notification.slug_and_redirects.title">
+                               <source>Slugs updated and redirects created</source>
+                       </trans-unit>
+                       <trans-unit id="notification.slug_and_redirects.message">
+                               <source>Because you renamed a slug, the slugs of all sub-pages were updated and redirects were created for you automatically.</source>
+                       </trans-unit>
+                       <trans-unit id="notification.slug_only.title">
+                               <source>Slugs updated</source>
+                       </trans-unit>
+                       <trans-unit id="notification.slug_only.message">
+                               <source>Because you renamed a slug, the slugs of all sub-pages were updated for you automatically.</source>
+                       </trans-unit>
+                       <trans-unit id="notification.redirects.button.revert_update">
+                               <source>Revert update</source>
+                       </trans-unit>
+                       <trans-unit id="notification.redirects.button.revert_redirect">
+                               <source>Revert redirects only</source>
+                       </trans-unit>
+                       <trans-unit id="redirects_error_title">
+                               <source>An error occurred</source>
+                       </trans-unit>
+                       <trans-unit id="redirects_error_message">
+                               <source>Sorry something went wrong.</source>
+                       </trans-unit>
+                       <trans-unit id="revert_update_success_title">
+                               <source>Revert successful</source>
+                       </trans-unit>
+                       <trans-unit id="revert_update_success_message">
+                               <source>All changes have been reverted.</source>
+                       </trans-unit>
+                       <trans-unit id="revert_redirects_success_title">
+                               <source>Revert successful</source>
+                       </trans-unit>
+                       <trans-unit id="revert_redirects_success_message">
+                               <source>All created redirects have been reverted.</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>
diff --git a/typo3/sysext/redirects/Resources/Public/JavaScript/EventHandler.js b/typo3/sysext/redirects/Resources/Public/JavaScript/EventHandler.js
new file mode 100644 (file)
index 0000000..45901dc
--- /dev/null
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports","jquery","TYPO3/CMS/Backend/Notification","TYPO3/CMS/Backend/ActionButton/DeferredAction"],function(e,t,r,i,n){"use strict";return new class{constructor(){document.addEventListener("typo3:redirects:slugChanged",e=>this.onSlugChanged(e.detail))}onSlugChanged(e){let t=[];const r=e.correlations;e.autoUpdateSlugs&&t.push({label:TYPO3.lang["notification.redirects.button.revert_update"],action:new n(()=>this.revert([r.correlationIdSlugUpdate,r.correlationIdRedirectCreation]))}),e.autoCreateRedirects&&t.push({label:TYPO3.lang["notification.redirects.button.revert_redirect"],action:new n(()=>this.revert([r.correlationIdRedirectCreation]))});let a=TYPO3.lang["notification.slug_only.title"],o=TYPO3.lang["notification.slug_only.message"];e.autoCreateRedirects&&(a=TYPO3.lang["notification.slug_and_redirects.title"],o=TYPO3.lang["notification.slug_and_redirects.message"]),i.info(a,o,0,t)}revert(e){r.ajax({url:TYPO3.settings.ajaxUrls.redirects_revert_correlation,data:{correlation_ids:e}}).done(e=>{"ok"===e.status&&i.success(e.title,e.message),"error"===e.status&&i.error(e.title,e.message)}).fail(()=>{i.error(TYPO3.lang.redirects_error_title,TYPO3.lang.redirects_error_message)})}}});
\ No newline at end of file
diff --git a/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test1.xml b/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test1.xml
new file mode 100644 (file)
index 0000000..e6a4b9b
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <pages>
+        <uid>1</uid>
+        <pid>0</pid>
+        <title>Root 1</title>
+        <slug>/</slug>
+    </pages>
+    <pages>
+        <uid>2</uid>
+        <pid>1</pid>
+        <title>Dummy 1-2</title>
+        <!-- this value is correct, because for the test scenario this page is already renamed -->
+        <slug>/test-new</slug>
+    </pages>
+    <pages>
+        <uid>3</uid>
+        <pid>1</pid>
+        <title>Dummy 1-3</title>
+        <slug>/dummy-1-3</slug>
+    </pages>
+    <pages>
+        <uid>4</uid>
+        <pid>1</pid>
+        <title>Dummy 1-4</title>
+        <slug>/dummy-1-4</slug>
+    </pages>
+    <pages>
+        <uid>5</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-5</title>
+        <slug>/dummy-1-2/dummy-1-2-5</slug>
+    </pages>
+    <pages>
+        <uid>6</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-6</title>
+        <slug>/dummy-1-2/dummy-1-2-6</slug>
+    </pages>
+    <pages>
+        <uid>7</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-7</title>
+        <slug>/dummy-1-2/dummy-1-2-7</slug>
+    </pages>
+    <pages>
+        <uid>8</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-8</title>
+        <slug>/dummy-1-3/dummy-1-3-8</slug>
+    </pages>
+    <pages>
+        <uid>9</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-9</title>
+        <slug>/dummy-1-3/dummy-1-3-9</slug>
+    </pages>
+    <pages>
+        <uid>10</uid>
+        <pid>4</pid>
+        <title>Dummy 1-4-10</title>
+        <slug>/dummy-1-4/dummy-1-4-10</slug>
+    </pages>
+</dataset>
diff --git a/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test2.xml b/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test2.xml
new file mode 100644 (file)
index 0000000..44ec3b8
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <pages>
+        <uid>1</uid>
+        <pid>0</pid>
+        <title>Root 1</title>
+        <!-- this value is correct, because for the test scenario this page is already renamed -->
+        <slug>/new-home</slug>
+    </pages>
+    <pages>
+        <uid>2</uid>
+        <pid>1</pid>
+        <title>Dummy 1-2</title>
+        <slug>/dummy-1-2</slug>
+    </pages>
+    <pages>
+        <uid>3</uid>
+        <pid>1</pid>
+        <title>Dummy 1-3</title>
+        <slug>/dummy-1-3</slug>
+    </pages>
+    <pages>
+        <uid>4</uid>
+        <pid>1</pid>
+        <title>Dummy 1-4</title>
+        <slug>/dummy-1-4</slug>
+    </pages>
+    <pages>
+        <uid>5</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-5</title>
+        <slug>/dummy-1-2/dummy-1-2-5</slug>
+    </pages>
+    <pages>
+        <uid>6</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-6</title>
+        <slug>/dummy-1-2/dummy-1-2-6</slug>
+    </pages>
+    <pages>
+        <uid>7</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-7</title>
+        <slug>/dummy-1-2/dummy-1-2-7</slug>
+    </pages>
+    <pages>
+        <uid>8</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-8</title>
+        <slug>/dummy-1-3/dummy-1-3-8</slug>
+    </pages>
+    <pages>
+        <uid>9</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-9</title>
+        <slug>/dummy-1-3/dummy-1-3-9</slug>
+    </pages>
+    <pages>
+        <uid>10</uid>
+        <pid>4</pid>
+        <title>Dummy 1-4-10</title>
+        <slug>/dummy-1-4/dummy-1-4-10</slug>
+    </pages>
+</dataset>
diff --git a/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test3.xml b/typo3/sysext/redirects/Tests/Functional/Service/Fixtures/SlugServiceTest_pages_test3.xml
new file mode 100644 (file)
index 0000000..57fe2c2
--- /dev/null
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <pages>
+        <uid>1</uid>
+        <pid>0</pid>
+        <title>Root 1</title>
+        <slug>/</slug>
+    </pages>
+    <pages>
+        <uid>2</uid>
+        <pid>1</pid>
+        <title>Dummy 1-2</title>
+        <slug>/dummy-1-2</slug>
+    </pages>
+    <pages>
+        <uid>3</uid>
+        <pid>1</pid>
+        <title>Dummy 1-3</title>
+        <!-- this value is correct, because for the test scenario this page is already renamed -->
+        <slug>/test-new</slug>
+    </pages>
+    <pages>
+        <uid>31</uid>
+        <pid>1</pid>
+        <title>Dummy 1-3 german</title>
+        <slug>/dummy-1-3</slug>
+        <l10n_parent>3</l10n_parent>
+        <l10n_source>3</l10n_source>
+        <sys_language_uid>1</sys_language_uid>
+    </pages>
+    <pages>
+        <uid>4</uid>
+        <pid>1</pid>
+        <title>Dummy 1-4</title>
+        <slug>/dummy-1-4</slug>
+    </pages>
+    <pages>
+        <uid>5</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-5</title>
+        <slug>/dummy-1-2/dummy-1-2-5</slug>
+    </pages>
+    <pages>
+        <uid>6</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-6</title>
+        <slug>/dummy-1-2/dummy-1-2-6</slug>
+    </pages>
+    <pages>
+        <uid>7</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-7</title>
+        <slug>/dummy-1-2/dummy-1-2-7</slug>
+    </pages>
+    <pages>
+        <uid>8</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-8</title>
+        <slug>/dummy-1-3/dummy-1-3-8</slug>
+    </pages>
+    <pages>
+        <uid>32</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-8 german</title>
+        <slug>/dummy-1-3/dummy-1-3-8</slug>
+        <l10n_parent>8</l10n_parent>
+        <l10n_source>8</l10n_source>
+        <sys_language_uid>1</sys_language_uid>
+    </pages>
+    <pages>
+        <uid>9</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-9</title>
+        <slug>/dummy-1-3/dummy-1-3-9</slug>
+    </pages>
+    <pages>
+        <uid>10</uid>
+        <pid>4</pid>
+        <title>Dummy 1-4-10</title>
+        <slug>/dummy-1-4/dummy-1-4-10</slug>
+    </pages>
+</dataset>
diff --git a/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php b/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php
new file mode 100644 (file)
index 0000000..9758c35
--- /dev/null
@@ -0,0 +1,363 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Functional\Domain\Repository;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Log\NullLogger;
+use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
+use TYPO3\CMS\Core\Domain\Repository\PageRepository;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Routing\SiteMatcher;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Redirects\Service\SlugService;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+/**
+ * Test case
+ */
+class SlugServiceTest extends FunctionalTestCase
+{
+    /**
+     * @var SlugService
+     */
+    private $subject;
+
+    /**
+     * @var CorrelationId
+     */
+    private $correlationId;
+
+    private $languages = [
+        [
+            'title' => 'English',
+            'enabled' => true,
+            'languageId' => '0',
+            'base' => '/en/',
+            'typo3Language' => 'default',
+            'locale' => 'en_US.UTF-8',
+            'iso-639-1' => 'en',
+            'navigationTitle' => 'English',
+            'hreflang' => 'en-us',
+            'direction' => 'ltr',
+            'flag' => 'us',
+        ],
+        [
+            'title' => 'German',
+            'enabled' => true,
+            'languageId' => '1',
+            'base' => '/de/',
+            'typo3Language' => 'de',
+            'locale' => 'de_DE.UTF-8',
+            'iso-639-1' => 'de',
+            'navigationTitle' => 'German',
+            'hreflang' => 'de-de',
+            'direction' => 'ltr',
+            'flag' => 'de',
+        ]
+    ];
+
+    protected $coreExtensionsToLoad = ['redirects'];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->correlationId = CorrelationId::forScope(StringUtility::getUniqueId('test'));
+        $this->setUpBackendUserFromFixture(1);
+    }
+
+    protected function tearDown(): void
+    {
+        unset($this->subject, $this->correlationId);
+        parent::tearDown();
+    }
+
+    /**
+     * This test should prove, that a renaming of a subtree works as expected
+     * and all slugs of sub pages are renamed and redirects are created.
+     *
+     * We test here that rebuildSlugsForSlugChange works for a partial tree.
+     * @test
+     */
+    public function rebuildSlugsForSlugChangeRenamesSubSlugsAndCreatesRedirects(): void
+    {
+        $this->buildBaseSite();
+        $this->createSubject();
+        $this->importDataSet(__DIR__ . '/Fixtures/SlugServiceTest_pages_test1.xml');
+        $this->subject->rebuildSlugsForSlugChange(2, '/dummy-1-2', '/test-new', $this->correlationId);
+
+        // These are the slugs after rebuildSlugsForSlugChange() has run
+        $slugs = [
+            '/',
+            '/test-new',
+            '/dummy-1-3',
+            '/dummy-1-4',
+            '/test-new/dummy-1-2-5',
+            '/test-new/dummy-1-2-6',
+            '/test-new/dummy-1-2-7',
+            '/dummy-1-3/dummy-1-3-8',
+            '/dummy-1-3/dummy-1-3-9',
+            '/dummy-1-4/dummy-1-4-10',
+        ];
+
+        // This redirects should exists, after rebuildSlugsForSlugChange() has run
+        $redirects = [
+            ['source_path' => '/dummy-1-2', 'target' => '/test-new'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-5', 'target' => '/test-new/dummy-1-2-5'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-6', 'target' => '/test-new/dummy-1-2-6'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-7', 'target' => '/test-new/dummy-1-2-7'],
+        ];
+
+        $this->assertSlugsAndRedirectsExists($slugs, $redirects);
+    }
+
+    /**
+     * This test should prove, that a renaming of a complete tree works as expected
+     * and all slugs of sub pages are renamed and redirects are created.
+     *
+     * We test here that rebuildSlugsForSlugChange works for a complete tree inclusive the root page.
+     * @test
+     */
+    public function rebuildSlugsForSlugChangeRenamesSubSlugsAndCreatesRedirectsForRootChange(): void
+    {
+        $this->buildBaseSite();
+        $this->createSubject();
+        $this->importDataSet(__DIR__ . '/Fixtures/SlugServiceTest_pages_test2.xml');
+        $this->subject->rebuildSlugsForSlugChange(1, '/', '/new-home', $this->correlationId);
+
+        // These are the slugs after rebuildSlugsForSlugChange() has run
+        $slugs = [
+            '/new-home',
+            '/new-home/dummy-1-2',
+            '/new-home/dummy-1-3',
+            '/new-home/dummy-1-4',
+            '/new-home/dummy-1-2/dummy-1-2-5',
+            '/new-home/dummy-1-2/dummy-1-2-6',
+            '/new-home/dummy-1-2/dummy-1-2-7',
+            '/new-home/dummy-1-3/dummy-1-3-8',
+            '/new-home/dummy-1-3/dummy-1-3-9',
+            '/new-home/dummy-1-4/dummy-1-4-10',
+        ];
+
+        // This redirects should exists, after rebuildSlugsForSlugChange() has run
+        $redirects = [
+            ['source_path' => '/', 'target' => '/new-home'],
+            ['source_path' => '/dummy-1-2', 'target' => '/new-home/dummy-1-2'],
+            ['source_path' => '/dummy-1-3', 'target' => '/new-home/dummy-1-3'],
+            ['source_path' => '/dummy-1-4', 'target' => '/new-home/dummy-1-4'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-5', 'target' => '/new-home/dummy-1-2/dummy-1-2-5'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-6', 'target' => '/new-home/dummy-1-2/dummy-1-2-6'],
+            ['source_path' => '/dummy-1-2/dummy-1-2-7', 'target' => '/new-home/dummy-1-2/dummy-1-2-7'],
+            ['source_path' => '/dummy-1-3/dummy-1-3-8', 'target' => '/new-home/dummy-1-3/dummy-1-3-8'],
+            ['source_path' => '/dummy-1-3/dummy-1-3-9', 'target' => '/new-home/dummy-1-3/dummy-1-3-9'],
+            ['source_path' => '/dummy-1-4/dummy-1-4-10', 'target' => '/new-home/dummy-1-4/dummy-1-4-10'],
+        ];
+
+        $this->assertSlugsAndRedirectsExists($slugs, $redirects);
+    }
+
+    /**
+     * This test should prove, that a renaming of a subtree works as expected
+     * and all slugs of sub pages are renamed and redirects are created.
+     *
+     * We test here that rebuildSlugsForSlugChange works for a setup with a base in a sub-folder.
+     * @test
+     */
+    public function rebuildSlugsForSlugChangeRenamesSubSlugsAndCreatesRedirectsWithSubFolderBase(): void
+    {
+        $this->buildBaseSiteInSubfolder();
+        $this->createSubject();
+        $this->importDataSet(__DIR__ . '/Fixtures/SlugServiceTest_pages_test1.xml');
+        $this->subject->rebuildSlugsForSlugChange(2, '/dummy-1-2', '/test-new', $this->correlationId);
+
+        // These are the slugs after rebuildSlugsForSlugChange() has run
+        $slugs = [
+            '/',
+            '/test-new',
+            '/dummy-1-3',
+            '/dummy-1-4',
+            '/test-new/dummy-1-2-5',
+            '/test-new/dummy-1-2-6',
+            '/test-new/dummy-1-2-7',
+            '/dummy-1-3/dummy-1-3-8',
+            '/dummy-1-3/dummy-1-3-9',
+            '/dummy-1-4/dummy-1-4-10',
+        ];
+
+        // This redirects should exists, after rebuildSlugsForSlugChange() has run
+        $redirects = [
+            ['source_path' => '/sub-folder/dummy-1-2', 'target' => '/sub-folder/test-new'],
+            ['source_path' => '/sub-folder/dummy-1-2/dummy-1-2-5', 'target' => '/sub-folder/test-new/dummy-1-2-5'],
+            ['source_path' => '/sub-folder/dummy-1-2/dummy-1-2-6', 'target' => '/sub-folder/test-new/dummy-1-2-6'],
+            ['source_path' => '/sub-folder/dummy-1-2/dummy-1-2-7', 'target' => '/sub-folder/test-new/dummy-1-2-7'],
+        ];
+
+        $this->assertSlugsAndRedirectsExists($slugs, $redirects);
+    }
+
+    /**
+     * This test should prove, that a renaming of a subtree works as expected
+     * and all slugs of sub pages are renamed and redirects are created.
+     *
+     * We test here that rebuildSlugsForSlugChange works for a setup with languages.
+     * @test
+     */
+    public function rebuildSlugsForSlugChangeRenamesSubSlugsAndCreatesRedirectsWithLanguages(): void
+    {
+        $this->buildBaseSiteWithLanguages();
+        $this->createSubject();
+        $this->importDataSet(__DIR__ . '/Fixtures/SlugServiceTest_pages_test3.xml');
+        $this->subject->rebuildSlugsForSlugChange(31, '/dummy-1-3', '/test-new', $this->correlationId);
+
+        // These are the slugs after rebuildSlugsForSlugChange() has run
+        $slugs = [
+            '/',
+            '/dummy-1-2',
+            '/test-new',
+            '/dummy-1-3',
+            '/dummy-1-4',
+            '/dummy-1-2/dummy-1-2-5',
+            '/dummy-1-2/dummy-1-2-6',
+            '/dummy-1-2/dummy-1-2-7',
+            '/dummy-1-3/dummy-1-3-8',
+            '/test-new/dummy-1-3-8',
+            '/dummy-1-3/dummy-1-3-9',
+            '/dummy-1-4/dummy-1-4-10',
+        ];
+
+        // This redirects should exists, after rebuildSlugsForSlugChange() has run
+        $redirects = [
+            ['source_path' => '/de/dummy-1-3', 'target' => '/de/test-new'],
+            ['source_path' => '/de/dummy-1-3/dummy-1-3-8', 'target' => '/de/test-new/dummy-1-3-8'],
+        ];
+
+        $this->assertSlugsAndRedirectsExists($slugs, $redirects);
+    }
+
+    /**
+     * This test should prove, that a renaming of a subtree works as expected
+     * and all slugs of sub pages are renamed and redirects are created.
+     *
+     * We test here that rebuildSlugsForSlugChange works with languages and a base in a sub-folder.
+     * @test
+     */
+    public function rebuildSlugsForSlugChangeRenamesSubSlugsAndCreatesRedirectsWithLanguagesInSubFolder(): void
+    {
+        $this->buildBaseSiteWithLanguagesInSubFolder();
+        $this->createSubject();
+        $this->importDataSet(__DIR__ . '/Fixtures/SlugServiceTest_pages_test3.xml');
+        $this->subject->rebuildSlugsForSlugChange(31, '/dummy-1-3', '/test-new', $this->correlationId);
+
+        // These are the slugs after rebuildSlugsForSlugChange() has run
+        $slugs = [
+            '/',
+            '/dummy-1-2',
+            '/test-new',
+            '/dummy-1-3',
+            '/dummy-1-4',
+            '/dummy-1-2/dummy-1-2-5',
+            '/dummy-1-2/dummy-1-2-6',
+            '/dummy-1-2/dummy-1-2-7',
+            '/dummy-1-3/dummy-1-3-8',
+            '/test-new/dummy-1-3-8',
+            '/dummy-1-3/dummy-1-3-9',
+            '/dummy-1-4/dummy-1-4-10',
+        ];
+
+        // This redirects should exists, after rebuildSlugsForSlugChange() has run
+        $redirects = [
+            ['source_path' => '/sub-folder/de/dummy-1-3', 'target' => '/sub-folder/de/test-new'],
+            ['source_path' => '/sub-folder/de/dummy-1-3/dummy-1-3-8', 'target' => '/sub-folder/de/test-new/dummy-1-3-8'],
+        ];
+
+        $this->assertSlugsAndRedirectsExists($slugs, $redirects);
+    }
+
+    protected function buildBaseSite(): void
+    {
+        $configuration = [
+            'rootPageId' => 1,
+            'base' => '/',
+        ];
+        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $siteConfiguration->write('testing', $configuration);
+    }
+
+    protected function buildBaseSiteInSubfolder(): void
+    {
+        $configuration = [
+            'rootPageId' => 1,
+            'base' => '/sub-folder',
+        ];
+        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $siteConfiguration->write('testing', $configuration);
+    }
+
+    protected function buildBaseSiteWithLanguages(): void
+    {
+        $configuration = [
+            'rootPageId' => 1,
+            'base' => '/',
+            'languages' => $this->languages,
+        ];
+        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $siteConfiguration->write('testing', $configuration);
+    }
+
+    protected function buildBaseSiteWithLanguagesInSubFolder(): void
+    {
+        $configuration = [
+            'rootPageId' => 1,
+            'base' => '/sub-folder',
+            'languages' => $this->languages,
+        ];
+        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $siteConfiguration->write('testing', $configuration);
+    }
+
+    protected function createSubject(): void
+    {
+        GeneralUtility::makeInstance(SiteMatcher::class)->refresh();
+        $this->subject = new SlugService(
+            GeneralUtility::makeInstance(Context::class),
+            GeneralUtility::makeInstance(LanguageService::class),
+            GeneralUtility::makeInstance(SiteFinder::class),
+            GeneralUtility::makeInstance(PageRepository::class)
+        );
+        $this->subject->setLogger(new NullLogger());
+    }
+
+    protected function assertSlugsAndRedirectsExists(array $slugs, array $redirects): void
+    {
+        $pageRecords = $this->getAllRecords('pages');
+        $this->assertCount(count($slugs), $pageRecords);
+        foreach ($pageRecords as $record) {
+            $this->assertContains($record['slug'], $slugs, 'unexpected slug: ' . $record['slug']);
+        }
+
+        $redirectRecords = $this->getAllRecords('sys_redirect');
+        $this->assertCount(count($redirects), $redirectRecords);
+        foreach ($redirectRecords as $record) {
+            $combination = [
+                'source_path' => $record['source_path'],
+                'target' => $record['target'],
+            ];
+            $this->assertContains($combination, $redirects, 'wrong redirect found');
+        }
+    }
+}
index b3ba974..d2dd43f 100644 (file)
@@ -13,6 +13,9 @@
                "sort-packages": true
        },
        "require": {
+               "doctrine/dbal": "^2.9",
+               "psr/http-message": "~1.0",
+               "psr/log": "~1.0.0",
                "typo3/cms-backend": "10.1.*@dev",
                "typo3/cms-core": "10.1.*@dev",
                "typo3fluid/fluid": "^2.6.4"
index cdee120..19f206e 100644 (file)
@@ -6,6 +6,7 @@ defined('TYPO3_MODE') or die();
 
 // Rebuild cache in DataHandler on changing / inserting / adding redirect records
 $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['redirects'] = \TYPO3\CMS\Redirects\Hooks\DataHandlerCacheFlushingHook::class . '->rebuildRedirectCacheIfNecessary';
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] = \TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook::class;
 
 // Inject sys_domains into valuepicker form
 $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord']
@@ -25,6 +26,9 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRe
 // Add validation call for form field source_host and source_path
 $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][\TYPO3\CMS\Redirects\Evaluation\SourceHost::class] = '';
 
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['constructPostProcess'][]
+    = \TYPO3\CMS\Redirects\Hooks\BackendControllerHook::class . '->registerClientSideEventHandler';
+
 if (ExtensionManagementUtility::isLoaded('reports')) {
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['tx_reports']['status']['providers']['LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:statusProvider'][] = \TYPO3\CMS\Redirects\Report\Status\RedirectStatus::class;
 }