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