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