[FEATURE] Add a new TCA type "slug" 89/56889/28
authorBenni Mack <benni@typo3.org>
Wed, 22 Aug 2018 21:05:20 +0000 (23:05 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Thu, 23 Aug 2018 10:23:23 +0000 (12:23 +0200)
A new TCA type "slug" is added, which allows to generate a part of a URL
which can later be used for adding URL segments to any kind of record.

The new slug TCA type will be added to the pages database table separately
to fill a page with the rootline "Home => Products => My Product" with
"/products/my-product/details/" into the slug field when creating
a subpage "Details" under "My Product".

Once the slug field is added when persisting the record, changing a title
(like the page title) will not modify the slug anymore, but instead this
has to be modified separately.

Next steps:
- Add the TCA type "slug" to pages table
- Introduce an upgrade wizard for that pages table
- Improve FormEngine via AJAX validation of "uniqueInSite" for slugs
- Implement slug resolving for pages.

Resolves: #84729
Releases: master
Change-Id: I079267f42308f40da71ab4765ba7e0251e79f736
Reviewed-on: https://review.typo3.org/56889
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
typo3/sysext/backend/Classes/Form/Element/InputSlugElement.php [new file with mode: 0644]
typo3/sysext/backend/Classes/Form/NodeFactory.php
typo3/sysext/core/Classes/DataHandling/DataHandler.php
typo3/sysext/core/Classes/DataHandling/SlugHelper.php [new file with mode: 0644]
typo3/sysext/core/Documentation/Changelog/master/Feature-84729-NewTCATypeSlug.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DataHandling/SlugHelperTest.php [new file with mode: 0644]

diff --git a/typo3/sysext/backend/Classes/Form/Element/InputSlugElement.php b/typo3/sysext/backend/Classes/Form/Element/InputSlugElement.php
new file mode 100644 (file)
index 0000000..5d63f18
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Form\Element;
+
+/*
+ * 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\Localization\LanguageService;
+use TYPO3\CMS\Core\Site\Entity\SiteInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+
+/**
+ * General type=input element with some additional value.
+ */
+class InputSlugElement extends AbstractFormElement
+{
+    /**
+     * Default field wizards enabled for this element.
+     *
+     * @var array
+     */
+    protected $defaultFieldWizard = [
+        'localizationStateSelector' => [
+            'renderType' => 'localizationStateSelector',
+        ],
+        'otherLanguageContent' => [
+            'renderType' => 'otherLanguageContent',
+            'after' => [
+                'localizationStateSelector'
+            ],
+        ],
+        'defaultLanguageDifferences' => [
+            'renderType' => 'defaultLanguageDifferences',
+            'after' => [
+                'otherLanguageContent',
+            ],
+        ],
+    ];
+
+    /**
+     * This will render a single-line input form field, possibly with various control/validation features
+     *
+     * @return array As defined in initializeResultArray() of AbstractNode
+     */
+    public function render()
+    {
+        $table = $this->data['tableName'];
+        $row = $this->data['databaseRow'];
+        $parameterArray = $this->data['parameterArray'];
+        $resultArray = $this->initializeResultArray();
+
+        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
+        $languageId = (int)($row[$languageField] ?? $this->data['defaultLanguageRow'][$languageField] ?? 0);
+        $baseUrl = $this->getPrefix($this->data['site'], $languageId);
+
+        $itemValue = $parameterArray['itemFormElValue'];
+        $config = $parameterArray['fieldConf']['config'];
+        $evalList = GeneralUtility::trimExplode(',', $config['eval'], true);
+        $size = MathUtility::forceIntegerInRange($config['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth);
+        $width = (int)$this->formMaxWidth($size);
+
+        // Convert UTF-8 characters back (that is important, see Slug class when sanitizing)
+        $itemValue = rawurldecode($itemValue);
+
+        $idAttribute = StringUtility::getUniqueId('formengine-input-');
+        $attributes = [
+            'value' => '',
+            'id' => $idAttribute,
+            'class' => 'form-control',
+            'disabled' => 'disabled',
+            'placeholder' => '/',
+            'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config),
+            'data-formengine-input-params' => json_encode([
+                'field' => $parameterArray['itemFormElName'],
+                'evalList' => implode(',', $evalList),
+                'is_in' => trim($config['is_in'] ?? '')
+            ]),
+            'data-formengine-input-name' => $parameterArray['itemFormElName'],
+        ];
+
+        $fieldInformationResult = $this->renderFieldInformation();
+        $fieldInformationHtml = $fieldInformationResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
+
+        $fieldControlResult = $this->renderFieldControl();
+        $fieldControlHtml = $fieldControlResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
+
+        $fieldWizardResult = $this->renderFieldWizard();
+        $fieldWizardHtml = $fieldWizardResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
+
+        $mainFieldHtml = [];
+        $mainFieldHtml[] = '<div class="formengine-field-item t3js-formengine-field-item">';
+        $mainFieldHtml[] = $fieldInformationHtml;
+        $mainFieldHtml[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">';
+        $mainFieldHtml[] =  '<div class="form-wizards-wrap">';
+        $mainFieldHtml[] =      '<div class="form-wizards-element">';
+        $mainFieldHtml[] =          '<div class="input-group">' . ($baseUrl ? '<span class="input-group-addon">' . htmlspecialchars($baseUrl) . '</span>' : '') . '<input type="text"' . GeneralUtility::implodeAttributes($attributes, true) . ' /></div>';
+        $mainFieldHtml[] =          '<input type="hidden" name="' . $parameterArray['itemFormElName'] . '" value="' . htmlspecialchars($itemValue) . '" />';
+        $mainFieldHtml[] =      '</div>';
+        if (!empty($fieldControlHtml)) {
+            $mainFieldHtml[] =  '<div class="form-wizards-items-aside">';
+            $mainFieldHtml[] =      '<div class="btn-group">';
+            $mainFieldHtml[] =          $fieldControlHtml;
+            $mainFieldHtml[] =      '</div>';
+            $mainFieldHtml[] =  '</div>';
+        }
+        if (!empty($fieldWizardHtml)) {
+            $mainFieldHtml[] =  '<div class="form-wizards-items-bottom">';
+            $mainFieldHtml[] =      $fieldWizardHtml;
+            $mainFieldHtml[] =  '</div>';
+        }
+        $mainFieldHtml[] =  '</div>';
+        $mainFieldHtml[] = '</div>';
+        $mainFieldHtml[] = '</div>';
+
+        $resultArray['html'] = implode(LF, $mainFieldHtml);
+        return $resultArray;
+    }
+
+    /**
+     * Render the prefix for the input field.
+     *
+     * @param SiteInterface $site
+     * @param int $requestLanguageId
+     * @return string
+     */
+    protected function getPrefix(SiteInterface $site, int $requestLanguageId = 0): string
+    {
+        $language = $site->getLanguageById($requestLanguageId);
+        $baseUrl = $language->getBase();
+        $baseUrl = rtrim($baseUrl, '/');
+        if (!empty($baseUrl)) {
+            $urlParts = parse_url($baseUrl);
+            if (!isset($urlParts['scheme']) && isset($urlParts['host'])) {
+                $baseUrl = 'http:' . $baseUrl;
+            }
+        }
+        return $baseUrl;
+    }
+
+    /**
+     * @return LanguageService
+     */
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
index 7b867fd..fb88e49 100644 (file)
@@ -92,6 +92,7 @@ class NodeFactory
         'unknown' => Element\UnknownElement::class,
         'user' => Element\UserElement::class,
         'fileInfo' => Element\FileInfoElement::class,
+        'slug' => Element\InputSlugElement::class,
 
         // Default classes to enrich single elements
         'fieldControl' => NodeExpansion\FieldControl::class,
index 4348cc9..edeab4e 100644 (file)
@@ -1536,7 +1536,7 @@ class DataHandler implements LoggerAwareInterface
                 default:
                     if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
                         // Evaluating the value
-                        $res = $this->checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID);
+                        $res = $this->checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray);
                         if (array_key_exists('value', $res)) {
                             $fieldArray[$field] = $res['value'];
                         }
@@ -1602,9 +1602,10 @@ class DataHandler implements LoggerAwareInterface
      * @param string $status 'update' or 'new' flag
      * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. If $realPid is -1 it means that a new version of the record is being inserted.
      * @param int $tscPID TSconfig PID
+     * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray)
      * @return array Returns the evaluated $value as key "value" in this array. Can be checked with isset($res['value']) ...
      */
-    public function checkValue($table, $field, $value, $id, $status, $realPid, $tscPID)
+    public function checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = [])
     {
         // Result array
         $res = [];
@@ -1661,7 +1662,7 @@ class DataHandler implements LoggerAwareInterface
         }
 
         // Perform processing:
-        $res = $this->checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $this->uploadedFileArray[$table][$id][$field], $tscPID);
+        $res = $this->checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $this->uploadedFileArray[$table][$id][$field], $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
         return $res;
     }
 
