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