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