@@ -1704,6 +1705,9 @@ class DataHandler implements LoggerAwareInterface
             case 'input':
                 $res = $this->checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field);
                 break;
+            case 'slug':
+                $res = $this->checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []);
+                break;
             case 'check':
                 $res = $this->checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
                 break;
@@ -1918,6 +1922,54 @@ class DataHandler implements LoggerAwareInterface
     }
 
     /**
+     * Evaluate "slug" type values.
+     *
+     * @param string $value The value to set.
+     * @param array $tcaFieldConf Field configuration from TCA
+     * @param string $table Table name
+     * @param int $id UID of record
+     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. If $realPid is -1 it means that a new version of the record is being inserted.
+     * @param string $field Field name
+     * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) for the record
+     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
+     */
+    protected function checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
+    {
+        $workspaceId = $this->BE_USER->workspace;
+        $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
+        $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray ?? []);
+        // Generate a value if there is none, otherwise ensure that all characters are cleaned up
+        if (empty($value)) {
+            $value = $helper->generate($fullRecord, $realPid);
+        } else {
+            $value = $helper->sanitize($value);
+        }
+
+        $languageId = (int)$fullRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']];
+        $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
+
+        // In case a workspace is given, and the $realPid(!) still is negative
+        // this is most probably triggered by versionizeRecord() and a raw record
+        // copy - thus, uniqueness cannot be determined without having the
+        // real information
+        // @todo This is still not explicit, but probably should be
+        if ($workspaceId > 0 && $realPid === -1
+            && !MathUtility::canBeInterpretedAsInteger($id)
+        ) {
+            return ['value' => $value];
+        }
+
+        if (in_array('uniqueInSite', $evalCodesArray, true)) {
+            $value = $helper->buildSlugForUniqueInSite($value, $id, $realPid, $languageId);
+        }
+        if (in_array('uniqueInPid', $evalCodesArray, true)) {
+            $value = $helper->buildSlugForUniqueInPid($value, $id, $realPid, $languageId);
+        }
+
+        return ['value' => $value];
+    }
+
+    /**
      * Evaluates 'check' type values.
      *
      * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
@@ -3866,7 +3918,7 @@ class DataHandler implements LoggerAwareInterface
         foreach ($fieldArray as $field => $fieldValue) {
             if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
                 // Evaluating the value.
-                $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0);
+                $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
                 if (isset($res['value'])) {
                     $fieldArray[$field] = $res['value'];
                 }
diff --git a/typo3/sysext/core/Classes/DataHandling/SlugHelper.php b/typo3/sysext/core/Classes/DataHandling/SlugHelper.php
new file mode 100644 (file)
index 0000000..1f2c7dd
--- /dev/null
@@ -0,0 +1,455 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\DataHandling;
+
+/*
+ * 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 TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Charset\CharsetConverter;
+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\Routing\SiteMatcher;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
+
+/**
+ * Generates, sanitizes and validates slugs for a TCA field
+ */
+class SlugHelper
+{
+    /**
+     * @var string
+     */
+    protected $tableName;
+
+    /**
+     * @var string
+     */
+    protected $fieldName;
+
+    /**
+     * @var int
+     */
+    protected $workspaceId;
+
+    /**
+     * @var array
+     */
+    protected $configuration = [];
+
+    /**
+     * @var bool
+     */
+    protected $workspaceEnabled;
+
+    /**
+     * Slug constructor.
+     *
+     * @param string $tableName TCA table
+     * @param string $fieldName TCA field
+     * @param array $configuration TCA configuration of the field
+     * @param int $workspaceId the workspace ID to be working on.
+     */
+    public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
+    {
+        $this->tableName = $tableName;
+        $this->fieldName = $fieldName;
+        $this->configuration = $configuration;
+        $this->workspaceId = $workspaceId;
+
+        $this->workspaceEnabled = BackendUtility::isTableWorkspaceEnabled($tableName);
+    }
+
+    /**
+     * Cleans a slug value so it is used directly in the path segment of a URL.
+     *
+     * @param string $slug
+     * @return string
+     */
+    public function sanitize(string $slug): string
+    {
+        // Convert to lowercase + remove tags
+        $slug = mb_strtolower($slug, 'utf-8');
+        $slug = strip_tags($slug);
+
+        // Convert some special tokens (space, "_" and "-") to the space character
+        $fallbackCharacter = (string)($this->configuration['fallbackCharacter'] ?? '-');
+        $slug = preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $slug);
+
+        // Convert extended letters to ascii equivalents
+        $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);
+
+        // Get rid of all invalid characters, but allow slashes
+        $slug = preg_replace('/[^\p{L}0-9\/' . preg_quote($fallbackCharacter) . ']/u', '', $slug);
+
+        // Convert multiple fallback characters to a single one
+        if ($fallbackCharacter !== '') {
+            $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
+        }
+
+        // Ensure slug is lower cased after all replacement was done:
+        // The specCharsToASCII() above for example converts "€" to "EUR"
+        $slug = mb_strtolower($slug, 'utf-8');
+        // keep slashes: re-convert them after rawurlencode did everything
+        $slug = rawurlencode($slug);
+        // @todo: add a test and see if we need this
+        $slug = str_replace('%2F', '/', $slug);
+        // Remove trailing and beginning slashes
+        $slug = '/' . $this->extract($slug);
+        return $slug;
+    }
+
+    /**
+     * Extracts payload of slug and removes wrapping delimiters,
+     * e.g. `/hello/world/` will become `hello/world`.
+     *
+     * @param string $slug
+     * @return string
+     */
+    public function extract(string $slug): string
+    {
+        // Convert some special tokens (space, "_" and "-") to the space character
+        $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
+        return trim($slug, $fallbackCharacter . '/');
+    }
+
+    /**
+     * Used when no slug exists for a record
+     *
+     * @param array $recordData
+     * @param int $pid
+     * @return string
+     */
+    public function generate(array $recordData, int $pid): string
+    {
+        if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
+            return '/';
+        }
+        $prefix = '';
+        $languageId = (int)$recordData[$GLOBALS['TCA'][$this->tableName]['ctrl']['languageField']];
+        if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
+            $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
+            $parentPageRecord = reset($rootLine);
+            if ($languageId > 0) {
+                $parentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
+                if (!empty($parentPageRecord)) {
+                    $parentPageRecord = reset($parentPageRecord);
+                }
+            }
+            if (is_array($parentPageRecord)) {
+                $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
+                $rootLineItemSlug = trim($rootLineItemSlug, '/');
+                if (!empty($rootLineItemSlug)) {
+                    $prefix = $rootLineItemSlug;
+                }
+            }
+        }
+
+        $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
+        $slugParts = [];
+        foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldName) {
+            if (!empty($recordData[$fieldName])) {
+                $slugParts[] = $recordData[$fieldName];
+            }
+        }
+        $slug = implode($fieldSeparator, $slugParts);
+        if (!empty($prefix)) {
+            $slug = $prefix . '/' . $slug;
+        }
+
+        return $this->sanitize($slug);
+    }
+
+    /**
+     * Checks if there are other records with the same slug that are located on the same PID.
+     *
+     * @param string $slug
+     * @param string|int $recordId
+     * @param int $pageId
+     * @param int $languageId
+     * @return bool
+     */
+    public function isUniqueInPid(string $slug, $recordId, int $pageId, int $languageId): bool
+    {
+        if ($pageId < 0) {
+            $pageId = $this->resolveLivePageId($recordId);
+        }
+
+        $queryBuilder = $this->createPreparedQueryBuilder();
+        $this->applySlugConstraint($queryBuilder, $slug);
+        $this->applyPageIdConstraint($queryBuilder, $pageId);
+        $this->applyRecordConstraint($queryBuilder, $recordId);
+        $this->applyLanguageConstraint($queryBuilder, $languageId);
+        $this->applyWorkspaceConstraint($queryBuilder);
+        $statement = $queryBuilder->execute();
+        return $statement->rowCount() === 0;
+    }
+
+    /**
+     * Check if there are other records with the same slug that are located on the same site.
+     *
+     * @param string $slug
+     * @param string|int $recordId
+     * @param int $pageId
+     * @param int $languageId
+     * @return bool
+     */
+    public function isUniqueInSite(string $slug, $recordId, int $pageId, int $languageId): bool
+    {
+        if ($pageId < 0) {
+            $pageId = $this->resolveLivePageId($recordId);
+        }
+
+        $queryBuilder = $this->createPreparedQueryBuilder();
+        $this->applySlugConstraint($queryBuilder, $slug);
+        $this->applyRecordConstraint($queryBuilder, $recordId);
+        $this->applyLanguageConstraint($queryBuilder, $languageId);
+        $this->applyWorkspaceConstraint($queryBuilder);
+        $statement = $queryBuilder->execute();
+
+        $records = $statement->fetchAll();
+        if (count($records) === 0) {
+            return true;
+        }
+
+        // The installation contains at least ONE other record with the same slug
+        // Now find out if it is the same root page ID
+        $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
+        $siteOfCurrentRecord = $siteMatcher->matchByPageId($pageId);
+        foreach ($records as $record) {
+            $siteOfExistingRecord = $siteMatcher->matchByPageId((int)$record['uid']);
+            if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
+                return false;
+            }
+        }
+
+        // Otherwise, everything is still fine
+        return true;
+    }
+
+    /**
+     * Generate a slug with a suffix "/mytitle-1" if that is in use already.
+     *
+     * @param string $slug proposed slug
+     * @param mixed $recordId can be a new record (non-int) or an existing record ID
+     * @param int $realPid pageID (already workspace-resolved)
+     * @param int $languageId the language ID realm to be searched for
+     * @return string
+     */
+    public function buildSlugForUniqueInSite(string $slug, $recordId, int $realPid, int $languageId): string
+    {
+        $slug = $this->sanitize($slug);
+        $rawValue = $this->extract($slug);
+        $newValue = $slug;
+        $counter = 0;
+        while (!$this->isUniqueInSite(
+                $newValue,
+                $recordId,
+                $realPid,
+                $languageId
+            ) && $counter++ < 100
+        ) {
+            $newValue = $this->sanitize($rawValue . '-' . $counter);
+        }
+        if ($counter === 100) {
+            $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
+        }
+        return $newValue;
+    }
+
+    /**
+     * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
+     *
+     * @param string $slug proposed slug
+     * @param mixed $recordId can be a new record (non-int) or an existing record ID
+     * @param int $realPid pageID (already workspace-resolved)
+     * @param int $languageId the language ID realm to be searched for
+     * @return string
+     */
+    public function buildSlugForUniqueInPid(string $slug, $recordId, int $realPid, int $languageId): string
+    {
+        $slug = $this->sanitize($slug);
+        $rawValue = $this->extract($slug);
+        $newValue = $slug;
+        $counter = 0;
+        while (!$this->isUniqueInPid(
+                $newValue,
+                $recordId,
+                $realPid,
+                $languageId
+            ) && $counter++ < 100
+        ) {
+            $newValue = $this->sanitize($rawValue . '-' . $counter);
+        }
+        if ($counter === 100) {
+            $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
+        }
+        return $newValue;
+    }
+
+    /**
+     * @return QueryBuilder
+     */
+    protected function createPreparedQueryBuilder(): QueryBuilder
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
+        $queryBuilder->getRestrictions()
+            ->removeAll()
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
+        $queryBuilder
+            ->select('uid', 'pid', $this->fieldName)
+            ->from($this->tableName);
+        return $queryBuilder;
+    }
+
+    /**
+     * @param QueryBuilder $queryBuilder
+     */
+    protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder)
+    {
+        if (!$this->workspaceEnabled) {
+            return;
+        }
+
+        $workspaceIds = [0];
+        if ($this->workspaceId > 0) {
+            $workspaceIds[] = $this->workspaceId;
+        }
+        $queryBuilder->andWhere(
+            $queryBuilder->expr()->in(
+                't3ver_wsid',
+                $queryBuilder->createNamedParameter($workspaceIds, Connection::PARAM_INT_ARRAY)
+            )
+        );
+    }
+
+    /**
+     * @param QueryBuilder $queryBuilder
+     * @param int $languageId
+     */
+    protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
+    {
+        $languageField = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
+        if (!is_string($languageField)) {
+            return;
+        }
+
+        // Only check records of the given language
+        $queryBuilder->andWhere(
+            $queryBuilder->expr()->eq(
+                $languageField,
+                $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
+            )
+        );
+    }
+
+    /**
+     * @param QueryBuilder $queryBuilder
+     * @param string $slug
+     */
+    protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
+    {
+        $queryBuilder->where(
+            $queryBuilder->expr()->eq(
+                $this->fieldName,
+                $queryBuilder->createNamedParameter($slug)
+            )
+        );
+    }
+
+    /**
+     * @param QueryBuilder $queryBuilder
+     * @param int $pageId
+     */
+    protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
+    {
+        if ($pageId < 0) {
+            throw new \RuntimeException(
+                sprintf(
+                    'Page id must be positive "%d"',
+                    $pageId
+                ),
+                1534962573
+            );
+        }
+
+        $queryBuilder->andWhere(
+            $queryBuilder->expr()->eq(
+                'pid',
+                $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
+            )
+        );
+    }
+
+    /**
+     * @param QueryBuilder $queryBuilder
+     * @param string|int $recordId
+     */
+    protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
+    {
+        // Exclude the current record if it is an existing record
+        if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
+            return;
+        }
+
+        $queryBuilder->andWhere(
+            $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
+        );
+        if ($this->workspaceId > 0 && $this->workspaceEnabled) {
+            $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, $recordId) ?? $recordId;
+            $queryBuilder->andWhere(
+                $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
+            );
+        }
+    }
+
+    /**
+     * @param int $recordId
+     * @return int
+     * @throws \RuntimeException
+     */
+    protected function resolveLivePageId($recordId): int
+    {
+        if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
+            throw new \RuntimeException(
+                sprintf(
+                    'Cannot resolve live page id for non-numeric identifier "%s"',
+                    $recordId
+                ),
+                1534951024
+            );
+        }
+
+        $liveVersion = BackendUtility::getLiveVersionOfRecord(
+            $this->tableName,
+            $recordId,
+            'pid'
+        );
+
+        if (empty($liveVersion)) {
+            throw new \RuntimeException(
+                sprintf(
+                    'Cannot resolve live page id for record "%s:%d"',
+                    $this->tableName,
+                    $recordId
+                ),
+                1534951025
+            );
+        }
+
+        return (int)$liveVersion['pid'];
+    }
+}
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-84729-NewTCATypeSlug.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-84729-NewTCATypeSlug.rst
new file mode 100644 (file)
index 0000000..2d72e58
--- /dev/null
@@ -0,0 +1,61 @@
+.. include:: ../../Includes.txt
+
+=====================================
+Feature: #84729 - New TCA type "slug"
+=====================================
+
+See :issue:`84729`
+
+Description
+===========
+
+A new TCA field type called `slug` has been added to TYPO3 Core. Its main purpose is to define parts of a URL
+path to generate and resolve URLs.
+
+With a URL like `https://www.typo3.org/ch/community/values/core-values/` a URL slug is typically a part like
+`/community` or `/community/values/core-values`.
+
+Within TYPO3, a slug is always part of the URL "path" - it does not scheme, host, HTTP verb, etc.
+
+A slug is usually added to a TCA-based database table, contain some rules for evaluation and definition.
+
+In contrast to concepts within RealURL of "URL segments", a slug is a segment of a URL, but it is not limited
+to be separated by slashes. Therefore, a slug can contain slashes.
+
+In the future, it could be possible to generate slugs for any TCA table, but its's main usage will be for the "pages"
+TCA structure.
+
+If a TCA table contains a field called "slug", it needs to be filled for every existing record. It can
+be shown and edited via regular Backend Forms, and is also evaluated during persistence via DataHandler.
+
+The default behaviour of a slug is as follows:
+- A slug only contains characters which are allowed within URLs. Spaces, commas and other special characters
+ are converted to a fallback character.
+- A slug is always lower-cased.
+- A slug is unicode-aware.
+
+The following options apply to the new TCA type:
+       'type' => 'slug',
+       'config' => [
+               'generatorOptions' => [
+                       'fields' => ['title', 'nav_title'],
+                       'fieldSeparator' => '/',
+                       'prefixParentPageSlug' => true
+               ]
+               'fallbackCharacter' => '-',
+               'eval' => 'uniqueInSite'
+       ]
+
+In addition the new 'eval' option 'uniqueInSite' to evaluate if a record is unique in a page tree (specific to a
+language).
+
+The new slug TCA type allows for two `eval` options `uniqueInSite` or `uniqueInPid` (useful for third-party
+records), and no other eval setting is checked for. It is possible to set both eval options, however it is
+recommended not to do so.
+
+It is possible to build a default value from the rootline (very helpful for pages, or categorized slugs),
+but also to just generate a "speaking" segment from e.g. a news title.
+
+Sanitization and Validation configuration options apply when persisting a record via DataHandler.
+
+.. index:: TCA, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/DataHandling/SlugHelperTest.php b/typo3/sysext/core/Tests/Unit/DataHandling/SlugHelperTest.php
new file mode 100644 (file)
index 0000000..22ad184
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\DataHandling;
+
+/*
+ * 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\DataHandling\SlugHelper;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class SlugHelperTest extends UnitTestCase
+{
+    /**
+     * @var bool
+     */
+    protected $resetSingletonInstances = true;
+
+    /**
+     * @return array
+     */
+    public function sanitizeDataProvider(): array
+    {
+        return [
+            'empty string' => [
+                [],
+                '',
+                '/',
+            ],
+            'lowercase characters' => [
+                [],
+                '1AZÄ',
+                '/1azae',
+            ],
+            'strig tags' => [
+                [],
+                '<foo>bar</foo>',
+                '/bar'
+            ],
+            'replace special chars to -' => [
+                [],
+                '1 2-3+4_5',
+                '/1-2-3-4-5',
+            ],
+            'empty fallback character' => [
+                [
+                    'fallbackCharacter' => '',
+                ],
+                '1_2',
+                '/12',
+            ],
+            'different fallback character' => [
+                [
+                    'fallbackCharacter' => '_',
+                ],
+                '1-2',
+                '/1_2',
+            ],
+            'convert umlauts' => [
+                [],
+                'ä ß Ö',
+                '/ae-ss-oe'
+            ],
+            'keep slashes' => [
+                [],
+                '1/2',
+                '/1/2',
+            ],
+            'keep pending slash' => [
+                [],
+                '/1/2',
+                '/1/2',
+            ],
+            'remove trailing slash' => [
+                [],
+                '1/2/',
+                '/1/2',
+            ],
+            'keep pending slash and remove fallback' => [
+                [],
+                '/-1/2',
+                '/1/2',
+            ],
+            'remove trailing slash and fallback' => [
+                [],
+                '1/2-/',
+                '/1/2',
+            ],
+            'reduce multiple fallback chars to one' => [
+                [],
+                '1---2',
+                '/1-2',
+            ],
+            'various special chars' => [
+                [],
+                'special-chars-«-∑-€-®-†-Ω-¨-ø-π-å-‚-∂-ƒ-©-ª-º-∆-@-¥-≈-ç-√-∫-~-µ-∞-…-–',
+                '/special-chars-eur-r-o-oe-p-aa-f-c-a-o-yen-c-u'
+            ],
+            'various special chars, allow unicode' => [
+                [
+                    'allowUnicodeCharacters' => true,
+                ],
+                'special-chars-«-∑-€-®-†-Ω-¨-ø-π-å-‚-∂-ƒ-©-ª-º-∆-@-¥-≈-ç-√-∫-~-µ-∞-…-–',
+                '/special-chars-eur-r-o-oe-p-aa-f-c-a-o-yen-c-u'
+            ]
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider sanitizeDataProvider
+     * @param array $configuration
+     * @param string $input
+     * @param string $expected
+     */
+    public function sanitizeConvertsString(array $configuration, string $input, string $expected)
+    {
+        $subject = new SlugHelper(
+            'dummyTable',
+            'dummyField',
+            $configuration
+        );
+        static::assertEquals(
+            $expected,
+            $subject->sanitize($input)
+        );
+    }
+}