Your IP : 216.73.217.13


Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Configuration/FlexForm/
Upload File :
Current File : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Configuration/FlexForm/FlexFormTools.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\FlexForm;

use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureIdentifierInitializedEvent;
use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureParsedEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureIdentifierInitializedEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureParsedEvent;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidCombinedPointerFieldException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowLoopException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowRootException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidPointerFieldValueException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidSinglePointerFieldException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaException;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Migrations\TcaMigration;
use TYPO3\CMS\Core\Preparations\TcaPreparation;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;

/**
 * Contains functions for manipulating flex form data
 *
 * The data structure identifier (array) has several commonly used
 * elements (keys), but is generally undefined. Users of this system
 * should be extra careful of what array keys are defined or not.
 *
 * Recommended keys include:
 *  - type
 *  - tableName
 *  - fieldName
 *  - dataStructureKey
 */
class FlexFormTools
{
    /**
     * If set, section indexes are re-numbered before processing
     *
     * @var bool
     */
    public $reNumberIndexesOfSectionData = false;

    /**
     * Options for array2xml() for flexform.
     * This will map the weird keys from the internal array to tags that could potentially be checked with a DTD/schema
     *
     * @var array
     */
    public $flexArray2Xml_options = [
        'parentTagMap' => [
            'data' => 'sheet',
            'sheet' => 'language',
            'language' => 'field',
            'el' => 'field',
            'field' => 'value',
            'field:el' => 'el',
            'el:_IS_NUM' => 'section',
            'section' => 'itemType',
        ],
        'disableTypeAttrib' => 2,
    ];

    /**
     * Reference to object called
     *
     * @var object
     */
    public $callBackObj;

    /**
     * Used for accumulation of clean XML
     *
     * @var array
     */
    public $cleanFlexFormXML = [];

    public function __construct(
        private ?EventDispatcherInterface $eventDispatcher = null,
    ) {
        $this->eventDispatcher ??= GeneralUtility::makeInstance(EventDispatcherInterface::class);
    }

    /**
     * The method locates a specific data structure from given TCA and row combination
     * and returns an identifier string that can be handed around, and can be resolved
     * to a single data structure later without giving $row and $tca data again.
     *
     * Note: The returned syntax is meant to only specify the target location of the data structure.
     * It SHOULD NOT be abused and enriched with data from the record that is dealt with. For
     * instance, it is now allowed to add source record specific date like the uid or the pid!
     * If that is done, it is up to the hook consumer to take care of possible side effects, eg. if
     * the data handler copies or moves records around and those references change.
     *
     * This method gets: Source data that influences the target location of a data structure
     * This method returns: Target specification of the data structure
     *
     * This method is "paired" with method getFlexFormDataStructureByIdentifier() that
     * will resolve the returned syntax again and returns the data structure itself.
     *
     * Both methods can be extended via events to return and accept additional
     * identifier strings if needed, and to transmit further information within the identifier strings.
     *
     * Note that the TCA for data structure definitions MUST NOT be overridden by
     * 'columnsOverrides' or by parent TCA in an inline relation! This would create a huge mess.
     *
     * Note: This method and the resolving methods below are well unit tested and document all
     * nasty details this way.
     *
     * @param array $fieldTca Full TCA of the field in question that has type=flex set
     * @param string $tableName The table name of the TCA field
     * @param string $fieldName The field name
     * @param array $row The data row
     * @return string Identifier JSON string
     * @throws \RuntimeException If TCA is misconfigured
     * @throws InvalidParentRowException in getDataStructureIdentifierFromRecord
     * @throws InvalidParentRowLoopException in getDataStructureIdentifierFromRecord
     * @throws InvalidParentRowRootException in getDataStructureIdentifierFromRecord
     * @throws InvalidPointerFieldValueException in getDataStructureIdentifierFromRecord
     * @throws InvalidTcaException in getDataStructureIdentifierFromRecord
     */
    public function getDataStructureIdentifier(array $fieldTca, string $tableName, string $fieldName, array $row): string
    {
        $dataStructureIdentifier = $this->eventDispatcher
            ->dispatch(new BeforeFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row))
            ->getIdentifier() ?? $this->getDefaultIdentifier($fieldTca, $tableName, $fieldName, $row);

