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