Your IP : 216.73.216.220


Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Configuration/
Upload File :
Current File : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Configuration/CKEditor5Migrator.php

<?php

declare(strict_types=1);

/*
 * 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!
 */

namespace TYPO3\CMS\Core\Configuration;

use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * @internal
 */
class CKEditor5Migrator
{
    /**
     * Main groups in CKEditor4 contain subgroups.
     * These groups are expanded during migration.
     */
    private const TOOLBAR_MAIN_GROUPS_MAP = [
        'document' => ['mode', 'document', 'doctools'],
        'clipboard' => ['clipboard', 'undo'],
        'editing' => ['find', 'selection', 'spellchecker', 'editing'],
        'forms' => ['forms'],
        'basicstyles' => ['basicstyles', 'cleanup'],
        'paragraph' => ['list', 'indent', 'blocks', 'align', 'bidi', 'paragraph'],
        'links' => ['links'],
        'insert' => ['insert'],
        'styles' => ['styles'],
        'colors' => ['colors'],
        'tools' => ['tools'],
        'others' => ['others'],
        'about' => ['about'],
        'blocks' => ['blocks'],
        'table' => ['table'],
        'tabletools' => [],
    ];

    /**
     * Groups in CKEditor4 contain buttons.
     */
    private const TOOLBAR_GROUPS_MAP = [
        'mode' => ['Source'],
        'document' => ['Save', 'NewPage', 'Preview', 'Print'],
        'doctools' => ['Templates'],
        'clipboard' => ['Cut', 'Copy', 'Paste', 'PasteText', 'PasteFromWord'],
        'undo' => ['Undo', 'Redo'],
        'find' => ['Find', 'Replace'],
        'selection' => ['SelectAll'],
        'spellchecker' => ['Scayt'],
        'forms' => ['Form', 'Checkbox', 'Radio', 'TextField', 'Textarea', 'Select', 'Button', 'ImageButton', 'HiddenField'],
        'basicstyles' => ['Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript', 'SoftHyphen'],
        'cleanup' => ['CopyFormatting', 'RemoveFormat'],
        'list' => ['NumberedList', 'BulletedList'],
        'indent' => ['Indent', 'Outdent'],
        'blocks' => ['Blockquote', 'CreateDiv'],
        'align' => ['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'],
        'bidi' => ['BidiLtr', 'BidiRtl', 'Language'],
        'links' => ['Link', 'Unlink', 'Anchor'],
        'insert' => ['Image', 'Flash', 'Table', 'HorizontalRule', 'Smiley', 'SpecialChar', 'PageBreak', 'Iframe'],
        'styles' => ['Styles', 'Format', 'Font', 'FontSize'],
        'format' => ['Format'],
        'table' => ['Table'],
        'specialchar' => ['SpecialChar'],
        'colors' => ['TextColor', 'BGColor'],
        'tools' => ['Maximize', 'ShowBlocks'],
        'about' => ['About'],
        'others' => [],
    ];

    // List of "old" button names vs the replacement(s)
    private const BUTTON_MAP = [
        // mode
        'Source' => 'sourceEditing',
        // document
        'Save' => null,
        'NewPage' => null,
        'Preview' => null,
        'Print' => null,
        // doctools
        'Templates' => null,
        // clipboard
        'Cut' => null,
        'Copy' => null,
        'Paste' => null,
        'PasteText' => null,
        'PasteFromWord' => null,
        // undo
        'Undo' => 'undo',
        'Redo' => 'redo',
        // find
        'Find' => null,
        'Replace' => 'findAndReplace',
        // selection
        'SelectAll' => 'selectAll',
        // spellchecker
        'Scayt' => null,
        // forms
        'Form' => null,
        'Checkbox' => null,
        'Radio' => null,
        'TextField' => null,
        'Textarea' => null,
        'Select' => null,
        'Button' => null,
        'ImageButton' => null,
        'HiddenField' => null,
        // basicstyles
        'Bold' => 'bold',
        'Italic' => 'italic',
        'Underline' => 'underline',
        'Strike' => 'strikethrough',
        'Subscript' => 'subscript',
        'Superscript' => 'superscript',
        // cleanup
        'CopyFormatting' => null,
        'RemoveFormat' => 'removeFormat',
        // list
        'NumberedList' => 'numberedList',
        'BulletedList' => 'bulletedList',
        // indent
        'Outdent' => 'outdent',
        'Indent' => 'indent',
        // blocks
        'Blockquote' => 'blockQuote',
        'CreateDiv' => null,
        // align
        'JustifyLeft' => 'alignment:left',
        'JustifyCenter' => 'alignment:center',
        'JustifyRight' => 'alignment:right',
        'JustifyBlock' => 'alignment:justify',
        // bidi
        'BidiLtr' => null,
        'BidiRtl' => null,
        'Language' => 'textPartLanguage',
        // links
        'Link' => 'link',
        'Unlink' => null,
        'Anchor' => null,
        // insert
        'Image' => 'insertImage',
        'Flash' => null,
        'Table' => 'insertTable',
        'HorizontalRule' => 'horizontalLine',
        'Smiley' => null,
        'SpecialChar' => 'specialCharacters',
        'PageBreak' => 'pageBreak',
        'Iframe' => null,
        // styles
        'Styles' => 'style',
        'Format' => 'heading',
        'Font' => null,
        'FontSize' => null,
        // colors
        'TextColor' => null,
        'BGColor' => null,
        // tools
        'Maximize' => null,
        'ShowBlocks' => null,
        // about
        'About' => null,
        // typo3
        'SoftHyphen' => 'softhyphen',
    ];

