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