[FOLLOWUP][TASK] Enhance DataHandler translation and synchronization handling
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / DataHandling / Localization / DataMapProcessor.php
1 <?php
2 namespace TYPO3\CMS\Core\DataHandling\Localization;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Utility\BackendUtility;
18 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
19 use TYPO3\CMS\Core\Database\Connection;
20 use TYPO3\CMS\Core\Database\ConnectionPool;
21 use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
22 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
23 use TYPO3\CMS\Core\Database\RelationHandler;
24 use TYPO3\CMS\Core\DataHandling\DataHandler;
25 use TYPO3\CMS\Core\Utility\GeneralUtility;
26 use TYPO3\CMS\Core\Utility\MathUtility;
27 use TYPO3\CMS\Core\Utility\StringUtility;
28 use TYPO3\CMS\Lang\LanguageService;
29
30 /**
31 * This processor analyses the provided data-map before actually being process
32 * in the calling DataHandler instance. Field names that are configured to have
33 * "allowLanguageSynchronization" enabled are either synchronized from there
34 * relative parent records (could be a default language record, or a l10n_source
35 * record) or to their dependent records (in case a default language record or
36 * nested records pointing upwards with l10n_source).
37 *
38 * Except inline relational record editing, all modifications are applied to
39 * the data-map directly, which ensures proper history entries as a side-effect.
40 * For inline relational record editing, this processor either triggers the copy
41 * or localize actions by instantiation a new local DataHandler instance.
42 *
43 * Namings in this class:
44 * + forTableName, forId always refers to dependencies data is provided *for*
45 * + fromTableName, fromId always refers to ancestors data is retrieved *from*
46 */
47 class DataMapProcessor
48 {
49 /**
50 * @var array
51 */
52 protected $dataMap = [];
53
54 /**
55 * @var array
56 */
57 protected $sanitizationMap = [];
58
59 /**
60 * @var BackendUserAuthentication
61 */
62 protected $backendUser;
63
64 /**
65 * @var DataMapItem[]
66 */
67 protected $items = [];
68
69 /**
70 * Class generator
71 *
72 * @param array $dataMap The submitted data-map to be worked on
73 * @param BackendUserAuthentication $backendUser Forwared backend-user scope
74 * @return DataMapProcessor
75 */
76 public static function instance(array $dataMap, BackendUserAuthentication $backendUser)
77 {
78 return GeneralUtility::makeInstance(
79 static::class,
80 $dataMap,
81 $backendUser
82 );
83 }
84
85 /**
86 * @param array $dataMap The submitted data-map to be worked on
87 * @param BackendUserAuthentication $backendUser Forwared backend-user scope
88 */
89 public function __construct(array $dataMap, BackendUserAuthentication $backendUser)
90 {
91 $this->dataMap = $dataMap;
92 $this->backendUser = $backendUser;
93 }
94
95 /**
96 * Processes the submitted data-map and returns the sanitized and enriched
97 * version depending on accordant localization states and dependencies.
98 *
99 * @return array
100 */
101 public function process()
102 {
103 foreach ($this->dataMap as $tableName => $idValues) {
104 $this->collectItems($tableName, $idValues);
105 }
106 if (!empty($this->items)) {
107 $this->sanitize();
108 $this->enrich();
109 }
110 return $this->dataMap;
111 }
112
113 /**
114 * Create data map items of all affected rows
115 *
116 * @param string $tableName
117 * @param array $idValues
118 */
119 protected function collectItems(string $tableName, array $idValues)
120 {
121 $forTableName = $tableName;
122 if ($forTableName === 'pages') {
123 $forTableName = 'pages_language_overlay';
124 }
125
126 if (!$this->isApplicable($forTableName)) {
127 return;
128 }
129
130 $fieldNames = [
131 'uid' => 'uid',
132 'l10n_state' => 'l10n_state',
133 'language' => $GLOBALS['TCA'][$forTableName]['ctrl']['languageField'],
134 'parent' => $GLOBALS['TCA'][$forTableName]['ctrl']['transOrigPointerField'],
135 ];
136 if (!empty($GLOBALS['TCA'][$forTableName]['ctrl']['translationSource'])) {
137 $fieldNames['source'] = $GLOBALS['TCA'][$forTableName]['ctrl']['translationSource'];
138 }
139
140 $translationValues = [];
141 // Fetching parent/source pointer values does not make sense for pages
142 if ($tableName !== 'pages') {
143 $translationValues = $this->fetchTranslationValues(
144 $tableName,
145 $fieldNames,
146 $this->filterNumericIds(array_keys($idValues))
147 );
148 }
149
150 $dependencies = $this->fetchDependencies(
151 $forTableName,
152 $this->filterNumericIds(array_keys($idValues))
153 );
154
155 foreach ($idValues as $id => $values) {
156 $recordValues = $translationValues[$id] ?? [];
157 $item = DataMapItem::build(
158 $tableName,
159 $id,
160 $values,
161 $recordValues,
162 $fieldNames
163 );
164
165 // must be any kind of localization and in connected mode
166 if ($item->getLanguage() > 0 && empty($item->getParent())) {
167 unset($item);
168 continue;
169 }
170 // add dependencies
171 if (!empty($dependencies[$id])) {
172 $item->setDependencies($dependencies[$id]);
173 }
174 $this->items[$tableName . ':' . $id] = $item;
175 }
176 }
177
178 /**
179 * Sanitizes the submitted data-map and removes fields which are not
180 * defined as custom and thus rely on either parent or source values.
181 */
182 protected function sanitize()
183 {
184 foreach (['grandChild', 'directChild'] as $type) {
185 foreach ($this->filterItemsByType($type) as $item) {
186 $this->sanitizeTranslationItem($item);
187 }
188 }
189 }
190
191 /**
192 * Handle synchronization of an item list
193 */
194 protected function enrich()
195 {
196 foreach (['grandChild', 'directChild'] as $type) {
197 foreach ($this->filterItemsByType($type) as $item) {
198 foreach ($item->getApplicableScopes() as $scope) {
199 $fromId = $item->getIdForScope($scope);
200 if (empty($fromId)) {
201 continue;
202 }
203
204 $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew());
205 $this->synchronizeTranslationItem($item, $fieldNames, $fromId);
206 }
207 $this->populateTranslationItem($item);
208 $this->finishTranslationItem($item);
209 }
210 }
211 foreach ($this->filterItemsByType('parent') as $item) {
212 $this->populateTranslationItem($item);
213 }
214 }
215
216 /**
217 * Sanitizes the submitted data-map for a particular item and removes
218 * fields which are not defined as custom and thus rely on either parent
219 * or source values.
220 *
221 * @param DataMapItem $item
222 */
223 protected function sanitizeTranslationItem(DataMapItem $item)
224 {
225 $fieldNames = array_merge(
226 $this->getFieldNamesForItemScope($item, DataMapItem::SCOPE_PARENT, !$item->isNew()),
227 $this->getFieldNamesForItemScope($item, DataMapItem::SCOPE_SOURCE, !$item->isNew())
228 );
229
230 $fieldNameMap = array_combine($fieldNames, $fieldNames);
231 // separate fields, that are submitted in data-map, but not defined as custom
232 $this->sanitizationMap[$item->getTableName()][$item->getId()] = array_intersect_key(
233 $this->dataMap[$item->getTableName()][$item->getId()],
234 $fieldNameMap
235 );
236 // remove fields, that are submitted in data-map, but not defined as custom
237 $this->dataMap[$item->getTableName()][$item->getId()] = array_diff_key(
238 $this->dataMap[$item->getTableName()][$item->getId()],
239 $fieldNameMap
240 );
241 }
242
243 /**
244 * Synchronize a single item
245 *
246 * @param DataMapItem $item
247 * @param array $fieldNames
248 * @param int $fromId
249 */
250 protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, int $fromId)
251 {
252 if (empty($fieldNames)) {
253 return;
254 }
255 $fieldNameList = 'uid,' . implode(',', $fieldNames);
256 $fromRecord = BackendUtility::getRecordWSOL(
257 $item->getFromTableName(),
258 $fromId,
259 $fieldNameList
260 );
261 $forRecord = [];
262 if (!$item->isNew()) {
263 $forRecord = BackendUtility::getRecordWSOL(
264 $item->getTableName(),
265 $item->getId(),
266 $fieldNameList
267 );
268 }
269 foreach ($fieldNames as $fieldName) {
270 $this->synchronizeFieldValues(
271 $item,
272 $fieldName,
273 $fromRecord,
274 $forRecord
275 );
276 }
277 }
278
279 /**
280 * Populates values downwards, either from a parent language item or
281 * a source language item to an accordant dependent translation item.
282 *
283 * @param DataMapItem $item
284 */
285 protected function populateTranslationItem(DataMapItem $item)
286 {
287 if ($item->isNew()) {
288 return;
289 }
290
291 foreach ([State::STATE_PARENT, State::STATE_SOURCE] as $scope) {
292 foreach ($item->findDependencies($scope) as $dependentItem) {
293 // use suggested item, if it was submitted in data-map
294 $suggestedDependentItem = $this->findItem(
295 $dependentItem->getTableName(),
296 $dependentItem->getId()
297 );
298 if ($suggestedDependentItem !== null) {
299 $dependentItem = $suggestedDependentItem;
300 }
301 $fieldNames = $this->getFieldNamesForItemScope(
302 $dependentItem,
303 $scope,
304 false
305 );
306 $this->synchronizeTranslationItem(
307 $dependentItem,
308 $fieldNames,
309 $item->getId()
310 );
311 }
312 }
313 }
314
315 /**
316 * Finishes a translation item by updating states to be persisted.
317 *
318 * @param DataMapItem $item
319 */
320 protected function finishTranslationItem(DataMapItem $item)
321 {
322 if (
323 $item->isParentType()
324 || !State::isApplicable($item->getTableName())
325 ) {
326 return;
327 }
328
329 $this->dataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export();
330 }
331
332 /**
333 * Synchronize simple values like text and similar
334 *
335 * @param DataMapItem $item
336 * @param string $fieldName
337 * @param array $fromRecord
338 * @param array $forRecord
339 */
340 protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
341 {
342 // skip if this field has been processed already, assumed that proper sanitation happened
343 if (!empty($this->dataMap[$item->getTableName()][$item->getId()][$fieldName])) {
344 return;
345 }
346
347 $fromId = $fromRecord['uid'];
348 $fromValue = $this->dataMap[$item->getFromTableName()][$fromId][$fieldName] ?? $fromRecord[$fieldName];
349
350 // plain values
351 if (!$this->isRelationField($item->getFromTableName(), $fieldName)) {
352 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = $fromValue;
353 // direct relational values
354 } elseif (!$this->isInlineRelationField($item->getFromTableName(), $fieldName)) {
355 $this->synchronizeDirectRelations($item, $fieldName, $fromRecord);
356 // inline relational values
357 } else {
358 $this->synchronizeInlineRelations($item, $fieldName, $fromRecord, $forRecord);
359 }
360 }
361
362 /**
363 * Synchronize select and group field localizations
364 *
365 * @param DataMapItem $item
366 * @param string $fieldName
367 * @param array $fromRecord
368 */
369 protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord)
370 {
371 $fromId = $fromRecord['uid'];
372 $fromValue = $this->dataMap[$item->getFromTableName()][$fromId][$fieldName] ?? $fromRecord[$fieldName];
373 $configuration = $GLOBALS['TCA'][$item->getFromTableName()]['columns'][$fieldName];
374
375 // non-MM relations are stored as comma separated values, just use them
376 // if values are available in data-map already, just use them as well
377 if (
378 empty($configuration['config']['MM'])
379 || isset($this->dataMap[$item->getFromTableName()][$fromId][$fieldName])
380 || ($configuration['config']['special'] ?? null) === 'languages'
381 ) {
382 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = $fromValue;
383 return;
384 }
385
386 // fetch MM relations from storage
387 $type = $configuration['config']['type'];
388 $manyToManyTable = $configuration['config']['MM'];
389 if ($type === 'group' && $configuration['config']['internal_type'] === 'db') {
390 $tableNames = trim($configuration['config']['allowed'] ?? '');
391 } elseif ($configuration['config']['type'] === 'select') {
392 $tableNames = ($configuration['foreign_table'] ?? '');
393 } else {
394 return;
395 }
396
397 $relationHandler = $this->createRelationHandler();
398 $relationHandler->start(
399 '',
400 $tableNames,
401 $manyToManyTable,
402 $fromId,
403 $item->getFromTableName(),
404 $configuration['config']
405 );
406
407 // provide list of relations, optionally prepended with table name
408 // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28"
409 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
410 ',',
411 $relationHandler->getValueArray()
412 );
413 }
414
415 /**
416 * Handle synchonization of inline relations
417 *
418 * @param DataMapItem $item
419 * @param string $fieldName
420 * @param array $fromRecord
421 * @param array $forRecord
422 * @throws \RuntimeException
423 */
424 protected function synchronizeInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
425 {
426 $fromId = $fromRecord['uid'];
427 $configuration = $GLOBALS['TCA'][$item->getFromTableName()]['columns'][$fieldName];
428 $isLocalizationModeExclude = ($configuration['l10n_mode'] ?? null) === 'exclude';
429 $foreignTableName = $configuration['config']['foreign_table'];
430 $manyToManyTable = ($configuration['config']['MM'] ?? '');
431
432 $fieldNames = [
433 'language' => ($GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'] ?? null),
434 'parent' => ($GLOBALS['TCA'][$foreignTableName]['ctrl']['transOrigPointerField'] ?? null),
435 'source' => ($GLOBALS['TCA'][$foreignTableName]['ctrl']['translationSource'] ?? null),
436 ];
437 $isTranslatable = (!empty($fieldNames['language']) && !empty($fieldNames['parent']));
438
439 // determine suggested elements of either translation parent or source record
440 // from data-map, in case the accordant language parent/source record was modified
441 if (isset($this->dataMap[$item->getFromTableName()][$fromId][$fieldName])) {
442 $suggestedAncestorIds = GeneralUtility::trimExplode(
443 ',',
444 $this->dataMap[$item->getFromTableName()][$fromId][$fieldName],
445 true
446 );
447 // determine suggested elements of either translation parent or source record from storage
448 } else {
449 $relationHandler = $this->createRelationHandler();
450 $relationHandler->start(
451 $fromRecord[$fieldName],
452 $foreignTableName,
453 $manyToManyTable,
454 $fromId,
455 $item->getFromTableName(),
456 $configuration['config']
457 );
458 $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray);
459 }
460 // determine persisted elements for the current data-map item
461 $relationHandler = $this->createRelationHandler();
462 $relationHandler->start(
463 $forRecord[$fieldName] ?? '',
464 $foreignTableName,
465 $manyToManyTable,
466 $item->getId(),
467 $item->getTableName(),
468 $configuration['config']
469 );
470 $persistedIds = $this->mapRelationItemId($relationHandler->itemArray);
471 // The dependent ID map points from language parent/source record to
472 // localization, thus keys: parents/sources & values: localizations
473 $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds);
474 // filter incomplete structures - this is a drawback of DataHandler's remap stack, since
475 // just created IRRE translations still belong to the language parent - filter them out
476 $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap));
477 // compile element differences to be resolved
478 // remove elements that are persisted at the language translation, but not required anymore
479 $removeIds = array_diff($persistedIds, array_values($dependentIdMap));
480 // remove elements that are persisted at the language parent/source, but not required anymore
481 $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds);
482 // missing elements that are persisted at the language parent/source, but not translated yet
483 $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap));
484 // persisted elements that should be copied or localized
485 $createAncestorIds = $this->filterNumericIds($missingAncestorIds, true);
486 // non-persisted elements that should be duplicated in data-map directly
487 $populateAncestorIds = $this->filterNumericIds($missingAncestorIds, false);
488 // this desired state map defines the final result of child elements in their parent translation
489 $desiredIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds);
490 // update existing translations in the desired state map
491 foreach ($dependentIdMap as $ancestorId => $translationId) {
492 if (isset($desiredIdMap[$ancestorId])) {
493 $desiredIdMap[$ancestorId] = $translationId;
494 }
495 }
496 // nothing to synchronize, but element order could have been changed
497 if (empty($removeAncestorIds) && empty($missingAncestorIds)) {
498 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
499 ',',
500 array_values($desiredIdMap)
501 );
502 return;
503 }
504 // In case only missing elements shall be created, re-use previously sanitized
505 // values IF child table cannot be translated, the relation parent item is new
506 // and the count of missing relations equals the count of previously sanitized
507 // relations. This is caused during copy processes, when the child relations
508 // already have been cloned in DataHandler::copyRecord_procBasedOnFieldType()
509 // without the possibility to resolve the initial connections at this point.
510 // Otherwise child relations would superfluously be duplicated again here.
511 // @todo Invalid manually injected child relations cannot be determined here
512 $sanitizedValue = $this->sanitizationMap[$item->getTableName()][$item->getId()][$fieldName] ?? null;
513 if (!empty($missingAncestorIds) && $item->isNew()
514 && $sanitizedValue !== null && !$isTranslatable
515 && count(GeneralUtility::trimExplode(',', $sanitizedValue)) === count($missingAncestorIds)
516 ) {
517 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = $sanitizedValue;
518 return;
519 }
520
521 $localCommandMap = [];
522 foreach ($removeIds as $removeId) {
523 $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
524 }
525 foreach ($removeAncestorIds as $removeAncestorId) {
526 $removeId = $dependentIdMap[$removeAncestorId];
527 $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
528 }
529 foreach ($createAncestorIds as $createAncestorId) {
530 // if child table is not aware of localization, just copy
531 if ($isLocalizationModeExclude || !$isTranslatable) {
532 $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = -$createAncestorId;
533 // otherwise, trigger the localization process
534 } else {
535 $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage();
536 }
537 }
538 // execute copy, localize and delete actions on persisted child records
539 if (!empty($localCommandMap)) {
540 $localDataHandler = GeneralUtility::makeInstance(DataHandler::class);
541 $localDataHandler->start([], $localCommandMap, $this->backendUser);
542 $localDataHandler->process_cmdmap();
543 // update copied or localized ids
544 foreach ($createAncestorIds as $createAncestorId) {
545 if (empty($localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId])) {
546 $additionalInformation = '';
547 if (!empty($localDataHandler->errorLog)) {
548 $additionalInformation = ', reason "'
549 . implode(', ', $localDataHandler->errorLog) . '"';
550 }
551 throw new \RuntimeException(
552 'Child record was not processed' . $additionalInformation,
553 1486233164);
554 }
555 $newLocalizationId = $localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId];
556 $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId;
557 $desiredIdMap[$createAncestorId] = $newLocalizationId;
558 }
559 }
560 // populate new child records in data-map
561 if (!empty($populateAncestorIds)) {
562 foreach ($populateAncestorIds as $populateAncestorId) {
563 $newLocalizationId = StringUtility::getUniqueId('NEW');
564 $desiredIdMap[$populateAncestorId] = $newLocalizationId;
565 $this->dataMap[$foreignTableName][$newLocalizationId] = $this->duplicateFromDataMap(
566 $foreignTableName,
567 $populateAncestorId,
568 $item->getLanguage(),
569 $fieldNames
570 );
571 }
572 }
573 // update inline parent field references - required to update pointer fields
574 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
575 ',',
576 array_values($desiredIdMap)
577 );
578 }
579
580 /**
581 * Fetches translation related field values for the items submitted in
582 * the data-map. That's why further adjustment for the tables pages vs.
583 * pages_language_overlay is not required.
584 *
585 * @param string $tableName
586 * @param array $fieldNames
587 * @param array $ids
588 * @return array
589 */
590 protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids)
591 {
592 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
593 ->getQueryBuilderForTable($tableName);
594 $queryBuilder->getRestrictions()
595 ->removeAll()
596 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
597 ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, $this->backendUser->workspace, false));
598 $statement = $queryBuilder
599 ->select(...array_values($fieldNames))
600 ->from($tableName)
601 ->where(
602 $queryBuilder->expr()->in(
603 'uid',
604 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
605 )
606 )
607 ->execute();
608
609 $translationValues = [];
610 foreach ($statement as $record) {
611 $translationValues[$record['uid']] = $record;
612 }
613 return $translationValues;
614 }
615
616 /**
617 * Fetches translation dependencies for a given parent/source record ids.
618 *
619 * @param string $tableName
620 * @param array $ids
621 * @return DataMapItem[][]
622 */
623 protected function fetchDependencies(string $tableName, array $ids)
624 {
625 if ($tableName === 'pages') {
626 $tableName = 'pages_language_overlay';
627 }
628
629 if (!BackendUtility::isTableLocalizable($tableName)) {
630 return [];
631 }
632
633 $fieldNames = [
634 'uid' => 'uid',
635 'l10n_state' => 'l10n_state',
636 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
637 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
638 ];
639 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
640 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
641 }
642
643 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
644
645 $dependencyMap = [];
646 foreach ($dependentElements as $dependentElement) {
647 $dependentItem = DataMapItem::build(
648 $tableName,
649 $dependentElement['uid'],
650 [],
651 $dependentElement,
652 $fieldNames
653 );
654
655 if ($dependentItem->isDirectChildType()) {
656 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
657 }
658 if ($dependentItem->isGrandChildType()) {
659 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
660 $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem;
661 }
662 }
663 return $dependencyMap;
664 }
665
666 /**
667 * Fetches dependent records that depend on given record id's in in either
668 * their parent or source field for translatable tables or their origin
669 * field for non-translatable tables and creates an id mapping.
670 *
671 * @param string $tableName
672 * @param array $ids
673 * @return array
674 */
675 protected function fetchDependentIdMap(string $tableName, array $ids)
676 {
677 if ($tableName === 'pages') {
678 $tableName = 'pages_language_overlay';
679 }
680
681 $isTranslatable = BackendUtility::isTableLocalizable($tableName);
682 $originFieldName = ($GLOBALS['TCA'][$tableName]['ctrl']['origUid'] ?? null);
683
684 if (!$isTranslatable && $originFieldName === null) {
685 return [];
686 }
687
688 if ($isTranslatable) {
689 $fieldNames = [
690 'uid' => 'uid',
691 'l10n_state' => 'l10n_state',
692 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
693 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
694 ];
695 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
696 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
697 }
698 } else {
699 $fieldNames = [
700 'uid' => 'uid',
701 'origin' => $originFieldName,
702 ];
703 }
704
705 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
706
707 $dependentIdMap = [];
708 foreach ($dependentElements as $dependentElement) {
709 $dependentId = $dependentElement['uid'];
710 // implicit: use origin pointer if table cannot be translated
711 if (!$isTranslatable) {
712 $dependentIdMap[$dependentElement[$fieldNames['origin']]] = $dependentId;
713 // implicit: having source value different to parent value, use source pointer
714 } elseif (
715 !empty($fieldNames['source'])
716 && $dependentElement[$fieldNames['source']] !== $dependentElement[$fieldNames['parent']]
717 ) {
718 $dependentIdMap[$dependentElement[$fieldNames['source']]] = $dependentId;
719 // implicit: otherwise, use parent pointer
720 } else {
721 $dependentIdMap[$dependentElement[$fieldNames['parent']]] = $dependentId;
722 }
723 }
724 return $dependentIdMap;
725 }
726
727 /**
728 * Fetch all elements that depend on given record id's in either their
729 * parent or source field for translatable tables or their origin field
730 * for non-translatable tables.
731 *
732 * @param string $tableName
733 * @param array $ids
734 * @param array $fieldNames
735 * @return array
736 * @throws \InvalidArgumentException
737 */
738 protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames)
739 {
740 $ids = $this->filterNumericIds($ids, true);
741
742 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
743 ->getQueryBuilderForTable($tableName);
744 $queryBuilder->getRestrictions()
745 ->removeAll()
746 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
747 ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, $this->backendUser->workspace, false));
748
749 $zeroParameter = $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT);
750 $ids = array_filter($ids, [MathUtility::class, 'canBeInterpretedAsInteger']);
751 $idsParameter = $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY);
752
753 // fetch by language dependency
754 if (!empty($fieldNames['language']) && !empty($fieldNames['parent'])) {
755 $ancestorPredicates = [
756 $queryBuilder->expr()->in(
757 $fieldNames['parent'],
758 $idsParameter
759 )
760 ];
761 if (!empty($fieldNames['source'])) {
762 $ancestorPredicates[] = $queryBuilder->expr()->in(
763 $fieldNames['source'],
764 $idsParameter
765 );
766 }
767 $predicates = [
768 // must be any kind of localization
769 $queryBuilder->expr()->gt(
770 $fieldNames['language'],
771 $zeroParameter
772 ),
773 // must be in connected mode
774 $queryBuilder->expr()->gt(
775 $fieldNames['parent'],
776 $zeroParameter
777 ),
778 // any parent or source pointers
779 $queryBuilder->expr()->orX(...$ancestorPredicates),
780 ];
781 // fetch by origin dependency ("copied from")
782 } elseif (!empty($fieldNames['origin'])) {
783 $predicates = [
784 $queryBuilder->expr()->in(
785 $fieldNames['origin'],
786 $idsParameter
787 )
788 ];
789 // otherwise: stop execution
790 } else {
791 throw new \InvalidArgumentException(
792 'Invalid combination of query field names given',
793 1487192370
794 );
795 }
796
797 $statement = $queryBuilder
798 ->select(...array_values($fieldNames))
799 ->from($tableName)
800 ->andWhere(...$predicates)
801 ->execute();
802
803 $dependentElements = [];
804 foreach ($statement as $record) {
805 $dependentElements[] = $record;
806 }
807 return $dependentElements;
808 }
809
810 /**
811 * Return array of data map items that are of given type
812 *
813 * @param string $type
814 * @return DataMapItem[]
815 */
816 protected function filterItemsByType(string $type)
817 {
818 return array_filter(
819 $this->items,
820 function (DataMapItem $item) use ($type) {
821 return $item->getType() === $type;
822 }
823 );
824 }
825
826 /**
827 * Return only id's that are integer - so no NEW...
828 *
829 * @param array $ids
830 * @param bool $numeric
831 * @return array
832 */
833 protected function filterNumericIds(array $ids, bool $numeric = true)
834 {
835 return array_filter(
836 $ids,
837 function ($id) use ($numeric) {
838 return MathUtility::canBeInterpretedAsInteger($id) === $numeric;
839 }
840 );
841 }
842
843 /**
844 * Flatten array
845 *
846 * @param array $relationItems
847 * @return string[]
848 */
849 protected function mapRelationItemId(array $relationItems)
850 {
851 return array_map(
852 function (array $relationItem) {
853 return (string)$relationItem['id'];
854 },
855 $relationItems
856 );
857 }
858
859 /**
860 * See if an items is in item list and return it
861 *
862 * @param string $tableName
863 * @param string|int $id
864 * @return null|DataMapItem
865 */
866 protected function findItem(string $tableName, $id)
867 {
868 return $this->items[$tableName . ':' . $id] ?? null;
869 }
870
871 /**
872 * Duplicates an item from data-map and prefixed language title,
873 * if applicable for the accordant field name.
874 *
875 * @param string $tableName
876 * @param string|int $id
877 * @param int $language
878 * @param array $fieldNames
879 * @return array
880 */
881 protected function duplicateFromDataMap(string $tableName, $id, int $language, array $fieldNames)
882 {
883 $data = $this->dataMap[$tableName][$id];
884 $isTranslatable = (!empty($fieldNames['language']) && !empty($fieldNames['parent']));
885
886 if (empty($language) || !$isTranslatable) {
887 return $data;
888 }
889
890 $data[$fieldNames['language']] = $language;
891 // @todo Only $id used in TCA type 'select' is resolved in DataHandler's remapStack
892 $data[$fieldNames['parent']] = $id;
893 if (!empty($fieldNames['source'])) {
894 // @todo Not sure, whether $id is resolved in DataHandler's remapStack
895 $data[$fieldNames['source']] = $id;
896 }
897
898 $prefixFieldNames = array_intersect(
899 array_keys($data),
900 $this->getPrefixLanguageTitleFieldNames($tableName)
901 );
902 if (empty($prefixFieldNames)) {
903 return $data;
904 }
905
906 $languageService = $this->getLanguageService();
907 $languageRecord = BackendUtility::getRecord('sys_language', $language, 'title');
908 list($pageId) = BackendUtility::getTSCpid($tableName, $id, $data['pid'] ?? null);
909
910 $TSconfig = $this->backendUser->getTSConfig(
911 'TCEMAIN',
912 BackendUtility::getPagesTSconfig($pageId)
913 );
914 if (!empty($TSconfig['translateToMessage'])) {
915 $prefix = $TSconfig['translateToMessage'];
916 if ($languageService !== null) {
917 $prefix = $languageService->sL($prefix);
918 }
919 $prefix = sprintf($prefix, $languageRecord['title']);
920 }
921 if (empty($prefix)) {
922 $prefix = 'Translate to ' . $languageRecord['title'] . ':';
923 }
924
925 foreach ($prefixFieldNames as $prefixFieldName) {
926 // @todo The hook in DataHandler is not applied here
927 $data[$prefixFieldName] = '[' . $prefix . '] ' . $data[$prefixFieldName];
928 }
929
930 return $data;
931 }
932
933 /**
934 * Field names we have to deal with
935 *
936 * @param DataMapItem $item
937 * @param string $scope
938 * @param bool $modified
939 * @return string[]
940 */
941 protected function getFieldNamesForItemScope(
942 DataMapItem $item,
943 string $scope,
944 bool $modified
945 ) {
946 if (
947 $scope === DataMapItem::SCOPE_PARENT
948 || $scope === DataMapItem::SCOPE_SOURCE
949 ) {
950 if (!State::isApplicable($item->getTableName())) {
951 return [];
952 }
953 return $item->getState()->filterFieldNames($scope, $modified);
954 }
955 if ($scope === DataMapItem::SCOPE_EXCLUDE) {
956 return $this->getLocalizationModeExcludeFieldNames(
957 $item->getTableName()
958 );
959 }
960 return [];
961 }
962
963 /**
964 * Field names of TCA table with columns having l10n_mode=exclude
965 *
966 * @param string $tableName
967 * @return string[]
968 */
969 protected function getLocalizationModeExcludeFieldNames(string $tableName)
970 {
971 $localizationExcludeFieldNames = [];
972 if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
973 return $localizationExcludeFieldNames;
974 }
975
976 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
977 if (($configuration['l10n_mode'] ?? null) === 'exclude') {
978 $localizationExcludeFieldNames[] = $fieldName;
979 }
980 }
981
982 return $localizationExcludeFieldNames;
983 }
984
985 /**
986 * Field names of TCA table with columns having l10n_mode=prefixLangTitle
987 *
988 * @param string $tableName
989 * @return array
990 */
991 protected function getPrefixLanguageTitleFieldNames(string $tableName)
992 {
993 if ($tableName === 'pages') {
994 $tableName = 'pages_language_overlay';
995 }
996
997 $prefixLanguageTitleFieldNames = [];
998 if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
999 return $prefixLanguageTitleFieldNames;
1000 }
1001
1002 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
1003 $type = $configuration['config']['type'] ?? null;
1004 if (
1005 ($configuration['l10n_mode'] ?? null) === 'prefixLangTitle'
1006 && ($type === 'input' || $type === 'text')
1007 ) {
1008 $prefixLanguageTitleFieldNames[] = $fieldName;
1009 }
1010 }
1011
1012 return $prefixLanguageTitleFieldNames;
1013 }
1014
1015 /**
1016 * True if we're dealing with a field that has foreign db relations
1017 *
1018 * @param string $tableName
1019 * @param string $fieldName
1020 * @return bool True if field is type=group with internalType === db or select with foreign_table
1021 */
1022 protected function isRelationField(string $tableName, string $fieldName): bool
1023 {
1024 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1025 return false;
1026 }
1027
1028 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1029
1030 return
1031 $configuration['type'] === 'group'
1032 && ($configuration['internal_type'] ?? null) === 'db'
1033 && !empty($configuration['allowed'])
1034 || $configuration['type'] === 'select'
1035 && (
1036 !empty($configuration['foreign_table'])
1037 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1038 || ($configuration['special'] ?? null) === 'languages'
1039 )
1040 || $this->isInlineRelationField($tableName, $fieldName)
1041 ;
1042 }
1043
1044 /**
1045 * True if we're dealing with an inline field
1046 *
1047 * @param string $tableName
1048 * @param string $fieldName
1049 * @return bool TRUE if field is of type inline with foreign_table set
1050 */
1051 protected function isInlineRelationField(string $tableName, string $fieldName): bool
1052 {
1053 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1054 return false;
1055 }
1056
1057 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1058
1059 return
1060 $configuration['type'] === 'inline'
1061 && !empty($configuration['foreign_table'])
1062 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1063 ;
1064 }
1065
1066 /**
1067 * Determines whether the table can be localized and either has fields
1068 * with allowLanguageSynchronization enabled or l10n_mode set to exclude.
1069 *
1070 * @param string $tableName
1071 * @return bool
1072 */
1073 protected function isApplicable(string $tableName): bool
1074 {
1075 return
1076 State::isApplicable($tableName)
1077 || BackendUtility::isTableLocalizable($tableName)
1078 && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0
1079 ;
1080 }
1081
1082 /**
1083 * @return RelationHandler
1084 */
1085 protected function createRelationHandler()
1086 {
1087 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
1088 $relationHandler->setWorkspaceId($this->backendUser->workspace);
1089 return $relationHandler;
1090 }
1091
1092 /**
1093 * @return null|LanguageService
1094 */
1095 protected function getLanguageService()
1096 {
1097 return $GLOBALS['LANG'] ?? null;
1098 }
1099 }