    /**
     * Mapping of plugins
     */
    private const PLUGIN_MAP = [
        'image' => [
            'module' => '@ckeditor/ckeditor5-image',
            'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
        ],
        'Image' => [
            'module' => '@ckeditor/ckeditor5-image',
            'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
        ],
        'alignment' => [
            'module' => '@ckeditor/ckeditor5-alignment',
            'exports' => [ 'Alignment' ],
        ],
        'Alignment' => [
            'module' => '@ckeditor/ckeditor5-alignment',
            'exports' => [ 'Alignment' ],
        ],
        'autolink' => [
            'module' => '@ckeditor/ckeditor5-link',
            'exports' => [ 'AutoLink' ],
        ],
        'AutoLink' => [
            'module' => '@ckeditor/ckeditor5-link',
            'exports' => [ 'AutoLink' ],
        ],
        'justify' => [
            'module' => '@ckeditor/ckeditor5-alignment',
            'exports' => [ 'Alignment' ],
        ],
        'showblocks' => [
            'module' =>  '@ckeditor/ckeditor5-show-blocks',
            'exports' => [ 'ShowBlocks' ],
        ],
        'ShowBlocks' => [
            'module' =>  '@ckeditor/ckeditor5-show-blocks',
            'exports' => [ 'ShowBlocks' ],
        ],
        'softhyphen' => [
            'module' =>  '@typo3/rte-ckeditor/plugin/whitespace.js',
            'exports' => [ 'Whitespace' ],
        ],
        'whitespace' => [
            'module' =>  '@typo3/rte-ckeditor/plugin/whitespace.js',
            'exports' => [ 'Whitespace' ],
        ],
        'Whitespace' => [
            'module' =>  '@typo3/rte-ckeditor/plugin/whitespace.js',
            'exports' => [ 'Whitespace' ],
        ],
        'wordcount' =>  [
            'module' => '@ckeditor/ckeditor5-word-count',
            'exports' => [ 'WordCount' ],
        ],
        'WordCount' =>  [
            'module' => '@ckeditor/ckeditor5-word-count',
            'exports' => [ 'WordCount' ],
        ],
    ];

    /**
     * @param array $configuration Richtext configuration
     */
    public function __construct(protected array $configuration)
    {
        if (isset($this->configuration['editor']['config'])) {
            $this->migrateExtraPlugins();
            $this->migrateRemovePlugins();
            $this->migrateToolbar();
            $this->migrateRemoveButtonsFromToolbar();
            $this->migrateFormatTagsToHeadings();
            $this->migrateStylesSetToStyleDefinitions();
            $this->migrateContentsCssToArray();
            $this->migrateTypo3LinkAdditionalAttributes();
            $this->migrateAllowedContent();
            // configure plugins
            $this->handleAlignmentPlugin();
            $this->handleWhitespacePlugin();
            $this->handleWordCountPlugin();

            // sort by key
            ksort($this->configuration['editor']['config']);
        }

        if (isset($this->configuration['buttons']['link'])) {
            $this->addLinkClassesToStyleSets();
        }
    }

