1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Core\DataHandling\Localization;
17
18use TYPO3\CMS\Backend\Utility\BackendUtility;
19use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
20use TYPO3\CMS\Core\Database\Connection;
21use TYPO3\CMS\Core\Database\ConnectionPool;
22use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
23use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
24use TYPO3\CMS\Core\Database\RelationHandler;
25use TYPO3\CMS\Core\DataHandling\DataHandler;
26use TYPO3\CMS\Core\DataHandling\ReferenceIndexUpdater;
27use TYPO3\CMS\Core\Localization\LanguageService;
28use TYPO3\CMS\Core\Utility\GeneralUtility;
29use TYPO3\CMS\Core\Utility\MathUtility;
30use TYPO3\CMS\Core\Utility\StringUtility;
31
32/**
33 * This processor analyzes the provided data-map before actually being process
34 * in the calling DataHandler instance. Field names that are configured to have
35 * "allowLanguageSynchronization" enabled are either synchronized from there
36 * relative parent records (could be a default language record, or a l10n_source
37 * record) or to their dependent records (in case a default language record or
38 * nested records pointing upwards with l10n_source).
39 *
40 * Except inline relational record editing, all modifications are applied to
41 * the data-map directly, which ensures proper history entries as a side-effect.
42 * For inline relational record editing, this processor either triggers the copy
43 * or localize actions by instantiation a new local DataHandler instance.
44 *
45 * Namings in this class:
46 * + forTableName, forId always refers to dependencies data is provided *for*
47 * + fromTableName, fromId always refers to ancestors data is retrieved *from*
48 *
49 * @internal should only be used by the TYPO3 Core
50 */
51class DataMapProcessor
52{
53    /**
54     * @var array
55     */
56    protected $allDataMap = [];
57
58    /**
59     * @var array
60     */
61    protected $modifiedDataMap = [];
62
63    /**
64     * @var array
65     */
66    protected $sanitizationMap = [];
67
68    /**
69     * @var BackendUserAuthentication
70     */
71    protected $backendUser;
72
73    /**
74     * @var ReferenceIndexUpdater
75     */
76    protected $referenceIndexUpdater;
77
78    /**
79     * @var DataMapItem[]
80     */
81    protected $allItems = [];
82
83    /**
84     * @var DataMapItem[]
85     */
86    protected $nextItems = [];
87
88    /**
89     * Class generator
90     *
91     * @param array $dataMap The submitted data-map to be worked on
92     * @param BackendUserAuthentication $backendUser Forwarded backend-user scope
93     * @param ReferenceIndexUpdater|null $referenceIndexUpdater Forward reference index updater to sub DataHandler instances
94     * @return DataMapProcessor
95     */
96    public static function instance(
97        array $dataMap,
98        BackendUserAuthentication $backendUser,
99        ReferenceIndexUpdater $referenceIndexUpdater = null
100    ) {
101        return GeneralUtility::makeInstance(
102            static::class,
103            $dataMap,
104            $backendUser,
105            $referenceIndexUpdater
106        );
107    }
108
109    /**
110     * @param array $dataMap The submitted data-map to be worked on
111     * @param BackendUserAuthentication $backendUser Forwarded backend-user scope
112     * @param ReferenceIndexUpdater|null $referenceIndexUpdater Forward reference index updater to sub DataHandler instances
113     */
114    public function __construct(
115        array $dataMap,
116        BackendUserAuthentication $backendUser,
117        ReferenceIndexUpdater $referenceIndexUpdater = null
118    ) {
119        $this->allDataMap = $dataMap;
120        $this->modifiedDataMap = $dataMap;
121        $this->backendUser = $backendUser;
122        if ($referenceIndexUpdater === null) {
123            $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
124        }
125        $this->referenceIndexUpdater = $referenceIndexUpdater;
126    }
127
128    /**
129     * Processes the submitted data-map and returns the sanitized and enriched
130     * version depending on accordant localization states and dependencies.
131     *
132     * @return array
133     */
134    public function process()
135    {
136        $iterations = 0;
137
138        while (!empty($this->modifiedDataMap)) {
139            $this->nextItems = [];
140            foreach ($this->modifiedDataMap as $tableName => $idValues) {
141                $this->collectItems($tableName, $idValues);
142            }
143
144            $this->modifiedDataMap = [];
145            if (empty($this->nextItems)) {
146                break;
147            }
148
149            if ($iterations++ === 0) {
150                $this->sanitize($this->allItems);
151            }
152            $this->enrich($this->nextItems);
153        }
154
155        $this->allDataMap = $this->purgeDataMap($this->allDataMap);
156        return $this->allDataMap;
157    }
158
159    /**
160     * Purges superfluous empty data-map sections.
161     *
162     * @param array $dataMap
163     * @return array
164     */
165    protected function purgeDataMap(array $dataMap): array
166    {
167        foreach ($dataMap as $tableName => $idValues) {
168            foreach ($idValues as $id => $values) {
169                if (empty($values)) {
170                    unset($dataMap[$tableName][$id]);
171                }
172            }
173            if (empty($dataMap[$tableName])) {
174                unset($dataMap[$tableName]);
175            }
176        }
177        return $dataMap;
178    }
179
180    /**
181     * Create data map items of all affected rows
182     *
183     * @param string $tableName
184     * @param array $idValues
185     */
186    protected function collectItems(string $tableName, array $idValues)
187    {
188        if (!$this->isApplicable($tableName)) {
189            return;
190        }
191
192        $fieldNames = [
193            'uid' => 'uid',
194            'l10n_state' => 'l10n_state',
195            'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
196            'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
197        ];
198        if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
199            $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
200        }
201
202        $translationValues = $this->fetchTranslationValues(
203            $tableName,
204            $fieldNames,
205            $this->filterNewItemIds(
206                $tableName,
207                $this->filterNumericIds(array_keys($idValues))
208            )
209        );
210
211        $dependencies = $this->fetchDependencies(
212            $tableName,
213            $this->filterNewItemIds($tableName, array_keys($idValues))
214        );
215
216        foreach ($idValues as $id => $values) {
217            $item = $this->findItem($tableName, $id);
218            // build item if it has not been created in a previous iteration
219            if ($item === null) {
220                $recordValues = $translationValues[$id] ?? [];
221                $item = DataMapItem::build(
222                    $tableName,
223                    $id,
224                    $values,
225                    $recordValues,
226                    $fieldNames
227                );
228
229                // elements using "all language" cannot be localized
230                if ($item->getLanguage() === -1) {
231                    unset($item);
232                    continue;
233                }
234                // must be any kind of localization and in connected mode
235                if ($item->getLanguage() > 0 && empty($item->getParent())) {
236                    unset($item);
237                    continue;
238                }
239                // add dependencies
240                if (!empty($dependencies[$id])) {
241                    $item->setDependencies($dependencies[$id]);
242                }
243            }
244            // add item to $this->allItems and $this->nextItems
245            $this->addNextItem($item);
246        }
247    }
248
249    /**
250     * Sanitizes the submitted data-map items and removes fields which are not
251     * defined as custom and thus rely on either parent or source values.
252     *
253     * @param DataMapItem[] $items
254     */
255    protected function sanitize(array $items)
256    {
257        foreach (['directChild', 'grandChild'] as $type) {
258            foreach ($this->filterItemsByType($type, $items) as $item) {
259                $this->sanitizeTranslationItem($item);
260            }
261        }
262    }
263
264    /**
265     * Handle synchronization of an item list
266     *
267     * @param DataMapItem[] $items
268     */
269    protected function enrich(array $items)
270    {
271        foreach (['directChild', 'grandChild'] as $type) {
272            foreach ($this->filterItemsByType($type, $items) as $item) {
273                foreach ($item->getApplicableScopes() as $scope) {
274                    $fromId = $item->getIdForScope($scope);
275                    $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew());
276                    $this->synchronizeTranslationItem($item, $fieldNames, $fromId);
277                }
278                $this->populateTranslationItem($item);
279                $this->finishTranslationItem($item);
280            }
281        }
282        foreach ($this->filterItemsByType('parent', $items) as $item) {
283            $this->populateTranslationItem($item);
284        }
285    }
286
287    /**
288     * Sanitizes the submitted data-map for a particular item and removes
289     * fields which are not defined as custom and thus rely on either parent
290     * or source values.
291     *
292     * @param DataMapItem $item
293     */
294    protected function sanitizeTranslationItem(DataMapItem $item)
295    {
296        $fieldNames = [];
297        foreach ($item->getApplicableScopes() as $scope) {
298            $fieldNames = array_merge(
299                $fieldNames,
300                $this->getFieldNamesForItemScope($item, $scope, false)
301            );
302        }
303
304        $fieldNameMap = array_combine($fieldNames, $fieldNames);
305        // separate fields, that are submitted in data-map, but not defined as custom
306        $this->sanitizationMap[$item->getTableName()][$item->getId()] = array_intersect_key(
307            $this->allDataMap[$item->getTableName()][$item->getId()],
308            $fieldNameMap
309        );
310        // remove fields, that are submitted in data-map, but not defined as custom
311        $this->allDataMap[$item->getTableName()][$item->getId()] = array_diff_key(
312            $this->allDataMap[$item->getTableName()][$item->getId()],
313            $fieldNameMap
314        );
315    }
316
317    /**
318     * Synchronize a single item
319     *
320     * @param DataMapItem $item
321     * @param array $fieldNames
322     * @param string|int $fromId
323     */
324    protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, $fromId)
325    {
326        if (empty($fieldNames)) {
327            return;
328        }
329
330        $fieldNameList = 'uid,' . implode(',', $fieldNames);
331
332        $fromRecord = ['uid' => $fromId];
333        if (MathUtility::canBeInterpretedAsInteger($fromId)) {
334            $fromRecord = BackendUtility::getRecordWSOL(
335                $item->getTableName(),
336                $fromId,
337                $fieldNameList
338            );
339        }
340
341        $forRecord = [];
342        if (!$item->isNew()) {
343            $forRecord = BackendUtility::getRecordWSOL(
344                $item->getTableName(),
345                $item->getId(),
346                $fieldNameList
347            );
348        }
349
350        if (is_array($fromRecord) && is_array($forRecord)) {
351            foreach ($fieldNames as $fieldName) {
352                $this->synchronizeFieldValues(
353                    $item,
354                    $fieldName,
355                    $fromRecord,
356                    $forRecord
357                );
358            }
359        }
360    }
361
362    /**
363     * Populates values downwards, either from a parent language item or
364     * a source language item to an accordant dependent translation item.
365     *
366     * @param DataMapItem $item
367     */
368    protected function populateTranslationItem(DataMapItem $item)
369    {
370        foreach ([DataMapItem::SCOPE_PARENT, DataMapItem::SCOPE_SOURCE] as $scope) {
371            foreach ($item->findDependencies($scope) as $dependentItem) {
372                // use suggested item, if it was submitted in data-map
373                $suggestedDependentItem = $this->findItem(
374                    $dependentItem->getTableName(),
375                    $dependentItem->getId()
376                );
377                if ($suggestedDependentItem !== null) {
378                    $dependentItem = $suggestedDependentItem;
379                }
380                foreach ([$scope, DataMapItem::SCOPE_EXCLUDE] as $dependentScope) {
381                    $fieldNames = $this->getFieldNamesForItemScope(
382                        $dependentItem,
383                        $dependentScope,
384                        false
385                    );
386                    $this->synchronizeTranslationItem(
387                        $dependentItem,
388                        $fieldNames,
389                        $item->getId()
390                    );
391                }
392            }
393        }
394    }
395
396    /**
397     * Finishes a translation item by updating states to be persisted.
398     *
399     * @param DataMapItem $item
400     */
401    protected function finishTranslationItem(DataMapItem $item)
402    {
403        if (
404            $item->isParentType()
405            || !State::isApplicable($item->getTableName())
406        ) {
407            return;
408        }
409
410        $this->allDataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export();
411    }
412
413    /**
414     * Synchronize simple values like text and similar
415     *
416     * @param DataMapItem $item
417     * @param string $fieldName
418     * @param array $fromRecord
419     * @param array $forRecord
420     */
421    protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
422    {
423        // skip if this field has been processed already, assumed that proper sanitation happened
424        if ($this->isSetInDataMap($item->getTableName(), $item->getId(), $fieldName)) {
425            return;
426        }
427
428        $fromId = $fromRecord['uid'];
429        // retrieve value from in-memory data-map
430        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
431            $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName];
432        } elseif (array_key_exists($fieldName, $fromRecord)) {
433            // retrieve value from record
434            $fromValue = $fromRecord[$fieldName];
435        } else {
436            // otherwise abort synchronization
437            return;
438        }
439
440        // plain values
441        if (!$this->isRelationField($item->getTableName(), $fieldName)) {
442            $this->modifyDataMap(
443                $item->getTableName(),
444                $item->getId(),
445                [$fieldName => $fromValue]
446            );
447        } elseif (!$this->isInlineRelationField($item->getTableName(), $fieldName)) {
448            // direct relational values
449            $this->synchronizeDirectRelations($item, $fieldName, $fromRecord);
450        } else {
451            // inline relational values
452            $this->synchronizeInlineRelations($item, $fieldName, $fromRecord, $forRecord);
453        }
454    }
455
456    /**
457     * Synchronize select and group field localizations
458     *
459     * @param DataMapItem $item
460     * @param string $fieldName
461     * @param array $fromRecord
462     */
463    protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord)
464    {
465        $specialTableName = null;
466        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
467        $isSpecialLanguageField = ($configuration['config']['special'] ?? null) === 'languages';
468
469        $fromId = $fromRecord['uid'];
470        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
471            $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName];
472        } else {
473            $fromValue = $fromRecord[$fieldName];
474        }
475
476        // non-MM relations are stored as comma separated values, just use them
477        // if values are available in data-map already, just use them as well
478        if (
479            empty($configuration['config']['MM'])
480            || $this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)
481            || $isSpecialLanguageField
482        ) {
483            $this->modifyDataMap(
484                $item->getTableName(),
485                $item->getId(),
486                [$fieldName => $fromValue]
487            );
488            return;
489        }
490        // resolve the language special table name
491        if ($isSpecialLanguageField) {
492            $specialTableName = 'sys_language';
493        }
494        // fetch MM relations from storage
495        $type = $configuration['config']['type'];
496        $manyToManyTable = $configuration['config']['MM'];
497        if ($type === 'group' && $configuration['config']['internal_type'] === 'db') {
498            $tableNames = trim($configuration['config']['allowed'] ?? '');
499        } elseif ($configuration['config']['type'] === 'select') {
500            $tableNames = ($specialTableName ?? $configuration['config']['foreign_table'] ?? '');
501        } else {
502            return;
503        }
504
505        $relationHandler = $this->createRelationHandler();
506        $relationHandler->start(
507            '',
508            $tableNames,
509            $manyToManyTable,
510            $fromId,
511            $item->getTableName(),
512            $configuration['config']
513        );
514
515        // provide list of relations, optionally prepended with table name
516        // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28"
517        $this->modifyDataMap(
518            $item->getTableName(),
519            $item->getId(),
520            [$fieldName => implode(',', $relationHandler->getValueArray())]
521        );
522    }
523
524    /**
525     * Handle synchronization of inline relations.
526     * Inline Relational Record Editing ("IRRE") always is modelled as 1:n composite relation - which means that
527     * direct(!) children cannot exist without their parent. Removing a relative parent results in cascaded removal
528     * of all direct(!) children as well.
529     *
530     * @param DataMapItem $item
531     * @param string $fieldName
532     * @param array $fromRecord
533     * @param array $forRecord
534     * @throws \RuntimeException
535     */
536    protected function synchronizeInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
537    {
538        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
539        $isLocalizationModeExclude = ($configuration['l10n_mode'] ?? null) === 'exclude';
540        $foreignTableName = $configuration['config']['foreign_table'];
541
542        $fieldNames = [
543            'language' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'] ?? null,
544            'parent' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['transOrigPointerField'] ?? null,
545            'source' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['translationSource'] ?? null,
546        ];
547        $isTranslatable = (!empty($fieldNames['language']) && !empty($fieldNames['parent']));
548        $isLocalized = !empty($item->getLanguage());
549
550        $suggestedAncestorIds = $this->resolveSuggestedInlineRelations(
551            $item,
552            $fieldName,
553            $fromRecord
554        );
555        $persistedIds = $this->resolvePersistedInlineRelations(
556            $item,
557            $fieldName,
558            $forRecord
559        );
560
561        // The dependent ID map points from language parent/source record to
562        // localization, thus keys: parents/sources & values: localizations
563        $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds, $item->getLanguage());
564        // filter incomplete structures - this is a drawback of DataHandler's remap stack, since
565        // just created IRRE translations still belong to the language parent - filter them out
566        $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap));
567        // compile element differences to be resolved
568        // remove elements that are persisted at the language translation, but not required anymore
569        $removeIds = array_diff($persistedIds, array_values($dependentIdMap));
570        // remove elements that are persisted at the language parent/source, but not required anymore
571        $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds);
572        // missing elements that are persisted at the language parent/source, but not translated yet
573        $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap));
574        // persisted elements that should be copied or localized
575        $createAncestorIds = $this->filterNumericIds($missingAncestorIds);
576        // non-persisted elements that should be duplicated in data-map directly
577        $populateAncestorIds = array_diff($missingAncestorIds, $createAncestorIds);
578        // this desired state map defines the final result of child elements in their parent translation
579        $desiredIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds);
580        // update existing translations in the desired state map
581        foreach ($dependentIdMap as $ancestorId => $translationId) {
582            if (isset($desiredIdMap[$ancestorId])) {
583                $desiredIdMap[$ancestorId] = $translationId;
584            }
585        }
586        // no children to be synchronized, but element order could have been changed
587        if (empty($removeAncestorIds) && empty($missingAncestorIds)) {
588            $this->modifyDataMap(
589                $item->getTableName(),
590                $item->getId(),
591                [$fieldName => implode(',', array_values($desiredIdMap))]
592            );
593            return;
594        }
595        // In case only missing elements shall be created, re-use previously sanitized
596        // values IF the relation parent item is new and the count of missing relations
597        // equals the count of previously sanitized relations.
598        // This is caused during copy processes, when the child relations
599        // already have been cloned in DataHandler::copyRecord_procBasedOnFieldType()
600        // without the possibility to resolve the initial connections at this point.
601        // Otherwise child relations would superfluously be duplicated again here.
602        // @todo Invalid manually injected child relations cannot be determined here
603        $sanitizedValue = $this->sanitizationMap[$item->getTableName()][$item->getId()][$fieldName] ?? null;
604        if (
605            !empty($missingAncestorIds) && $item->isNew() && $sanitizedValue !== null
606            && count(GeneralUtility::trimExplode(',', $sanitizedValue, true)) === count($missingAncestorIds)
607        ) {
608            $this->modifyDataMap(
609                $item->getTableName(),
610                $item->getId(),
611                [$fieldName => $sanitizedValue]
612            );
613            return;
614        }
615
616        $localCommandMap = [];
617        foreach ($removeIds as $removeId) {
618            $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
619        }
620        foreach ($removeAncestorIds as $removeAncestorId) {
621            $removeId = $dependentIdMap[$removeAncestorId];
622            $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
623        }
624        foreach ($createAncestorIds as $createAncestorId) {
625            // if child table is not aware of localization, just copy
626            if ($isLocalizationModeExclude || !$isTranslatable) {
627                $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = [
628                    'target' => -$createAncestorId,
629                    'ignoreLocalization' => true,
630                ];
631            } else {
632                // otherwise, trigger the localization process
633                $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage();
634            }
635        }
636        // execute copy, localize and delete actions on persisted child records
637        if (!empty($localCommandMap)) {
638            $localDataHandler = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
639            $localDataHandler->start([], $localCommandMap, $this->backendUser);
640            $localDataHandler->process_cmdmap();
641            // update copied or localized ids
642            foreach ($createAncestorIds as $createAncestorId) {
643                if (empty($localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId])) {
644                    $additionalInformation = '';
645                    if (!empty($localDataHandler->errorLog)) {
646                        $additionalInformation = ', reason "'
647                        . implode(', ', $localDataHandler->errorLog) . '"';
648                    }
649                    throw new \RuntimeException(
650                        'Child record was not processed' . $additionalInformation,
651                        1486233164
652                    );
653                }
654                $newLocalizationId = $localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId];
655                $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId;
656                $desiredIdMap[$createAncestorId] = $newLocalizationId;
657                // apply localization references to l10n_mode=exclude children
658                // (without keeping their reference to their origin, synchronization is not possible)
659                if ($isLocalizationModeExclude && $isTranslatable && $isLocalized) {
660                    $adjustCopiedValues = $this->applyLocalizationReferences(
661                        $foreignTableName,
662                        $createAncestorId,
663                        $item->getLanguage(),
664                        $fieldNames,
665                        []
666                    );
667                    $this->modifyDataMap(
668                        $foreignTableName,
669                        $newLocalizationId,
670                        $adjustCopiedValues
671                    );
672                }
673            }
674        }
675        // populate new child records in data-map
676        if (!empty($populateAncestorIds)) {
677            foreach ($populateAncestorIds as $populateAncestorId) {
678                $newLocalizationId = StringUtility::getUniqueId('NEW');
679                $desiredIdMap[$populateAncestorId] = $newLocalizationId;
680                $duplicatedValues = $this->allDataMap[$foreignTableName][$populateAncestorId] ?? [];
681                // applies localization references to given raw data-map item
682                if ($isTranslatable && $isLocalized) {
683                    $duplicatedValues = $this->applyLocalizationReferences(
684                        $foreignTableName,
685                        $populateAncestorId,
686                        $item->getLanguage(),
687                        $fieldNames,
688                        $duplicatedValues
689                    );
690                }
691                // prefixes language title if applicable for the accordant field name in raw data-map item
692                if ($isTranslatable && $isLocalized && !$isLocalizationModeExclude) {
693                    $duplicatedValues = $this->prefixLanguageTitle(
694                        $foreignTableName,
695                        $populateAncestorId,
696                        $item->getLanguage(),
697                        $duplicatedValues
698                    );
699                }
700                $this->modifyDataMap(
701                    $foreignTableName,
702                    $newLocalizationId,
703                    $duplicatedValues
704                );
705            }
706        }
707        // update inline parent field references - required to update pointer fields
708        $this->modifyDataMap(
709            $item->getTableName(),
710            $item->getId(),
711            [$fieldName => implode(',', array_values($desiredIdMap))]
712        );
713    }
714
715    /**
716     * Determines suggest inline relations of either translation parent or
717     * source record from data-map or storage in case records have been
718     * persisted already.
719     *
720     * @param DataMapItem $item
721     * @param string $fieldName
722     * @param array $fromRecord
723     * @return int[]|string[]
724     */
725    protected function resolveSuggestedInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord): array
726    {
727        $suggestedAncestorIds = [];
728        $fromId = $fromRecord['uid'];
729        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
730        $foreignTableName = $configuration['config']['foreign_table'];
731        $manyToManyTable = ($configuration['config']['MM'] ?? '');
732
733        // determine suggested elements of either translation parent or source record
734        // from data-map, in case the accordant language parent/source record was modified
735        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
736            $suggestedAncestorIds = GeneralUtility::trimExplode(
737                ',',
738                $this->allDataMap[$item->getTableName()][$fromId][$fieldName],
739                true
740            );
741        } elseif (MathUtility::canBeInterpretedAsInteger($fromId)) {
742            // determine suggested elements of either translation parent or source record from storage
743            $relationHandler = $this->createRelationHandler();
744            $relationHandler->start(
745                $fromRecord[$fieldName],
746                $foreignTableName,
747                $manyToManyTable,
748                $fromId,
749                $item->getTableName(),
750                $configuration['config']
751            );
752            $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray);
753        }
754
755        return array_filter($suggestedAncestorIds);
756    }
757
758    /**
759     * Determine persisted inline relations for current data-map-item.
760     *
761     * @param DataMapItem $item
762     * @param string $fieldName
763     * @param array $forRecord
764     * @return int[]
765     */
766    private function resolvePersistedInlineRelations(DataMapItem $item, string $fieldName, array $forRecord): array
767    {
768        $persistedIds = [];
769        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
770        $foreignTableName = $configuration['config']['foreign_table'];
771        $manyToManyTable = ($configuration['config']['MM'] ?? '');
772
773        // determine persisted elements for the current data-map item
774        if (!$item->isNew()) {
775            $relationHandler = $this->createRelationHandler();
776            $relationHandler->start(
777                $forRecord[$fieldName] ?? '',
778                $foreignTableName,
779                $manyToManyTable,
780                $item->getId(),
781                $item->getTableName(),
782                $configuration['config']
783            );
784            $persistedIds = $this->mapRelationItemId($relationHandler->itemArray);
785        }
786
787        return array_filter($persistedIds);
788    }
789
790    /**
791     * Determines whether a combination of table name, id and field name is
792     * set in data-map. This method considers null values as well, that would
793     * not be considered by a plain isset() invocation.
794     *
795     * @param string $tableName
796     * @param string|int $id
797     * @param string $fieldName
798     * @return bool
799     */
800    protected function isSetInDataMap(string $tableName, $id, string $fieldName)
801    {
802        return
803            // directly look-up field name
804            isset($this->allDataMap[$tableName][$id][$fieldName])
805            // check existence of field name as key for null values
806            || isset($this->allDataMap[$tableName][$id])
807            && is_array($this->allDataMap[$tableName][$id])
808            && array_key_exists($fieldName, $this->allDataMap[$tableName][$id]);
809    }
810
811    /**
812     * Applies modifications to the data-map, calling this method is essential
813     * to determine new data-map items to be process for synchronizing chained
814     * record localizations.
815     *
816     * @param string $tableName
817     * @param string|int $id
818     * @param array $values
819     * @throws \RuntimeException
820     */
821    protected function modifyDataMap(string $tableName, $id, array $values)
822    {
823        // avoid superfluous iterations by data-map changes with values
824        // that actually have not been changed and were available already
825        $sameValues = array_intersect_assoc(
826            $this->allDataMap[$tableName][$id] ?? [],
827            $values
828        );
829        if (!empty($sameValues)) {
830            $fieldNames = implode(', ', array_keys($sameValues));
831            throw new \RuntimeException(
832                sprintf(
833                    'Issued data-map change for table %s with same values '
834                    . 'for these fields names %s',
835                    $tableName,
836                    $fieldNames
837                ),
838                1488634845
839            );
840        }
841
842        $this->modifiedDataMap[$tableName][$id] = array_merge(
843            $this->modifiedDataMap[$tableName][$id] ?? [],
844            $values
845        );
846        $this->allDataMap[$tableName][$id] = array_merge(
847            $this->allDataMap[$tableName][$id] ?? [],
848            $values
849        );
850    }
851
852    /**
853     * @param DataMapItem $item
854     */
855    protected function addNextItem(DataMapItem $item)
856    {
857        $identifier = $item->getTableName() . ':' . $item->getId();
858        if (!isset($this->allItems[$identifier])) {
859            $this->allItems[$identifier] = $item;
860        }
861        $this->nextItems[$identifier] = $item;
862    }
863
864    /**
865     * Fetches translation related field values for the items submitted in
866     * the data-map.
867     *
868     * @param string $tableName
869     * @param array $fieldNames
870     * @param array $ids
871     * @return array
872     */
873    protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids)
874    {
875        if (empty($ids)) {
876            return [];
877        }
878
879        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
880            ->getQueryBuilderForTable($tableName);
881        $queryBuilder->getRestrictions()
882            ->removeAll()
883            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
884            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->backendUser->workspace));
885        $statement = $queryBuilder
886            ->select(...array_values($fieldNames))
887            ->from($tableName)
888            ->where(
889                $queryBuilder->expr()->in(
890                    'uid',
891                    $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
892                )
893            )
894            ->execute();
895
896        $translationValues = [];
897        foreach ($statement as $record) {
898            $translationValues[$record['uid']] = $record;
899        }
900        return $translationValues;
901    }
902
903    /**
904     * Fetches translation dependencies for a given parent/source record ids.
905     *
906     * Existing records in database:
907     * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0]
908     * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1]
909     * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2]
910     *
911     * Input $ids and their results:
912     * + [5]   -> [DataMapItem(6), DataMapItem(7)] # since 5 is parent/source
913     * + [6]   -> [DataMapItem(7)]                 # since 6 is source
914     * + [7]   -> []                               # since there's nothing
915     *
916     * @param string $tableName
917     * @param int[]|string[] $ids
918     * @return DataMapItem[][]
919     */
920    protected function fetchDependencies(string $tableName, array $ids)
921    {
922        if (empty($ids) || !BackendUtility::isTableLocalizable($tableName)) {
923            return [];
924        }
925
926        $fieldNames = [
927            'uid' => 'uid',
928            'l10n_state' => 'l10n_state',
929            'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
930            'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
931        ];
932        if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
933            $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
934        }
935        $fieldNamesMap = array_combine($fieldNames, $fieldNames);
936
937        $persistedIds = $this->filterNumericIds($ids);
938        $createdIds = array_diff($ids, $persistedIds);
939        $dependentElements = $this->fetchDependentElements($tableName, $persistedIds, $fieldNames);
940
941        foreach ($createdIds as $createdId) {
942            $data = $this->allDataMap[$tableName][$createdId] ?? null;
943            if ($data === null) {
944                continue;
945            }
946            $dependentElements[] = array_merge(
947                ['uid' => $createdId],
948                array_intersect_key($data, $fieldNamesMap)
949            );
950        }
951
952        $dependencyMap = [];
953        foreach ($dependentElements as $dependentElement) {
954            $dependentItem = DataMapItem::build(
955                $tableName,
956                $dependentElement['uid'],
957                [],
958                $dependentElement,
959                $fieldNames
960            );
961
962            if ($dependentItem->isDirectChildType()) {
963                $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
964            }
965            if ($dependentItem->isGrandChildType()) {
966                $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
967                $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem;
968            }
969        }
970        return $dependencyMap;
971    }
972
973    /**
974     * Fetches dependent records that depend on given record id's in in either
975     * their parent or source field for translatable tables or their origin
976     * field for non-translatable tables and creates an id mapping.
977     *
978     * This method expands the search criteria by expanding to ancestors.
979     *
980     * Existing records in database:
981     * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0]
982     * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1]
983     * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2]
984     *
985     * Input $ids and $desiredLanguage and their results:
986     * + $ids=[5], $lang=1 -> [5 => 6] # since 5 is source of 6
987     * + $ids=[5], $lang=2 -> []       # since 5 is parent of 7, but different language
988     * + $ids=[6], $lang=1 -> []       # since there's nothing
989     * + $ids=[6], $lang=2 -> [6 => 7] # since 6 has source 5, which is ancestor of 7
990     * + $ids=[7], $lang=* -> []       # since there's nothing
991     *
992     * @param string $tableName
993     * @param array $ids
994     * @param int $desiredLanguage
995     * @return array
996     */
997    protected function fetchDependentIdMap(string $tableName, array $ids, int $desiredLanguage)
998    {
999        $ancestorIdMap = [];
1000        if (empty($ids)) {
1001            return [];
1002        }
1003
1004        $ids = $this->filterNumericIds($ids);
1005        $isTranslatable = BackendUtility::isTableLocalizable($tableName);
1006        $originFieldName = ($GLOBALS['TCA'][$tableName]['ctrl']['origUid'] ?? null);
1007
1008        if (!$isTranslatable && $originFieldName === null) {
1009            // @todo Possibly throw an error, since pointing to original entity is not possible (via origin/parent)
1010            return [];
1011        }
1012
1013        if ($isTranslatable) {
1014            $fieldNames = [
1015                'uid' => 'uid',
1016                'l10n_state' => 'l10n_state',
1017                'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
1018                'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
1019            ];
1020            if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
1021                $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
1022            }
1023        } else {
1024            $fieldNames = [
1025                'uid' => 'uid',
1026                'origin' => $originFieldName,
1027            ];
1028        }
1029
1030        $fetchIds = $ids;
1031        if ($isTranslatable) {
1032            // expand search criteria via parent and source elements
1033            $translationValues = $this->fetchTranslationValues($tableName, $fieldNames, $ids);
1034            $ancestorIdMap = $this->buildElementAncestorIdMap($fieldNames, $translationValues);
1035            $fetchIds = array_unique(array_merge($ids, array_keys($ancestorIdMap)));
1036        }
1037
1038        $dependentElements = $this->fetchDependentElements($tableName, $fetchIds, $fieldNames);
1039
1040        $dependentIdMap = [];
1041        foreach ($dependentElements as $dependentElement) {
1042            $dependentId = $dependentElement['uid'];
1043            // implicit: use origin pointer if table cannot be translated
1044            if (!$isTranslatable) {
1045                $ancestorId = (int)$dependentElement[$fieldNames['origin']];
1046            // only consider element if it reflects the desired language
1047            } elseif ((int)$dependentElement[$fieldNames['language']] === $desiredLanguage) {
1048                $ancestorId = $this->resolveAncestorId($fieldNames, $dependentElement);
1049            } else {
1050                // otherwise skip the element completely
1051                continue;
1052            }
1053            // only keep ancestors that were initially requested before expanding
1054            if (in_array($ancestorId, $ids, true)) {
1055                $dependentIdMap[$ancestorId] = $dependentId;
1056            } elseif (!empty($ancestorIdMap[$ancestorId])) {
1057                // resolve from previously expanded search criteria
1058                $possibleChainedIds = array_intersect(
1059                    $ids,
1060                    $ancestorIdMap[$ancestorId]
1061                );
1062                if (!empty($possibleChainedIds)) {
1063                    $ancestorId = $possibleChainedIds[0];
1064                    $dependentIdMap[$ancestorId] = $dependentId;
1065                }
1066            }
1067        }
1068        return $dependentIdMap;
1069    }
1070
1071    /**
1072     * Fetch all elements that depend on given record id's in either their
1073     * parent or source field for translatable tables or their origin field
1074     * for non-translatable tables.
1075     *
1076     * @param string $tableName
1077     * @param array $ids
1078     * @param array $fieldNames
1079     * @return array
1080     * @throws \InvalidArgumentException
1081     */
1082    protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames)
1083    {
1084        if (empty($ids)) {
1085            return [];
1086        }
1087
1088        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1089            ->getQueryBuilderForTable($tableName);
1090        $queryBuilder->getRestrictions()
1091            ->removeAll()
1092            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
1093            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->backendUser->workspace));
1094
1095        $zeroParameter = $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT);
1096        $ids = $this->filterNumericIds($ids);
1097        $idsParameter = $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY);
1098
1099        // fetch by language dependency
1100        if (!empty($fieldNames['language']) && !empty($fieldNames['parent'])) {
1101            $ancestorPredicates = [
1102                $queryBuilder->expr()->in(
1103                    $fieldNames['parent'],
1104                    $idsParameter
1105                )
1106            ];
1107            if (!empty($fieldNames['source'])) {
1108                $ancestorPredicates[] = $queryBuilder->expr()->in(
1109                    $fieldNames['source'],
1110                    $idsParameter
1111                );
1112            }
1113            $predicates = [
1114                // must be any kind of localization
1115                $queryBuilder->expr()->gt(
1116                    $fieldNames['language'],
1117                    $zeroParameter
1118                ),
1119                // must be in connected mode
1120                $queryBuilder->expr()->gt(
1121                    $fieldNames['parent'],
1122                    $zeroParameter
1123                ),
1124                // any parent or source pointers
1125                $queryBuilder->expr()->orX(...$ancestorPredicates),
1126            ];
1127        } elseif (!empty($fieldNames['origin'])) {
1128            // fetch by origin dependency ("copied from")
1129            $predicates = [
1130                $queryBuilder->expr()->in(
1131                    $fieldNames['origin'],
1132                    $idsParameter
1133                )
1134            ];
1135        } else {
1136            // otherwise: stop execution
1137            throw new \InvalidArgumentException(
1138                'Invalid combination of query field names given',
1139                1487192370
1140            );
1141        }
1142
1143        $statement = $queryBuilder
1144            ->select(...array_values($fieldNames))
1145            ->from($tableName)
1146            ->andWhere(...$predicates)
1147            ->execute();
1148
1149        $dependentElements = [];
1150        foreach ($statement as $record) {
1151            $dependentElements[] = $record;
1152        }
1153        return $dependentElements;
1154    }
1155
1156    /**
1157     * Return array of data map items that are of given type
1158     *
1159     * @param string $type
1160     * @param DataMapItem[] $items
1161     * @return DataMapItem[]
1162     */
1163    protected function filterItemsByType(string $type, array $items)
1164    {
1165        return array_filter(
1166            $items,
1167            function (DataMapItem $item) use ($type) {
1168                return $item->getType() === $type;
1169            }
1170        );
1171    }
1172
1173    /**
1174     * Return only ids that are integer - so no "NEW..." values
1175     *
1176     * @param string[]|int[] $ids
1177     * @return int[]
1178     */
1179    protected function filterNumericIds(array $ids)
1180    {
1181        $ids = array_filter(
1182            $ids,
1183            function ($id) {
1184                return MathUtility::canBeInterpretedAsInteger($id);
1185            }
1186        );
1187        return array_map('intval', $ids);
1188    }
1189
1190    /**
1191     * Return only ids that don't have an item equivalent in $this->allItems.
1192     *
1193     * @param string $tableName
1194     * @param int[] $ids
1195     * @return array
1196     */
1197    protected function filterNewItemIds(string $tableName, array $ids)
1198    {
1199        return array_filter(
1200            $ids,
1201            function ($id) use ($tableName) {
1202                return $this->findItem($tableName, $id) === null;
1203            }
1204        );
1205    }
1206
1207    /**
1208     * Flatten array
1209     *
1210     * @param array $relationItems
1211     * @return string[]
1212     */
1213    protected function mapRelationItemId(array $relationItems)
1214    {
1215        return array_map(
1216            function (array $relationItem) {
1217                return (int)$relationItem['id'];
1218            },
1219            $relationItems
1220        );
1221    }
1222
1223    /**
1224     * @param array<string, string> $fieldNames
1225     * @param array<string, mixed> $element
1226     * @return int|null either a (non-empty) ancestor uid, or `null` if unresolved
1227     */
1228    protected function resolveAncestorId(array $fieldNames, array $element)
1229    {
1230        $sourceName = $fieldNames['source'] ?? null;
1231        if ($sourceName !== null && !empty($element[$sourceName])) {
1232            // implicit: use source pointer if given (not empty)
1233            return (int)$element[$sourceName];
1234        }
1235        $parentName = $fieldNames['parent'] ?? null;
1236        if ($parentName !== null && !empty($element[$parentName])) {
1237            // implicit: use parent pointer if given (not empty)
1238            return (int)$element[$parentName];
1239        }
1240        return null;
1241    }
1242
1243    /**
1244     * Builds a map from ancestor ids to accordant localization dependents.
1245     *
1246     * The result of e.g. [5 => [6, 7]] refers to ids 6 and 7 being dependents
1247     * (either used in parent or source field) of the ancestor with id 5.
1248     *
1249     * @param array $fieldNames
1250     * @param array $elements
1251     * @return array
1252     */
1253    protected function buildElementAncestorIdMap(array $fieldNames, array $elements)
1254    {
1255        $ancestorIdMap = [];
1256        foreach ($elements as $element) {
1257            $ancestorId = $this->resolveAncestorId($fieldNames, $element);
1258            if ($ancestorId !== null) {
1259                $ancestorIdMap[$ancestorId][] = (int)$element['uid'];
1260            }
1261        }
1262        return $ancestorIdMap;
1263    }
1264
1265    /**
1266     * See if an items is in item list and return it
1267     *
1268     * @param string $tableName
1269     * @param string|int $id
1270     * @return DataMapItem|null
1271     */
1272    protected function findItem(string $tableName, $id)
1273    {
1274        return $this->allItems[$tableName . ':' . $id] ?? null;
1275    }
1276
1277    /**
1278     * Duplicates an item from data-map and prefixes language title,
1279     * if applicable for the accordant field name.
1280     *
1281     * @param string $tableName
1282     * @param string|int $fromId
1283     * @param int $language
1284     * @param array $fieldNames
1285     * @param bool $localize
1286     * @return array
1287     * @deprecated Not used anymore, split into applyLocalizationReferences() and prefixLanguageTitle()
1288     */
1289    protected function duplicateFromDataMap(string $tableName, $fromId, int $language, array $fieldNames, bool $localize): array
1290    {
1291        $data = $this->allDataMap[$tableName][$fromId] ?? [];
1292        // just return if localization cannot be applied
1293        if (empty($language) || !$localize) {
1294            return $data;
1295        }
1296        $data = $this->applyLocalizationReferences($tableName, $fromId, $language, $fieldNames, $data);
1297        $data = $this->prefixLanguageTitle($tableName, $fromId, $language, $data);
1298        return $data;
1299    }
1300
1301    /**
1302     * Applies localization references to given raw data-map item.
1303     *
1304     * @param string $tableName
1305     * @param string|int $fromId
1306     * @param int $language
1307     * @param array $fieldNames
1308     * @param array $data
1309     * @return array
1310     */
1311    protected function applyLocalizationReferences(string $tableName, $fromId, int $language, array $fieldNames, array $data): array
1312    {
1313        // just return if localization cannot be applied
1314        if (empty($language)) {
1315            return $data;
1316        }
1317
1318        // apply `languageField`, e.g. `sys_language_uid`
1319        $data[$fieldNames['language']] = $language;
1320        // apply `transOrigPointerField`, e.g. `l10n_parent`
1321        if (empty($data[$fieldNames['parent']])) {
1322            // @todo Only $id used in TCA type 'select' is resolved in DataHandler's remapStack
1323            $data[$fieldNames['parent']] = $fromId;
1324        }
1325        // apply `translationSource`, e.g. `l10n_source`
1326        if (!empty($fieldNames['source'])) {
1327            // @todo Not sure, whether $id is resolved in DataHandler's remapStack
1328            $data[$fieldNames['source']] = $fromId;
1329        }
1330        // unset field names that are expected to be handled in this processor
1331        foreach ($this->getFieldNamesToBeHandled($tableName) as $fieldName) {
1332            unset($data[$fieldName]);
1333        }
1334
1335        return $data;
1336    }
1337
1338    /**
1339     * Prefixes language title if applicable for the accordant field name in raw data-map item.
1340     *
1341     * @param string $tableName
1342     * @param string|int $fromId
1343     * @param int $language
1344     * @param array $data
1345     * @return array
1346     */
1347    protected function prefixLanguageTitle(string $tableName, $fromId, int $language, array $data): array
1348    {
1349        $prefix = '';
1350        $prefixFieldNames = array_intersect(
1351            array_keys($data),
1352            $this->getPrefixLanguageTitleFieldNames($tableName)
1353        );
1354        if (empty($prefixFieldNames)) {
1355            return $data;
1356        }
1357
1358        $languageService = $this->getLanguageService();
1359        $languageRecord = BackendUtility::getRecord('sys_language', $language, 'title');
1360        [$pageId] = BackendUtility::getTSCpid($tableName, $fromId, $data['pid'] ?? null);
1361
1362        $tsConfigTranslateToMessage = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.']['translateToMessage'] ?? '';
1363        if (!empty($tsConfigTranslateToMessage)) {
1364            $prefix = $tsConfigTranslateToMessage;
1365            if ($languageService !== null) {
1366                $prefix = $languageService->sL($prefix);
1367            }
1368            $prefix = sprintf($prefix, $languageRecord['title']);
1369        }
1370        if (empty($prefix)) {
1371            $prefix = 'Translate to ' . $languageRecord['title'] . ':';
1372        }
1373
1374        foreach ($prefixFieldNames as $prefixFieldName) {
1375            // @todo The hook in DataHandler is not applied here
1376            $data[$prefixFieldName] = '[' . $prefix . '] ' . $data[$prefixFieldName];
1377        }
1378
1379        return $data;
1380    }
1381
1382    /**
1383     * Field names we have to deal with
1384     *
1385     * @param DataMapItem $item
1386     * @param string $scope
1387     * @param bool $modified
1388     * @return string[]
1389     */
1390    protected function getFieldNamesForItemScope(
1391        DataMapItem $item,
1392        string $scope,
1393        bool $modified
1394    ) {
1395        if (
1396            $scope === DataMapItem::SCOPE_PARENT
1397            || $scope === DataMapItem::SCOPE_SOURCE
1398        ) {
1399            if (!State::isApplicable($item->getTableName())) {
1400                return [];
1401            }
1402            return $item->getState()->filterFieldNames($scope, $modified);
1403        }
1404        if ($scope === DataMapItem::SCOPE_EXCLUDE) {
1405            return $this->getLocalizationModeExcludeFieldNames(
1406                $item->getTableName()
1407            );
1408        }
1409        return [];
1410    }
1411
1412    /**
1413     * Field names of TCA table with columns having l10n_mode=exclude
1414     *
1415     * @param string $tableName
1416     * @return string[]
1417     */
1418    protected function getLocalizationModeExcludeFieldNames(string $tableName)
1419    {
1420        $localizationExcludeFieldNames = [];
1421        if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
1422            return $localizationExcludeFieldNames;
1423        }
1424
1425        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
1426            if (($configuration['l10n_mode'] ?? null) === 'exclude'
1427                && ($configuration['config']['type'] ?? null) !== 'none'
1428            ) {
1429                $localizationExcludeFieldNames[] = $fieldName;
1430            }
1431        }
1432
1433        return $localizationExcludeFieldNames;
1434    }
1435
1436    /**
1437     * Gets a list of field names which have to be handled. Basically this
1438     * includes fields using allowLanguageSynchronization or l10n_mode=exclude.
1439     *
1440     * @param string $tableName
1441     * @return string[]
1442     */
1443    protected function getFieldNamesToBeHandled(string $tableName)
1444    {
1445        return array_merge(
1446            State::getFieldNames($tableName),
1447            $this->getLocalizationModeExcludeFieldNames($tableName)
1448        );
1449    }
1450
1451    /**
1452     * Field names of TCA table with columns having l10n_mode=prefixLangTitle
1453     *
1454     * @param string $tableName
1455     * @return array
1456     */
1457    protected function getPrefixLanguageTitleFieldNames(string $tableName)
1458    {
1459        $prefixLanguageTitleFieldNames = [];
1460        if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
1461            return $prefixLanguageTitleFieldNames;
1462        }
1463
1464        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
1465            $type = $configuration['config']['type'] ?? null;
1466            if (
1467                ($configuration['l10n_mode'] ?? null) === 'prefixLangTitle'
1468                && ($type === 'input' || $type === 'text')
1469            ) {
1470                $prefixLanguageTitleFieldNames[] = $fieldName;
1471            }
1472        }
1473
1474        return $prefixLanguageTitleFieldNames;
1475    }
1476
1477    /**
1478     * True if we're dealing with a field that has foreign db relations
1479     *
1480     * @param string $tableName
1481     * @param string $fieldName
1482     * @return bool True if field is type=group with internalType === db or select with foreign_table
1483     */
1484    protected function isRelationField(string $tableName, string $fieldName): bool
1485    {
1486        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1487            return false;
1488        }
1489
1490        $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1491
1492        return
1493            $configuration['type'] === 'group'
1494                && ($configuration['internal_type'] ?? null) === 'db'
1495                && !empty($configuration['allowed'])
1496            || $configuration['type'] === 'select'
1497                && (
1498                    !empty($configuration['foreign_table'])
1499                        && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1500                    || ($configuration['special'] ?? null) === 'languages'
1501                )
1502            || $this->isInlineRelationField($tableName, $fieldName)
1503        ;
1504    }
1505
1506    /**
1507     * True if we're dealing with an inline field
1508     *
1509     * @param string $tableName
1510     * @param string $fieldName
1511     * @return bool TRUE if field is of type inline with foreign_table set
1512     */
1513    protected function isInlineRelationField(string $tableName, string $fieldName): bool
1514    {
1515        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1516            return false;
1517        }
1518
1519        $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1520
1521        return
1522            $configuration['type'] === 'inline'
1523            && !empty($configuration['foreign_table'])
1524            && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1525        ;
1526    }
1527
1528    /**
1529     * Determines whether the table can be localized and either has fields
1530     * with allowLanguageSynchronization enabled or l10n_mode set to exclude.
1531     *
1532     * @param string $tableName
1533     * @return bool
1534     */
1535    protected function isApplicable(string $tableName): bool
1536    {
1537        return
1538            State::isApplicable($tableName)
1539            || BackendUtility::isTableLocalizable($tableName)
1540                && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0
1541        ;
1542    }
1543
1544    /**
1545     * @return RelationHandler
1546     */
1547    protected function createRelationHandler()
1548    {
1549        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
1550        $relationHandler->setWorkspaceId($this->backendUser->workspace);
1551        return $relationHandler;
1552    }
1553
1554    /**
1555     * @return LanguageService|null
1556     */
1557    protected function getLanguageService()
1558    {
1559        return $GLOBALS['LANG'] ?? null;
1560    }
1561}
1562