        $dataStructureIdentifier = $this->eventDispatcher
            ->dispatch(new AfterFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row, $dataStructureIdentifier))
            ->getIdentifier();

        return json_encode($dataStructureIdentifier, JSON_THROW_ON_ERROR);
    }

    /**
     * Returns the default data structure identifier.
     *
     * @param array $fieldTca Full TCA of the field in question that has type=flex set
     * @param string $tableName The table name of the TCA field
     * @param string $fieldName The field name
     * @param array $row The data row
     */
    protected function getDefaultIdentifier(array $fieldTca, string $tableName, string $fieldName, array $row): array
    {
        $tcaDataStructureArray = $fieldTca['config']['ds'] ?? null;
        $tcaDataStructurePointerField = $fieldTca['config']['ds_pointerField'] ?? null;
        if (!is_array($tcaDataStructureArray) && $tcaDataStructurePointerField) {
            // "ds" is not an array, but "ds_pointerField" is set -> data structure is found in different table
            $dataStructureIdentifier = $this->getDataStructureIdentifierFromRecord(
                $fieldTca,
                $tableName,
                $fieldName,
                $row
            );
        } elseif (is_array($tcaDataStructureArray)) {
            $dataStructureIdentifier = $this->getDataStructureIdentifierFromTcaArray(
                $fieldTca,
                $tableName,
                $fieldName,
                $row
            );
        } else {
            throw new \RuntimeException(
                'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
                . ' The field is configured as type="flex" and no "ds_pointerField" is defined and "ds" is not an array.'
                . ' Either configure a default data structure in [\'ds\'][\'default\'] or add a "ds_pointerField" lookup mechanism'
                . ' that specifies the data structure',
                1463826960
            );
        }

        return $dataStructureIdentifier;
    }

    /**
     * The data structure is located in a record. This method resolves the record and
     * returns an array to identify that record.
     *
     * The example setup below looks in current row for a tx_templavoila_ds value. If not found,
     * it will search the rootline (the table is a tree, typically pages) until a value in
     * tx_templavoila_next_ds or tx_templavoila_ds is found. That value should then be an
     * integer, that points to a record in tx_templavoila_datastructure, and then the data
     * structure is found in field dataprot:
     *
     * fieldTca = [
     *     'config' => [
     *         'type' => 'flex',
     *         'ds_pointerField' => 'tx_templavoila_ds',
     *         'ds_pointerField_searchParent' => 'pid',
     *         'ds_pointerField_searchParent_subField' => 'tx_templavoila_next_ds',
     *         'ds_tableField' => 'tx_templavoila_datastructure:dataprot',
     *     ]
     * ]
     *
     * More simple scenario without tree traversal and having a valid data structure directly
     * located in field theFlexDataStructureField.
     *
     * fieldTca = [
     *     'config' => [
     *         'type' => 'flex',
     *         'ds_pointerField' => 'theFlexDataStructureField',
     *     ]
     * ]
     *
     * Example return array:
     * [
     *     'type' => 'record',
     *     'tableName' => 'tx_templavoila_datastructure',
     *     'uid' => 42,
     *     'fieldName' => 'dataprot',
     * ];
     *
     * @param array $fieldTca Full TCA of the field in question that has type=flex set
     * @param string $tableName The table name of the TCA field
     * @param string $fieldName The field name
     * @param array $row The data row
     * @return array Identifier as array, see example above
     * @throws InvalidParentRowException
     * @throws InvalidParentRowLoopException
     * @throws InvalidParentRowRootException
     * @throws InvalidPointerFieldValueException
     * @throws InvalidTcaException
     */
    protected function getDataStructureIdentifierFromRecord(array $fieldTca, string $tableName, string $fieldName, array $row): array
    {
        $pointerFieldName = $finalPointerFieldName = $fieldTca['config']['ds_pointerField'];
        if (!array_key_exists($pointerFieldName, $row)) {
            // Pointer field does not exist in row at all -> throw
            throw new InvalidTcaException(
                'No data structure for field "' . $fieldName . '" in table "' . $tableName . '" found, no "ds" array'
                . ' configured and given row does not have a field with ds_pointerField name "' . $pointerFieldName . '".',
                1464115059
            );
        }
        $pointerValue = $row[$pointerFieldName];
        // If set, this is typically set to "pid"
        $parentFieldName = $fieldTca['config']['ds_pointerField_searchParent'] ?? null;
        $pointerSubFieldName = $fieldTca['config']['ds_pointerField_searchParent_subField'] ?? null;
        if (!$pointerValue && $parentFieldName) {
            // Fetch rootline until a valid pointer value is found
            $handledUids = [];
            while (!$pointerValue) {
                $handledUids[$row['uid']] = 1;
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
                $queryBuilder->getRestrictions()
                    ->removeAll()
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
                $queryBuilder->select('uid', $parentFieldName, $pointerFieldName);
                if (!empty($pointerSubFieldName)) {
                    $queryBuilder->addSelect($pointerSubFieldName);
                }
                $queryStatement = $queryBuilder->from($tableName)
                    ->where(
                        $queryBuilder->expr()->eq(
                            'uid',
                            $queryBuilder->createNamedParameter($row[$parentFieldName], Connection::PARAM_INT)
                        )
                    )
                    ->executeQuery();
                $rowCount = $queryBuilder
                    ->count('uid')
                    ->executeQuery()
                    ->fetchOne();
                if ($rowCount !== 1) {
                    throw new InvalidParentRowException(
                        'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
                        . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
                        . ' with uid "' . $row[$parentFieldName] . '" was done. This row however does not exist or was deleted.',
                        1463833794
                    );
                }
                $row = $queryStatement->fetchAssociative();
                if (isset($handledUids[$row[$parentFieldName]])) {
                    // Row has been fetched before already -> loop detected!
                    throw new InvalidParentRowLoopException(
                        'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
                        . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
                        . ' with uid "' . $row[$parentFieldName] . '" was done. A loop of records was detected, the tree is broken.',
                        1464110956
                    );
                }
                BackendUtility::workspaceOL($tableName, $row);
                // New pointer value: This is the "subField" value if given, else the field value
                // ds_pointerField_searchParent_subField is the "template on next level" structure from templavoila
                if ($pointerSubFieldName && $row[$pointerSubFieldName]) {
                    $finalPointerFieldName = $pointerSubFieldName;
                    $pointerValue = $row[$pointerSubFieldName];
                } else {
                    $pointerValue = $row[$pointerFieldName];
                }
                if (!$pointerValue && ((int)$row[$parentFieldName] === 0 || $row[$parentFieldName] === null)) {
                    // If on root level and still no valid pointer found -> exception
                    throw new InvalidParentRowRootException(
                        'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
                        . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
                        . ' with uid "' . $row[$parentFieldName] . '" was done. Root node with uid "' . $row['uid'] . '"'
                        . ' was fetched and still no valid pointer field value was found.',
                        1464112555
                    );
                }
            }
        }
        if (!$pointerValue) {
            // Still no valid pointer value -> exception, This still can be a data integrity issue, so throw a catchable exception
            throw new InvalidPointerFieldValueException(
                'No data structure for field "' . $fieldName . '" in table "' . $tableName . '" found, no "ds" array'
                . ' configured and data structure could be found by resolving parents. This is probably a TCA misconfiguration.',
                1464114011
            );
        }
        // Ok, finally we have the field value. This is now either a data structure directly, or a pointer to a file,
        // or the value can be interpreted as integer (is a uid) and "ds_tableField" is set, so this is the table, uid and field
        // where the final data structure can be found.
        if (MathUtility::canBeInterpretedAsInteger($pointerValue)) {
            if (!isset($fieldTca['config']['ds_tableField'])) {
                throw new InvalidTcaException(
                    'Invalid data structure pointer for field "' . $fieldName . '" in table "' . $tableName . '", the value'
                    . 'resolved to "' . $pointerValue . '" . which is an integer, so "ds_tableField" must be configured',
                    1464115639
                );
            }
            if (substr_count($fieldTca['config']['ds_tableField'], ':') !== 1) {
                // ds_tableField must be of the form "table:field"
                throw new InvalidTcaException(
                    'Invalid TCA configuration for field "' . $fieldName . '" in table "' . $tableName . '", the setting'
                    . '"ds_tableField" must be of the form "tableName:fieldName"',
                    1464116002
                );
            }
            [$foreignTableName, $foreignFieldName] = GeneralUtility::trimExplode(':', $fieldTca['config']['ds_tableField']);
            $dataStructureIdentifier = [
                'type' => 'record',
                'tableName' => $foreignTableName,
                'uid' => (int)$pointerValue,
                'fieldName' => $foreignFieldName,
            ];
        } else {
            $dataStructureIdentifier = [
                'type' => 'record',
                'tableName' => $tableName,
                'uid' => (int)$row['uid'],
                'fieldName' => $finalPointerFieldName,
            ];
        }
        return $dataStructureIdentifier;
    }

    /**
     * Find matching data structure in TCA ds array.
     *
     * Data structure is defined in 'ds' config array.
     * Also, there can be a ds_pointerField
     *
     * fieldTca = [
     *     'config' => [
     *         'type' => 'flex',
     *         'ds' => [
     *             'aName' => '<T3DataStructure>...' OR 'FILE:...'
     *         ],
     *         'ds_pointerField' => 'optionalSetting,upToTwoCommaSeparatedFieldNames',
     *     ]
     * ]
     *
     * This method returns an array of the form:
     * [
     *     'type' => 'Tca:',
     *     'tableName' => $tableName,
     *     'fieldName' => $fieldName,
     *     'dataStructureKey' => $key,
     * ];
     *
     * Example:
     * [
     *     'type' => 'Tca:',
     *     'tableName' => 'tt_content',
     *     'fieldName' => 'pi_flexform',
     *     'dataStructureKey' => 'powermail_pi1,list',
     * ];
     *
     * @param array $fieldTca Full TCA of the field in question that has type=flex set
     * @param string $tableName The table name of the TCA field
     * @param string $fieldName The field name
     * @param array $row The data row
     * @return array Identifier as array, see example above
     * @throws InvalidCombinedPointerFieldException
     * @throws InvalidSinglePointerFieldException
     * @throws InvalidTcaException
     */
    protected function getDataStructureIdentifierFromTcaArray(array $fieldTca, string $tableName, string $fieldName, array $row): array
    {
        $dataStructureIdentifier = [
            'type' => 'tca',
            'tableName' => $tableName,
            'fieldName' => $fieldName,
            'dataStructureKey' => null,
        ];
        $tcaDataStructurePointerField = $fieldTca['config']['ds_pointerField'] ?? null;
        if ($tcaDataStructurePointerField === null) {
            // No ds_pointerField set -> use 'default' as ds array key if exists.
            if (isset($fieldTca['config']['ds']['default'])) {
                $dataStructureIdentifier['dataStructureKey'] = 'default';
            } else {
                // A tca is configured as flex without ds_pointerField. A 'default' key must exist, otherwise
                // this is a configuration error.
                // May happen with an unloaded extension -> catchable
                throw new InvalidTcaException(
                    'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
                    . ' The field is configured as type="flex" and no "ds_pointerField" is defined. Either configure'
                    . ' a default data structure in [\'ds\'][\'default\'] or add a "ds_pointerField" lookup mechanism'
                    . ' that specifies the data structure',
                    1463652560
                );
            }
        } else {
            // ds_pointerField is set, it can be a comma separated list of two fields, explode it.
            $pointerFieldArray = GeneralUtility::trimExplode(',', $tcaDataStructurePointerField, true);
            // Obvious configuration error, either one or two fields must be declared
            $pointerFieldsCount = count($pointerFieldArray);
            if ($pointerFieldsCount !== 1 && $pointerFieldsCount !== 2) {
                // If it's there, it must be correct -> not catchable
                throw new \RuntimeException(
                    'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
                    . ' ds_pointerField must be either a single field name, or a comma separated list of two fields,'
                    . ' the invalid configuration string provided was: "' . $tcaDataStructurePointerField . '"',
                    1463577497
                );
            }
            // Verify first field exists in row array. If not, this is a hard error: Any extension that sets a
            // ds_pointerField to some field name should take care that field does exist, too. They are a pair,
            // so there shouldn't be a situation where the field does not exist. Throw an exception if that is violated.
            if (!isset($row[$pointerFieldArray[0]])) {
                // If it's declared, it must exist -> not catchable
                throw new \RuntimeException(
                    'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
                    . ' ds_pointerField "' . $pointerFieldArray[0] . '" points to a field name that does not exist.',
                    1463578899
                );
            }
            // Similar situation for the second field: If it is set, the field must exist.
            if (isset($pointerFieldArray[1]) && !isset($row[$pointerFieldArray[1]])) {
                // If it's declared, it must exist -> not catchable
                throw new \RuntimeException(
                    'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
                    . ' Second part "' . $pointerFieldArray[1] . '" of ds_pointerField with full value "'
                    . $tcaDataStructurePointerField . '" points to a field name that does not exist.',
                    1463578900
                );
            }
            if ($pointerFieldsCount === 1) {
                if (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]]])) {
                    // Field value points directly to an existing key in tca ds
                    $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]];
                } elseif (isset($fieldTca['config']['ds']['default'])) {
                    // Field value does not exit in tca ds, fall back to default key if exists
                    $dataStructureIdentifier['dataStructureKey'] = 'default';
                } else {
                    // The value of the ds_pointerField field points to a key in the ds array that does
                    // not exist, and there is no fallback either. This can happen if an extension brings
                    // new flex form definitions and that extension is unloaded later. "Old" records of the
                    // extension could then still point to the no longer existing key in ds. We throw a
                    // specific exception here to give controllers an opportunity to catch this case.
                    throw new InvalidSinglePointerFieldException(
                        'Field value of field "' . $pointerFieldArray[0] . '" of database record with uid "'
                        . $row['uid'] . '" from table "' . $tableName . '" points to a "ds" key ' . $row[$pointerFieldArray[0]]
                        . ' but this key does not exist and there is no "default" fallback.',
                        1463653197
                    );
                }
            } else {
                // Two comma separated field names
                if (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]] . ',' . $row[$pointerFieldArray[1]]])) {
                    // firstValue,secondValue
                    $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]] . ',' . $row[$pointerFieldArray[1]];
                } elseif (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]] . ',*'])) {
                    // firstValue,*
                    $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]] . ',*';
                } elseif (isset($fieldTca['config']['ds']['*,' . $row[$pointerFieldArray[1]]])) {
                    // *,secondValue
                    $dataStructureIdentifier['dataStructureKey'] = '*,' . $row[$pointerFieldArray[1]];
                } elseif (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]]])) {
                    // firstValue
                    $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]];
                } elseif (isset($fieldTca['config']['ds']['default'])) {
                    // Fall back to default
                    $dataStructureIdentifier['dataStructureKey'] = 'default';
                } else {
                    // No ds_pointerField value could be determined and 'default' does not exist as
                    // fallback. This is the same case as the above scenario, throw a
                    // InvalidCombinedPointerFieldException here, too.
                    throw new InvalidCombinedPointerFieldException(
                        'Field combination of fields "' . $pointerFieldArray[0] . '" and "' . $pointerFieldArray[1] . '" of database'
                        . 'record with uid "' . $row['uid'] . '" from table "' . $tableName . '" with values "' . $row[$pointerFieldArray[0]] . '"'
                        . ' and "' . $row[$pointerFieldArray[1]] . '" could not be resolved to any registered data structure and '
                        . ' no "default" fallback exists.',
                        1463678524
                    );
                }
            }
        }
        return $dataStructureIdentifier;
    }

    /**
     * Parse a data structure identified by $identifier to the final data structure array.
     * This method is called after getDataStructureIdentifier(), finds the data structure
     * and returns it.
     *
     * Hooks allow to manipulate the find logic and to post process the data structure array.
     *
     * Note that the TCA for data structure definitions MUST NOT be overridden by
     * 'columnsOverrides' or by parent TCA in an inline relation! This would create a huge mess.
     *
     * After the data structure definition is found, the method resolves:
     * * FILE:EXT: prefix of the data structure itself - the ds is in a file
     * * FILE:EXT: prefix for sheets - if single sheets are in files
     * * Create an sDEF sheet if the data structure has non, yet.
     *
     * After that method is run, the data structure is fully resolved to an array,
     * and same base normalization is done: If the ds did not contain a sheet,
     * it will have one afterwards as "sDEF"
     *
     * This method gets: Target specification of the data structure.
     * This method returns: The normalized data structure parsed to an array.
     *
     * Read the unit tests for nasty details.
     *
     * @param string $identifier JSON string to find the data structure location
     * @return array Parsed and normalized data structure
     * @throws InvalidIdentifierException
     */
    public function parseDataStructureByIdentifier(string $identifier): array
    {
        // Throw an exception for an empty string. This might be a valid use case for new
        // records in some situations, so this is catchable to give callers a chance to deal with that.
        if (empty($identifier)) {
            throw new InvalidIdentifierException(
                'Empty string given to parseFlexFormDataStructureByIdentifier(). This exception might '
                . ' be caught to handle some new record situations properly',
                1478100828
            );
        }

        $parsedIdentifier = json_decode($identifier, true);

        if (!is_array($parsedIdentifier) || $parsedIdentifier === []) {
            // If there is some identifier and it can't be decoded, programming error -> not catchable
            throw new \RuntimeException(
                'Identifier could not be decoded to an array.',
                1478345642
            );
        }

        $dataStructure = $this->eventDispatcher
            ->dispatch(new BeforeFlexFormDataStructureParsedEvent($parsedIdentifier))
            ->getDataStructure() ?? $this->getDefaultStructureForIdentifier($parsedIdentifier);

        $dataStructure = $this->convertDataStructureToArray($dataStructure);
        $dataStructure = $this->ensureDefaultSheet($dataStructure);
        $dataStructure = $this->resolveFileDirectives($dataStructure);

        return $this->eventDispatcher
            ->dispatch(new AfterFlexFormDataStructureParsedEvent($dataStructure, $parsedIdentifier))
            ->getDataStructure();
    }

    protected function convertDataStructureToArray(string|array $dataStructure): array
    {
        if (is_array($dataStructure)) {
            return $dataStructure;
        }

        // Resolve FILE: prefix pointing to a DS in a file
        if (str_starts_with(trim($dataStructure), 'FILE:')) {
            $fileName = substr(trim($dataStructure), 5);
            $file = GeneralUtility::getFileAbsFileName($fileName);
            if (empty($file) || !is_file($file)) {
                throw new \RuntimeException(
                    'Data structure file "' . $fileName . '" could not be resolved to an existing file',
                    1478105826
                );
            }
            $dataStructure = (string)file_get_contents($file);
        }

        // Parse main structure
        $dataStructure = GeneralUtility::xml2array($dataStructure);

        // Throw if it still is not an array, probably because GeneralUtility::xml2array() failed.
        // This also may happen if artificial identifiers were constructed which don't resolve. The
        // flex form "exclude" access rights systems does that -> catchable
        if (!is_array($dataStructure)) {
            throw new InvalidIdentifierException(
                'Parse error: Data structure could not be resolved to a valid structure.',
                1478106090
            );
        }

        return $dataStructure;
    }

    protected function getDefaultStructureForIdentifier(array $identifier): string
    {
        if (($identifier['type'] ?? '') === 'record') {
            // Handle "record" type, see getDataStructureIdentifierFromRecord()
            if (empty($identifier['tableName']) || empty($identifier['uid']) || empty($identifier['fieldName'])) {
                throw new \RuntimeException(
                    'Incomplete "record" based identifier: ' . json_encode($identifier),
                    1478113873
                );
            }
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($identifier['tableName']);
            $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
            $dataStructure = $queryBuilder
                ->select($identifier['fieldName'])
                ->from($identifier['tableName'])
                ->where(
                    $queryBuilder->expr()->eq(
                        'uid',
                        $queryBuilder->createNamedParameter($identifier['uid'], Connection::PARAM_INT)
                    )
                )
                ->executeQuery()
                ->fetchOne();
        } elseif (($identifier['type'] ?? '') === 'tca') {
            // Handle "tca" type, see getDataStructureIdentifierFromTcaArray
            if (empty($identifier['tableName']) || empty($identifier['fieldName']) || empty($identifier['dataStructureKey'])) {
                throw new \RuntimeException(
                    'Incomplete "tca" based identifier: ' . json_encode($identifier),
                    1478113471
                );
            }
            $table = $identifier['tableName'];
            $field = $identifier['fieldName'];
            $dataStructureKey = $identifier['dataStructureKey'];
            if (!isset($GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey])
                || !is_string($GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey])
            ) {
                // This may happen for elements pointing to an unloaded extension -> catchable
                throw new InvalidIdentifierException(
                    'Specified identifier ' . json_encode($identifier) . ' does not resolve to a valid'
                    . ' TCA array value',
                    1478105491
                );
            }
            $dataStructure = $GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey];
        } else {
            throw new InvalidIdentifierException(
                'Identifier ' . json_encode($identifier) . ' could not be resolved',
                1478104554
            );
        }
        return $dataStructure;
    }

    /**
     * Ensures a data structure has a default sheet, and no duplicate data
     */
    protected function ensureDefaultSheet(array $dataStructure): array
    {
        if (isset($dataStructure['ROOT']) && isset($dataStructure['sheets'])) {
            throw new \RuntimeException(
                'Parsed data structure has both ROOT and sheets on top level. That is invalid.',
                1440676540
            );
        }
        if (isset($dataStructure['ROOT']) && is_array($dataStructure['ROOT'])) {
            $dataStructure['sheets']['sDEF']['ROOT'] = $dataStructure['ROOT'];
            unset($dataStructure['ROOT']);
        }
        return $dataStructure;
    }

    /**
     * Resolve FILE:EXT and EXT: for single sheets
     */
    protected function resolveFileDirectives(array $dataStructure): array
    {
        if (isset($dataStructure['sheets']) && is_array($dataStructure['sheets'])) {
            foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) {
                if (!is_array($sheetStructure)) {
                    if (str_starts_with(trim($sheetStructure), 'FILE:')) {
                        $file = GeneralUtility::getFileAbsFileName(substr(trim($sheetStructure), 5));
                    } else {
                        $file = GeneralUtility::getFileAbsFileName(trim($sheetStructure));
                    }
                    if ($file && @is_file($file)) {
                        $sheetStructure = GeneralUtility::xml2array((string)file_get_contents($file));
                    }
                }
                $dataStructure['sheets'][$sheetName] = $sheetStructure;
                $dataStructure = $this->removeElementTceFormsRecursive($dataStructure);

                if (is_array($dataStructure['sheets'][$sheetName])) {
                    // @todo Use TcaPreparation instead of duplicating the code.
                    // @todo Actually, the type category preparation is different for FlexForm as is doesn't support manyToMany.
                    // @todo The difficulty for type file is the difference of the field name. For FlexForm it is not the column name of TCA, but the sub key.
                    $dataStructure['sheets'][$sheetName] = $this->migrateFlexFormTcaRecursive($dataStructure['sheets'][$sheetName]);
                    $dataStructure['sheets'][$sheetName] = $this->prepareCategoryFields($dataStructure['sheets'][$sheetName]);
                    $dataStructure['sheets'][$sheetName] = $this->prepareFileFields($dataStructure['sheets'][$sheetName]);
                }
            }
        }
        return $dataStructure;
    }

    /**
     * Handler for Flex Forms
     *
     * @param string $table The table name of the record
     * @param string $field The field name of the flexform field to work on
     * @param array $row The record data array
     * @param object $callBackObj Object in which the call back function is located
     * @param string $callBackMethod_value Method name of call back function in object for values
     * @return bool|string true on success, string if error happened (error string returned)
     */
    public function traverseFlexFormXMLData($table, $field, $row, $callBackObj, $callBackMethod_value)
    {
        $PA = [];
        if (!is_array($GLOBALS['TCA'][$table]) || !is_array($GLOBALS['TCA'][$table]['columns'][$field])) {
            return 'TCA table/field was not defined.';
        }
        $this->callBackObj = $callBackObj;

        // Get data structure. The methods may throw various exceptions, with some of them being
        // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
        // and substitute with a dummy DS.
        $dataStructureArray = ['sheets' => ['sDEF' => []]];
        try {
            $dataStructureIdentifier = $this->getDataStructureIdentifier($GLOBALS['TCA'][$table]['columns'][$field], $table, $field, $row);
            $dataStructureArray = $this->parseDataStructureByIdentifier($dataStructureIdentifier);
        } catch (InvalidParentRowException|InvalidParentRowLoopException|InvalidParentRowRootException|InvalidPointerFieldValueException|InvalidIdentifierException $e) {
        }

        // Get flexform XML data
        $editData = GeneralUtility::xml2array($row[$field]);
        if (!is_array($editData)) {
            return 'Parsing error: ' . $editData;
        }
        // Check if $dataStructureArray['sheets'] is indeed an array before loop or it will crash with runtime error
        if (!is_array($dataStructureArray['sheets'])) {
            return 'Data Structure ERROR: sheets is defined but not an array for table ' . $table . (isset($row['uid']) ? ' and uid ' . $row['uid'] : '');
        }
        // Traverse languages:
        foreach ($dataStructureArray['sheets'] as $sheetKey => $sheetData) {
            // Render sheet:
            if (isset($sheetData['ROOT']['el']) && is_array($sheetData['ROOT']['el'])) {
                $PA['vKeys'] = ['DEF'];
                $PA['lKey'] = 'lDEF';
                $PA['callBackMethod_value'] = $callBackMethod_value;
                $PA['table'] = $table;
                $PA['field'] = $field;
                $PA['uid'] = $row['uid'];
                // Render flexform:
                $this->traverseFlexFormXMLData_recurse($sheetData['ROOT']['el'], $editData['data'][$sheetKey]['lDEF'] ?? [], $PA, 'data/' . $sheetKey . '/lDEF');
            } else {
                return 'Data Structure ERROR: No ROOT element found for sheet "' . $sheetKey . '".';
            }
        }
        return true;
    }

    /**
     * Recursively traversing flexform data according to data structure and element data
     *
     * @param array $dataStruct (Part of) data structure array that applies to the sub section of the flexform data we are processing
     * @param array $editData (Part of) edit data array, reflecting current part of data structure
     * @param array $PA Additional parameters passed.
     * @param string $path Telling the "path" to the element in the flexform XML
     */
    public function traverseFlexFormXMLData_recurse($dataStruct, $editData, &$PA, $path = ''): void
    {
        if (is_array($dataStruct)) {
            foreach ($dataStruct as $key => $value) {
                if (isset($value['type']) && $value['type'] === 'array') {
                    // Array (Section) traversal
                    if ($value['section'] ?? false) {
                        if (isset($editData[$key]['el']) && is_array($editData[$key]['el'])) {
                            if ($this->reNumberIndexesOfSectionData) {
                                $temp = [];
                                $c3 = 0;
                                foreach ($editData[$key]['el'] as $v3) {
                                    $temp[++$c3] = $v3;
                                }
                                $editData[$key]['el'] = $temp;
                            }
                            foreach ($editData[$key]['el'] as $k3 => $v3) {
                                if (is_array($v3)) {
                                    $cc = $k3;
                                    $theType = key($v3);
                                    $theDat = $v3[$theType];
                                    $newSectionEl = $value['el'][$theType];
                                    if (is_array($newSectionEl)) {
                                        $this->traverseFlexFormXMLData_recurse([$theType => $newSectionEl], [$theType => $theDat], $PA, $path . '/' . $key . '/el/' . $cc);
                                    }
                                }
                            }
                        }
                    } else {
                        // Array traversal
                        if (isset($editData[$key]['el'])) {
                            $this->traverseFlexFormXMLData_recurse($value['el'], $editData[$key]['el'], $PA, $path . '/' . $key . '/el');
                        }
                    }
                } elseif (isset($value['config']) && is_array($value['config'])) {
                    // Processing a field value:
                    foreach ($PA['vKeys'] as $vKey) {
                        $vKey = 'v' . $vKey;
                        // Call back
                        if (!empty($PA['callBackMethod_value']) && isset($editData[$key][$vKey])) {
                            $this->executeCallBackMethod($PA['callBackMethod_value'], [
                                $value,
                                $editData[$key][$vKey],
                                $PA,
                                $path . '/' . $key . '/' . $vKey,
                                $this,
                            ]);
                        }
                    }
                }
            }
        }
    }

    /**
     * Execute method on callback object
     *
     * @param string $methodName Method name to call
     * @param array $parameterArray Parameters
     * @return mixed Result of callback object
     */
    protected function executeCallBackMethod($methodName, array $parameterArray)
    {
        return $this->callBackObj->$methodName(...$parameterArray);
    }

    /***********************************
     *
     * Processing functions
     *
     ***********************************/
    /**
     * Cleaning up FlexForm XML to hold only the values it may according to its Data Structure. Also the order of tags will follow that of the data structure.
     * BE CAREFUL: DO not clean records in workspaces unless IN the workspace! The Data Structure might resolve falsely on a workspace record when cleaned from Live workspace.
     *
     * @param string $table Table name
     * @param string $field Field name of the flex form field in which the XML is found that should be cleaned.
     * @param array $row The record
     * @return string Clean XML from FlexForm field
     */
    public function cleanFlexFormXML($table, $field, $row)
    {
        // New structure:
        $this->cleanFlexFormXML = [];
        // Create and call iterator object:
        $flexObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools::class);
        $flexObj->reNumberIndexesOfSectionData = true;
        $flexObj->traverseFlexFormXMLData($table, $field, $row, $this, 'cleanFlexFormXML_callBackFunction');
        return $this->flexArray2Xml($this->cleanFlexFormXML, true);
    }

    /**
     * Call back function for \TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools class
     * Basically just setting the value in a new array (thus cleaning because only values that are valid are visited!)
     *
     * @param array $dsArr Data structure for the current value
     * @param mixed $data Current value
     * @param array $PA Additional configuration used in calling function
     * @param string $path Path of value in DS structure
     * @param FlexFormTools $pObj caller
     */
    public function cleanFlexFormXML_callBackFunction($dsArr, $data, $PA, $path, $pObj)
    {
        // Just setting value in our own result array, basically replicating the structure:
        $this->cleanFlexFormXML = ArrayUtility::setValueByPath($this->cleanFlexFormXML, $path, $data);
    }

    /**
     * Convert FlexForm data array to XML
     *
     * @param array $array Array to output in <T3FlexForms> XML
     * @param bool $addPrologue If set, the XML prologue is returned as well.
     * @return string XML content.
     */
    public function flexArray2Xml($array, $addPrologue = false)
    {
        if ($GLOBALS['TYPO3_CONF_VARS']['BE']['flexformForceCDATA']) {
            $this->flexArray2Xml_options['useCDATA'] = 1;
        }
        $output = GeneralUtility::array2xml($array, '', 0, 'T3FlexForms', 4, $this->flexArray2Xml_options);
        if ($addPrologue) {
            $output = '<?xml version="1.0" encoding="utf-8" standalone="yes" ?>' . LF . $output;
        }
        return $output;
    }

    /**
     * Prepare type=category fields if given.
     *
     * NOTE: manyToMany relationships are not supported!
     *
     * @param array $dataStructureSheets
     * @return array The processed $dataStructureSheets
     */
    protected function prepareCategoryFields(array $dataStructureSheets): array
    {
        if ($dataStructureSheets === []) {
            // Early return in case the no sheets are given
            return $dataStructureSheets;
        }

        foreach ($dataStructureSheets as &$structure) {
            if (!is_array($structure['el'] ?? false) || $structure['el'] === []) {
                // Skip if no elements (fields) are defined
                continue;
            }
            foreach ($structure['el'] as $fieldName => &$fieldConfig) {
                if (($fieldConfig['config']['type'] ?? '') !== 'category') {
                    // Skip if type is not "category"
                    continue;
                }

                // Add a default label if none is defined
                if (!isset($fieldConfig['label'])) {
                    $fieldConfig['label'] = 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.categories';
                }

                // Initialize default column configuration and merge it with already defined
                $fieldConfig['config']['size'] ??= 20;

                // Force foreign_table_* fields for type category
                $fieldConfig['config']['foreign_table'] = 'sys_category';
                $fieldConfig['config']['foreign_table_where'] = ' AND {#sys_category}.{#sys_language_uid} IN (-1, 0)';

                if (empty($fieldConfig['config']['relationship'])) {
                    // Fall back to "oneToMany" when no relationship is given
                    $fieldConfig['config']['relationship'] = 'oneToMany';
                }

                if (!in_array($fieldConfig['config']['relationship'], ['oneToOne', 'oneToMany'], true)) {
                    throw new \UnexpectedValueException(
                        '"relationship" must be one of "oneToOne" or "oneToMany", "manyToMany" is not supported as "relationship"' .
                        ' for field ' . $fieldName . ' of type "category" in flexform.',
                        1627640208
                    );
                }

                // Set the maxitems value (necessary for DataHandling and FormEngine)
                if ($fieldConfig['config']['relationship'] === 'oneToOne') {
                    // In case relationship is set to "oneToOne", maxitems must be 1.
                    if ((int)($fieldConfig['config']['maxitems'] ?? 0) > 1) {
                        throw new \UnexpectedValueException(
                            $fieldName . ' is defined as type category with an "oneToOne" relationship. ' .
                            'Therefore maxitems must be 1. Otherwise, use oneToMany as relationship instead.',
                            1627640209
                        );
                    }
                    $fieldConfig['config']['maxitems'] = 1;
                } elseif ($fieldConfig['config']['relationship'] === 'oneToMany') {
                    // In case maxitems is not set or set to 0, set the default value "99999"
                    if (!($fieldConfig['config']['maxitems'] ?? false)) {
                        $fieldConfig['config']['maxitems'] = 99999;
                    } elseif ((int)($fieldConfig['config']['maxitems'] ?? 0) === 1) {
                        throw new \UnexpectedValueException(
                            'Can not use maxitems=1 for field ' . $fieldName . ' with "relationship" set to "oneToMany". Use "oneToOne" instead.',
                            1627640210
                        );
                    }
                }

                // Add the default value if not set
                if (!isset($fieldConfig['config']['default'])
                    && $fieldConfig['config']['relationship'] !== 'oneToMany'
                ) {
                    $fieldConfig['config']['default'] = 0;
                }
            }
        }

        return $dataStructureSheets;
    }

    /**
     * Prepare type=file fields if given.
     *
     * @return array The processed $dataStructureSheets
     */
    protected function prepareFileFields(array $dataStructureSheets): array
    {
        if ($dataStructureSheets === []) {
            // Early return in case the no sheets are given
            return $dataStructureSheets;
        }

        foreach ($dataStructureSheets as &$structure) {
            if (!is_array($structure['el'] ?? false) || $structure['el'] === []) {
                // Skip if no elements (fields) are defined
                continue;
            }
            foreach ($structure['el'] as $fieldName => &$fieldConfig) {
                if (($fieldConfig['config']['type'] ?? '') !== 'file') {
                    // Skip if type is not "file"
                    continue;
                }

                $fieldConfig['config'] = array_replace_recursive(
                    $fieldConfig['config'],
                    [
                        'foreign_table' => 'sys_file_reference',
                        'foreign_field' => 'uid_foreign',
                        'foreign_sortby' => 'sorting_foreign',
                        'foreign_table_field' => 'tablenames',
                        'foreign_match_fields' => [
                            'fieldname' => $fieldName,
                        ],
                        'foreign_label' => 'uid_local',
                        'foreign_selector' => 'uid_local',
                    ]
                );

                if (!empty(($allowed = ($fieldConfig['config']['allowed'] ?? null)))) {
                    $fieldConfig['config']['allowed'] = TcaPreparation::prepareFileExtensions($allowed);
                }
                if (!empty(($disallowed = ($fieldConfig['config']['disallowed'] ?? null)))) {
                    $fieldConfig['config']['disallowed'] = TcaPreparation::prepareFileExtensions($disallowed);
                }
            }
        }

        return $dataStructureSheets;
    }

    /**
     * Remove "TCEforms" key from all elements in data structure to simplify further parsing.
     *
     * Example config:
     * ['config']['ds']['sheets']['sDEF']['ROOT']['el']['anElement']['TCEforms']['label'] becomes
     * ['config']['ds']['sheets']['sDEF']['ROOT']['el']['anElement']['label']
     *
     * and
     *
     * ['ROOT']['TCEforms']['sheetTitle'] becomes
     * ['ROOT']['sheetTitle']
     *
     * @internal This method serves as a compatibility layer and will be removed in TYPO3 v13.
     */
    public function removeElementTceFormsRecursive(array $structure): array
    {
        $newStructure = [];
        foreach ($structure as $key => $value) {
            if ($key === 'ROOT' && is_array($value) && isset($value['TCEforms'])) {
                trigger_error(
                    'The tag "<TCEforms>" should not be set under the FlexForm definition "<ROOT>" anymore. It should be omitted while the underlying configuration ascends one level up. This compatibility layer will be removed in TYPO3 v13.',
                    E_USER_DEPRECATED
                );
                $value = array_merge($value, $value['TCEforms']);
                unset($value['TCEforms']);
            }
            if ($key === 'el' && is_array($value)) {
                $newSubStructure = [];
                foreach ($value as $subKey => $subValue) {
                    if (is_array($subValue) && count($subValue) === 1 && isset($subValue['TCEforms'])) {
                        trigger_error(
                            'The tag "<TCEforms>" was found in a FlexForm definition for the field "<' . $subKey . '>". It should be omitted while the underlying configuration ascends one level up. This compatibility layer will be removed in TYPO3 v13.',
                            E_USER_DEPRECATED
                        );
                        $newSubStructure[$subKey] = $subValue['TCEforms'];
                    } else {
                        $newSubStructure[$subKey] = $subValue;
                    }
                }
                $value = $newSubStructure;
            }
            if (is_array($value)) {
                $value = $this->removeElementTceFormsRecursive($value);
            }
            $newStructure[$key] = $value;
        }
        return $newStructure;
    }

    /**
     * Recursively migrate flex form TCA
     */
    public function migrateFlexFormTcaRecursive(array $structure): array
    {
        $newStructure = [];
        foreach ($structure as $key => $value) {
            if ($key === 'el' && is_array($value)) {
                $newSubStructure = [];
                $tcaMigration = GeneralUtility::makeInstance(TcaMigration::class);
                foreach ($value as $subKey => $subValue) {
                    // On-the-fly migration for flex form "TCA". Call the TcaMigration and log any deprecations.
                    $dummyTca = [
                        'dummyTable' => [
                            'columns' => [
                                'dummyField' => $subValue,
                            ],
                        ],
                    ];
                    $migratedTca = $tcaMigration->migrate($dummyTca);
                    $messages = $tcaMigration->getMessages();
                    if (!empty($messages)) {
                        $context = 'FlexFormTools did an on-the-fly migration of a flex form data structure. This is deprecated and will be removed.'
                            . ' Merge the following changes into the flex form definition "' . $subKey . '":';
                        array_unshift($messages, $context);
                        trigger_error(implode(LF, $messages), E_USER_DEPRECATED);
                    }
                    $newSubStructure[$subKey] = $migratedTca['dummyTable']['columns']['dummyField'];
                }
                $value = $newSubStructure;
            }
            if (is_array($value)) {
                $value = $this->migrateFlexFormTcaRecursive($value);
            }
            $newStructure[$key] = $value;
        }
        return $newStructure;
    }
}