    public function get(): array
    {
        return $this->configuration;
    }

    protected function migrateExtraPlugins(): void
    {
        if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
            return;
        }

        foreach ($this->configuration['editor']['config']['extraPlugins'] as $entry) {
            $moduleToBeLoaded = self::PLUGIN_MAP[$entry] ?? null;
            if ($moduleToBeLoaded === null) {
                continue;
            }
            $this->configuration['editor']['config']['importModules'][] = $moduleToBeLoaded;
            $this->removeExtraPlugin($entry);
        }
    }

    protected function migrateRemovePlugins(): void
    {
        if (!isset($this->configuration['editor']['config']['removePlugins'])) {
            return;
        }

        foreach ($this->configuration['editor']['config']['removePlugins'] as $key => $entry) {
            $moduleToBeRemoved = self::PLUGIN_MAP[$entry] ?? null;
            if ($moduleToBeRemoved !== null) {
                unset($this->configuration['editor']['config']['removePlugins'][$key]);
                $this->configuration['editor']['config']['removeImportModules'][] = $moduleToBeRemoved;
            }
        }
        if (count($this->configuration['editor']['config']['removePlugins']) === 0) {
            unset($this->configuration['editor']['config']['removePlugins']);
        } else {
            $this->configuration['editor']['config']['removePlugins'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['removePlugins']);
        }
    }

    /**
     * CE4: https://ckeditor.com/latest/samples/toolbarconfigurator/index.html#basic
     * CE5: https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format
     */
    protected function migrateToolbar(): void
    {
        /**
         * Collection of the final toolbar configuration
         * @var array{items: string[], removeItems: string[], shouldNotGroupWhenFull: bool} $toolbar
         */
        $toolbar = [
            'items' => [],
            'removeItems' => $this->configuration['editor']['config']['toolbar']['removeItems'] ?? [],
            'shouldNotGroupWhenFull' => $this->configuration['editor']['config']['toolbar']['shouldNotGroupWhenFull'] ?? true,
        ];

        // Migrate CKEditor4 toolbarGroups
        // There can only be one configuration at a time, if 'toolbarGroups' is set
        // we prefer this definition above the toolbar definition.
        // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbarGroups
        if (is_array($this->configuration['editor']['config']['toolbarGroups'] ?? null)) {
            $toolbar['items'] = $this->configuration['editor']['config']['toolbarGroups'];
            unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config']['toolbarGroups']);
        }

        // Migrate CKEditor4 toolbar templates
        // Resolve toolbar template and override current toolbar
        // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbar
        if (is_string($this->configuration['editor']['config']['toolbar'] ?? null)) {
            $toolbarName = 'toolbar_' . trim($this->configuration['editor']['config']['toolbar']);
            if (is_array($this->configuration['editor']['config'][$toolbarName] ?? null)) {
                $toolbar['items'] = $this->configuration['editor']['config'][$toolbarName];
                unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config'][$toolbarName]);
            }
        }

        // Collect toolbar items
        if (is_array($this->configuration['editor']['config']['toolbar'] ?? null)) {
            $toolbar['items'] = $this->configuration['editor']['config']['toolbar']['items'] ?? $this->configuration['editor']['config']['toolbar'];
        }

        $toolbar['items'] = $this->migrateToolbarItems($toolbar['items']);
        $this->configuration['editor']['config']['toolbar'] = $toolbar;
    }

    protected function migrateToolbarItems(array $items): array
    {
        $toolbarItems = [];
        foreach ($items as $item) {
            if (is_string($item)) {
                $toolbarItems[] = $this->migrateToolbarButton($item);
                continue;
            }
            if (is_array($item)) {
                // Expand CKEditor4 preset toolbar groups
                if (is_string($item['name'] ?? null) && count($item) === 1 && isset(self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']])) {
                    $item['groups'] = self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']];
                }
                // Flatten CKEditor4 arrays that only have strings assigned
                if (count($item) === count(array_filter($item, static fn($value) => is_string($value)))) {
                    $migratedToolbarItems = $item;
                    $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems);
                    $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems);
                    array_push($toolbarItems, ...$migratedToolbarItems);
                    $toolbarItems[] = '|';
                    continue;
                }
                // Flatten CKEditor4 named groups
                if (is_string($item['name'] ?? null) && is_array($item['items'] ?? null)) {
                    $migratedToolbarItems = $item['items'];
                    $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems);
                    $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems);
                    array_push($toolbarItems, ...$migratedToolbarItems);
                    $toolbarItems[] = '|';
                    continue;
                }
                // Expand CKEditor4 toolbar groups
                if (is_string($item['name'] ?? null) && is_array($item['groups'] ?? null)) {
                    $itemGroups = array_filter($item['groups'], static fn($itemGroup) => is_string($itemGroup));

                    // Process Main CKEditor4 Groups
                    $unGroupedToolbarItems = [];
                    foreach ($itemGroups as $itemGroup) {
                        if (isset(self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup])) {
                            array_push($unGroupedToolbarItems, ...self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup]);
                            $unGroupedToolbarItems[] = '|';
                            continue;
                        }
                        $unGroupedToolbarItems[] = $itemGroup;
                    }

                    // Process CKEditor4 Groups
                    $groupedToolbarItems = [];
                    foreach ($itemGroups as $itemGroup) {
                        if (isset(self::TOOLBAR_GROUPS_MAP[$itemGroup])) {
                            array_push($groupedToolbarItems, ...self::TOOLBAR_GROUPS_MAP[$itemGroup]);
                            $groupedToolbarItems[] = '|';
                            continue;
                        }
                        $groupedToolbarItems[] = $itemGroup;
                    }

                    $migratedToolbarItems = $groupedToolbarItems;
                    $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems);
                    $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems);
                    array_push($toolbarItems, ...$migratedToolbarItems);
                    $toolbarItems[] = '|';
                    continue;
                }

                $toolbarItems[] = $item;
            }
        }

        $toolbarItems = $this->migrateToolbarLinebreaks($toolbarItems);
        $toolbarItems = $this->migrateToolbarCleanup($toolbarItems);

        return array_values($toolbarItems);
    }

    protected function migrateToolbarButton(string $buttonName): ?string
    {
        if (array_key_exists($buttonName, self::BUTTON_MAP)) {
            return self::BUTTON_MAP[$buttonName];
        }
        return $buttonName;
    }

    protected function migrateToolbarButtons(array $toolbarItems): array
    {
        $processedItems = [];
        foreach ($toolbarItems as $toolbarItem) {
            if (is_string($toolbarItem)) {
                if (($toolbarItem = $this->migrateToolbarButton($toolbarItem)) !== null) {
                    $processedItems[] = $this->migrateToolbarButton($toolbarItem);
                }
            } else {
                $processedItems[] = $toolbarItem;
            }
        }

        return $processedItems;
    }

    protected function migrateToolbarSpacers(array $toolbarItems): array
    {
        $processedItems = [];
        foreach ($toolbarItems as $toolbarItem) {
            if (is_string($toolbarItem)) {
                $toolbarItem = str_replace('-', '|', $toolbarItem);
            }
            $processedItems[] = $toolbarItem;
        }

        return $processedItems;
    }

    protected function migrateToolbarLinebreaks(array $toolbarItems): array
    {
        $processedItems = [];
        foreach ($toolbarItems as $toolbarItem) {
            if (is_string($toolbarItem)) {
                $toolbarItem = str_replace('/', '-', $toolbarItem);
            }
            $processedItems[] = $toolbarItem;
        }

        return $processedItems;
    }

    protected function migrateToolbarCleanup(array $toolbarItems): array
    {
        // Ensure buttons are only added once to the toolbar.
        $searchValues = [];
        foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
            if (is_string($toolbarItem) && !in_array($toolbarItem, ['|', '-'])) {
                if (array_key_exists($toolbarItem, $searchValues)) {
                    unset($toolbarItems[$toolbarKey]);
                } else {
                    $searchValues[$toolbarItem] = true;
                }
            }
        }

        $previousItem = null;
        $previousKey = null;
        foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
            if ($previousItem === null && ($toolbarItem === '|' || $toolbarItem === '-')) {
                unset($toolbarItems[$toolbarKey]);
                continue;
            }

            if ($previousItem === '|' && ($toolbarItem === '|' || $toolbarItem === '-')) {
                unset($toolbarItems[$previousKey]);
            }

            $previousKey = $toolbarKey;
            $previousItem = $toolbarItem;
        }

        $lastToolbarItem = array_slice($toolbarItems, -1, 1);
        if ($lastToolbarItem === ['-'] || $lastToolbarItem === ['|']) {
            array_pop($toolbarItems);
        }

        return array_values($toolbarItems);
    }

    protected function migrateRemoveButtonsFromToolbar(): void
    {
        if (!isset($this->configuration['editor']['config']['removeButtons'])) {
            return;
        }

        if (is_string($this->configuration['editor']['config']['removeButtons'])) {
            $this->configuration['editor']['config']['removeButtons'] = GeneralUtility::trimExplode(
                ',',
                $this->configuration['editor']['config']['removeButtons'],
                true
            );
        }

        $removeItems = [];
        foreach ($this->configuration['editor']['config']['removeButtons'] as $buttonName) {
            if (array_key_exists($buttonName, self::BUTTON_MAP)) {
                if (self::BUTTON_MAP[$buttonName] !== null) {
                    $removeItems[] = self::BUTTON_MAP[$buttonName];
                }
            } else {
                $removeItems[] = $buttonName;
            }
        }

        foreach ($removeItems as $name) {
            $this->removeToolbarItem($name);
        }

        // Cleanup final configuration after migration
        unset($this->configuration['editor']['config']['removeButtons']);
    }

    protected function migrateFormatTagsToHeadings(): void
    {
        // new definition is in place, no migration is done
        if (isset($this->configuration['editor']['config']['heading']['options'])) {
            // discard legacy configuration if new configuration exists
            unset($this->configuration['editor']['config']['format_tags']);
            return;
        }
        // migrate format_tags to custom buttons
        if (isset($this->configuration['editor']['config']['format_tags'])) {
            $formatTags = explode(';', $this->configuration['editor']['config']['format_tags']);
            $allowedHeadings = [];
            foreach ($formatTags as $paragraphTag) {
                switch (strtolower($paragraphTag)) {
                    case 'p':
                        $allowedHeadings[] = [
                            'model' => 'paragraph',
                            'title' => 'Paragraph',
                        ];
                        break;
                    case 'h1':
                    case 'h2':
                    case 'h3':
                    case 'h4':
                    case 'h5':
                    case 'h6':
                        $headingNumber = substr($paragraphTag, -1);
                        $allowedHeadings[] = [
                            'model' => 'heading' . $headingNumber,
                            'view' => 'h' . $headingNumber,
                            'title' => 'Heading ' . $headingNumber,
                        ];
                        break;
                    case 'pre':
                        $allowedHeadings[] = [
                            'model' => 'formatted',
                            'view' => 'pre',
                            'title' => 'Formatted',
                        ];
                }
            }

            // remove legacy configuration after migration
            unset($this->configuration['editor']['config']['format_tags']);
            $this->configuration['editor']['config']['heading']['options'] = $allowedHeadings;
        }
    }

    protected function migrateStylesSetToStyleDefinitions(): void
    {
        // new definition is in place, no migration is done
        if (isset($this->configuration['editor']['config']['style']['definitions'])) {
            // discard legacy configuration if new configuration exists
            unset($this->configuration['editor']['config']['stylesSet']);
            return;
        }
        // Migrate 'stylesSet' to 'styles' => 'definitions'
        if (isset($this->configuration['editor']['config']['stylesSet'])) {
            $styleDefinitions = [];
            foreach ($this->configuration['editor']['config']['stylesSet'] as $styleSet) {
                if (!isset($styleSet['name'], $styleSet['element'])) {
                    // @todo: log
                    continue;
                }
                $class = $styleSet['attributes']['class'] ?? null;
                $definition = [
                    'name' => $styleSet['name'],
                    'element' => $styleSet['element'],
                    'classes' => [''],
                ];
                if ($class) {
                    $definition['classes'] = explode(' ', $class);
                }
                $styleDefinitions[] = $definition;
            }

            // remove legacy configuration after migration
            unset($this->configuration['editor']['config']['stylesSet']);
            $this->configuration['editor']['config']['style']['definitions'] = $styleDefinitions;
        }
    }

    protected function migrateContentsCssToArray(): void
    {
        if (isset($this->configuration['editor']['config']['contentsCss'])) {
            if (!is_array($this->configuration['editor']['config']['contentsCss'])) {
                if (empty($this->configuration['editor']['config']['contentsCss'])) {
                    unset($this->configuration['editor']['config']['contentsCss']);
                    return;
                }
                $this->configuration['editor']['config']['contentsCss'] = (array)$this->configuration['editor']['config']['contentsCss'];
            }

            $this->configuration['editor']['config']['contentsCss'] = array_map(static function (mixed $styleSrc) {
                // Trim values, if input is a string, otherwise leave as-is (will be filtered out)
                return is_string($styleSrc) ? trim($styleSrc) : $styleSrc;
            }, $this->configuration['editor']['config']['contentsCss']);
            $this->configuration['editor']['config']['contentsCss'] = array_values(
                array_filter($this->configuration['editor']['config']['contentsCss'], static function (mixed $styleSrc): bool {
                    // We care for non-empty strings only
                    return is_string($styleSrc) && $styleSrc !== '';
                })
            );
        }
    }

    protected function migrateTypo3LinkAdditionalAttributes(): void
    {
        if (!isset($this->configuration['editor']['config']['typo3link']['additionalAttributes'])) {
            return;
        }
        $additionalAttributes = $this->configuration['editor']['config']['typo3link']['additionalAttributes'];
        unset($this->configuration['editor']['config']['typo3link']['additionalAttributes']);
        if ($this->configuration['editor']['config']['typo3link'] === []) {
            unset($this->configuration['editor']['config']['typo3link']);
        }
        if (!is_array($additionalAttributes) || $additionalAttributes === []) {
            return;
        }
        $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
            'name' => 'a',
            'attributes' => array_values($additionalAttributes),
        ];
    }

    protected function parseRuleProperties(string $properties, string $type): ?string
    {
        $groupsPatterns = [
            'styles' => '/{([^}]+)}/',
            'attrs' => '/\[([^\]]+)\]/',
            'classes' => '/\(([^\)]+)\)/',
        ];
        $pattern = $groupsPatterns[$type] ?? null;
        if ($pattern === null) {
            throw new \InvalidArgumentException('Expected type to be styles, attrs or classes', 1696326899);
        }

        $matches = [];
        if (preg_match($pattern, $properties, $matches) === 1) {
            return trim($matches[1]);
        }

        return null;
    }

    /**
     * Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1438
     */
    protected function parseRulesString(string $input): array
    {
        $ruleConfig = [];
        do {
            $matches = [];
            $res = preg_match(
                // Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1431
                // <   elements   ><                       styles, attributes and classes                        >< separator >
                '/^([a-z0-9\-*\s]+)((?:\s*\{[!\w\-,\s\*]+\}\s*|\s*\[[!\w\-,\s\*]+\]\s*|\s*\([!\w\-,\s\*]+\)\s*){0,3})(?:;\s*|$)/i',
                $input,
                $matches
            );
            if ($res === false || $res === 0) {
                return $ruleConfig;
            }
            $name = $matches[1];
            $properties = $matches[2] ?? null;
            $config = true;
            if ($properties !== null) {
                $config = [];
                $config['styles'] = $this->parseRuleProperties($properties, 'styles');
                $config['attributes'] = $this->parseRuleProperties($properties, 'attrs');
                $config['classes'] = $this->parseRuleProperties($properties, 'classes');
            }
            $ruleConfig[$name] = $config;

            $input = substr($input, strlen($matches[0]));
        } while ($input !== '');
        return $ruleConfig;
    }

    protected function migrateAllowedContent(): void
    {
        $types = [
            'allowedContent' => 'allow',
            'extraAllowedContent' => 'allow',
            'disallowedContent' => 'disallow',
        ];

        foreach ($types as $option4 => $option5) {
            if (!isset($this->configuration['editor']['config'][$option4])) {
                continue;
            }

            if ($option4 === 'allowedContent') {
                if ($this->configuration['editor']['config']['allowedContent'] === true || $this->configuration['editor']['config']['allowedContent'] === '1') {
                    $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
                        // Allow *any* tag (even custom elements)
                        'name' => [
                            'pattern' => '.+',
                        ],
                        'attributes' => true,
                        'classes' => true,
                        'styles' => true,
                    ];
                    unset($this->configuration['editor']['config']['allowedContent']);
                    continue;
                }
            }

            $config4 = $this->configuration['editor']['config'][$option4];
            if (is_string($config4)) {
                $config4 = $this->parseRulesString($config4);
            }

            foreach ($config4 as $name => $options) {
                $config = [];
                if ($name !== '*') {
                    $config['name'] = str_contains($name, '*') || str_contains($name, ' ') ?
                        [ 'pattern' => str_replace(['*', ' '], ['.+', '|'], $name) ] :
                        $name;
                }

                if (is_bool($options)) {
                    if ($options) {
                        $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
                    }
                    continue;
                }

                if (!is_array($options)) {
                    continue;
                }

                $wildcardToRegex = fn(string $v): string|array => str_contains($v, '*') ? [ 'pattern' => str_replace('*', '.+', $v) ] : $v;
                if (isset($options['classes'])) {
                    if ($options['classes'] === '*') {
                        $config['classes'] = true;
                    } else {
                        $config['classes'] = array_map($wildcardToRegex, explode(',', $options['classes']));
                    }
                }

                if (isset($options['attributes'])) {
                    if ($options['attributes'] === '*') {
                        $config['attributes'] = true;
                    } else {
                        $config['attributes'] = array_map($wildcardToRegex, explode(',', $options['attributes']));
                    }
                }

                if (isset($options['styles'])) {
                    if ($options['styles'] === '*') {
                        $config['styles'] = true;
                    } else {
                        $config['styles'] = array_map($wildcardToRegex, explode(',', $options['styles']));
                    }
                }
                $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
            }
            unset($this->configuration['editor']['config'][$option4]);
        }
    }

    protected function handleAlignmentPlugin(): void
    {
        // Migrate legacy configuration
        // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-justifyClasses
        if (isset($this->configuration['editor']['config']['justifyClasses'])) {
            if (!isset($this->configuration['editor']['config']['alignment'])) {
                $legacyConfig = $this->configuration['editor']['config']['justifyClasses'];
                $indexMap = [
                    0 => 'left',
                    1 => 'center',
                    2 => 'right',
                    3 => 'justify',
                ];
                foreach ($legacyConfig as $index => $class) {
                    $itemConfig = [];
                    if (isset($indexMap[$index])) {
                        $itemConfig['name'] = $indexMap[$index];
                    }
                    $itemConfig['className'] = $class;
                    $this->configuration['editor']['config']['alignment']['options'][] = $itemConfig;
                }
            }
            unset($this->configuration['editor']['config']['justifyClasses']);
        }
        $this->removeExtraPlugin('justify');

        // Remove related configuration if plugin should not be loaded
        if (in_array(
            '@ckeditor/ckeditor5-alignment',
            array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
            true
        )) {
            // Remove toolbar items
            $this->removeToolbarItem('alignment');
            $this->removeToolbarItem('alignment:left');
            $this->removeToolbarItem('alignment:right');
            $this->removeToolbarItem('alignment:center');
            $this->removeToolbarItem('alignment:justify');

            // Remove config
            if (isset($this->configuration['editor']['config']['alignment'])) {
                unset($this->configuration['editor']['config']['alignment']);
            }

            return;
        }

        if (is_array($this->configuration['editor']['config']['alignment']['options'] ?? null)) {
            $classMap = [];
            foreach ($this->configuration['editor']['config']['alignment']['options'] as $option) {
                if (is_string($option['name'] ?? null)
                    && is_string($option['className'] ?? null)
                    && in_array($option['name'], ['left', 'center', 'right', 'justify'])) {
                    $classMap[$option['name']] = $option['className'];
                }
            }
        }

        // Default config
        $this->configuration['editor']['config']['alignment'] = [
            'options' => [
                ['name' => 'left', 'className' => $classMap['left'] ?? 'text-start'],
                ['name' => 'center', 'className' => $classMap['center'] ?? 'text-center'],
                ['name' => 'right', 'className' => $classMap['right'] ?? 'text-end'],
                ['name' => 'justify', 'className' => $classMap['justify'] ?? 'text-justify'],
            ],
        ];
    }

    protected function handleWhitespacePlugin(): void
    {
        // Remove related configuration if plugin should not be loaded
        if (in_array(
            '@typo3/rte-ckeditor/plugin/whitespace.js',
            array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
            true
        )) {
            // Remove toolbar items
            $this->removeToolbarItem('softhyphen');

            return;
        }

        // Add button if missing
        if (!in_array('softhyphen', $this->configuration['editor']['config']['toolbar']['items'], true)) {
            $this->configuration['editor']['config']['toolbar']['items'][] = 'softhyphen';
        }
    }

    protected function handleWordCountPlugin(): void
    {
        // Migrate legacy configuration
        //
        // CKEditor4 used `wordcount` (lowercase), which is `wordCount` in CKEditor5.
        // The amount of properties has been reduced.
        //
        // see https://ckeditor.com/docs/ckeditor5/latest/features/word-count.html
        if (isset($this->configuration['editor']['config']['wordcount'])) {
            if (!isset($this->configuration['editor']['config']['wordCount'])) {
                $legacyConfig = $this->configuration['editor']['config']['wordcount'];
                if (isset($legacyConfig['showCharCount'])) {
                    $this->configuration['editor']['config']['wordCount']['displayCharacters'] = !empty($legacyConfig['showCharCount']);
                }
                if (isset($legacyConfig['showWordCount'])) {
                    $this->configuration['editor']['config']['wordCount']['displayWords'] = !empty($legacyConfig['showWordCount']);
                }
            }
            unset($this->configuration['editor']['config']['wordcount']);
        }

        // Remove related configuration if plugin should not be loaded
        if (in_array(
            '@ckeditor/ckeditor5-word-count',
            array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
            true
        )) {
            // Remove config
            if (isset($this->configuration['editor']['config']['wordCount'])) {
                unset($this->configuration['editor']['config']['wordCount']);
            }

            return;
        }

        // Default config
        $this->configuration['editor']['config']['wordCount'] = [
            'displayCharacters' => $this->configuration['editor']['config']['wordCount']['displayCharacters'] ?? true,
            'displayWords' => $this->configuration['editor']['config']['wordCount']['displayWords'] ?? true,
        ];
    }

    protected function addLinkClassesToStyleSets(): void
    {
        if (!isset($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])) {
            return;
        }

        // Ensure editor.config.style.definitions exists
        $this->configuration['editor']['config']['style']['definitions'] ??= [];

        $allowedClassSets = is_array($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])
            ? $this->configuration['buttons']['link']['properties']['class']['allowedClasses']
            : GeneralUtility::trimExplode(',', $this->configuration['buttons']['link']['properties']['class']['allowedClasses'], true);

        // Determine index where link classes should be added at to keep styles grouped
        $indexToInsertElementsAt = array_key_last($this->configuration['editor']['config']['style']['definitions']) + 1;
        foreach ($this->configuration['editor']['config']['style']['definitions'] as $index => $styleSetDefinition) {
            if ($styleSetDefinition['element'] === 'a') {
                $indexToInsertElementsAt = $index + 1;
            }
        }

        foreach ($allowedClassSets as $classSet) {
            $allowedClasses = GeneralUtility::trimExplode(' ', $classSet);
            foreach ($this->configuration['editor']['config']['style']['definitions'] as $styleSetDefinition) {
                if ($styleSetDefinition['element'] === 'a' && $styleSetDefinition['classes'] === $allowedClasses) {
                    // allowedClasses is already configured, continue with next one
                    continue 2;
                }
            }

            // We're still here, this means $allowedClasses wasn't found
            array_splice($this->configuration['editor']['config']['style']['definitions'], $indexToInsertElementsAt, 0, [[
                'classes' => $allowedClasses,
                'element' => 'a',
                'name' => implode(' ', $allowedClasses), // we lack a human-readable name here...
            ]]);
            $indexToInsertElementsAt++;
        }
    }

    private function removeToolbarItem(string $name): void
    {
        $this->configuration['editor']['config']['toolbar']['removeItems'][] = $name;
        $this->configuration['editor']['config']['toolbar']['removeItems'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['toolbar']['removeItems']);
    }

    private function removeExtraPlugin(string $name): void
    {
        if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
            return;
        }

        $this->configuration['editor']['config']['extraPlugins'] = array_filter($this->configuration['editor']['config']['extraPlugins'], function ($value) use ($name) {
            return $value !== $name;
        });

        if (empty($this->configuration['editor']['config']['extraPlugins'])) {
            unset($this->configuration['editor']['config']['extraPlugins']);
            return;
        }

        $this->configuration['editor']['config']['extraPlugins'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['extraPlugins']);
    }

    /**
     * Ensure to have clean array with incrementing identifiers
     * to avoid JavaScript casting this to an object
     */
    private function getUniqueArrayValues(array $array)
    {
        return array_values(array_unique($array));
    }
}