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