1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Core\DataHandling\Localization; 17 18use TYPO3\CMS\Backend\Utility\BackendUtility; 19use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; 20use TYPO3\CMS\Core\Database\Connection; 21use TYPO3\CMS\Core\Database\ConnectionPool; 22use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; 23use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction; 24use TYPO3\CMS\Core\Database\RelationHandler; 25use TYPO3\CMS\Core\DataHandling\DataHandler; 26use TYPO3\CMS\Core\DataHandling\ReferenceIndexUpdater; 27use TYPO3\CMS\Core\Localization\LanguageService; 28use TYPO3\CMS\Core\Utility\GeneralUtility; 29use TYPO3\CMS\Core\Utility\MathUtility; 30use TYPO3\CMS\Core\Utility\StringUtility; 31 32/** 33 * This processor analyzes the provided data-map before actually being process 34 * in the calling DataHandler instance. Field names that are configured to have 35 * "allowLanguageSynchronization" enabled are either synchronized from there 36 * relative parent records (could be a default language record, or a l10n_source 37 * record) or to their dependent records (in case a default language record or 38 * nested records pointing upwards with l10n_source). 39 * 40 * Except inline relational record editing, all modifications are applied to 41 * the data-map directly, which ensures proper history entries as a side-effect. 42 * For inline relational record editing, this processor either triggers the copy 43 * or localize actions by instantiation a new local DataHandler instance. 44 * 45 * Namings in this class: 46 * + forTableName, forId always refers to dependencies data is provided *for* 47 * + fromTableName, fromId always refers to ancestors data is retrieved *from* 48 * 49 * @internal should only be used by the TYPO3 Core 50 */ 51class DataMapProcessor 52{ 53 /** 54 * @var array 55 */ 56 protected $allDataMap = []; 57 58 /** 59 * @var array 60 */ 61 protected $modifiedDataMap = []; 62 63 /** 64 * @var array 65 */ 66 protected $sanitizationMap = []; 67 68 /** 69 * @var BackendUserAuthentication 70 */ 71 protected $backendUser; 72 73 /** 74 * @var ReferenceIndexUpdater 75 */ 76 protected $referenceIndexUpdater; 77 78 /** 79 * @var DataMapItem[] 80 */ 81 protected $allItems = []; 82 83 /** 84 * @var DataMapItem[] 85 */ 86 protected $nextItems = []; 87 88 /** 89 * Class generator 90 * 91 * @param array $dataMap The submitted data-map to be worked on 92 * @param BackendUserAuthentication $backendUser Forwarded backend-user scope 93 * @param ReferenceIndexUpdater|null $referenceIndexUpdater Forward reference index updater to sub DataHandler instances 94 * @return DataMapProcessor 95 */ 96 public static function instance( 97 array $dataMap, 98 BackendUserAuthentication $backendUser, 99 ReferenceIndexUpdater $referenceIndexUpdater = null 100 ) { 101 return GeneralUtility::makeInstance( 102 static::class, 103 $dataMap, 104 $backendUser, 105 $referenceIndexUpdater 106 ); 107 } 108 109 /** 110 * @param array $dataMap The submitted data-map to be worked on 111 * @param BackendUserAuthentication $backendUser Forwarded backend-user scope 112 * @param ReferenceIndexUpdater|null $referenceIndexUpdater Forward reference index updater to sub DataHandler instances 113 */ 114 public function __construct( 115 array $dataMap, 116 BackendUserAuthentication $backendUser, 117 ReferenceIndexUpdater $referenceIndexUpdater = null 118 ) { 119 $this->allDataMap = $dataMap; 120 $this->modifiedDataMap = $dataMap; 121 $this->backendUser = $backendUser; 122 if ($referenceIndexUpdater === null) { 123 $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class); 124 } 125 $this->referenceIndexUpdater = $referenceIndexUpdater; 126 } 127 128 /** 129 * Processes the submitted data-map and returns the sanitized and enriched 130 * version depending on accordant localization states and dependencies. 131 * 132 * @return array 133 */ 134 public function process() 135 { 136 $iterations = 0; 137 138 while (!empty($this->modifiedDataMap)) { 139 $this->nextItems = []; 140 foreach ($this->modifiedDataMap as $tableName => $idValues) { 141 $this->collectItems($tableName, $idValues); 142 } 143 144 $this->modifiedDataMap = []; 145 if (empty($this->nextItems)) { 146 break; 147 } 148 149 if ($iterations++ === 0) { 150 $this->sanitize($this->allItems); 151 } 152 $this->enrich($this->nextItems); 153 } 154 155 $this->allDataMap = $this->purgeDataMap($this->allDataMap); 156 return $this->allDataMap; 157 } 158 159 /** 160 * Purges superfluous empty data-map sections. 161 * 162 * @param array $dataMap 163 * @return array 164 */ 165 protected function purgeDataMap(array $dataMap): array 166 { 167 foreach ($dataMap as $tableName => $idValues) { 168 foreach ($idValues as $id => $values) { 169 if (empty($values)) { 170 unset($dataMap[$tableName][$id]); 171 } 172 } 173 if (empty($dataMap[$tableName])) { 174 unset($dataMap[$tableName]); 175 } 176 } 177 return $dataMap; 178 } 179 180 /** 181 * Create data map items of all affected rows 182 * 183 * @param string $tableName 184 * @param array $idValues 185 */ 186 protected function collectItems(string $tableName, array $idValues) 187 { 188 if (!$this->isApplicable($tableName)) { 189 return; 190 } 191 192 $fieldNames = [ 193 'uid' => 'uid', 194 'l10n_state' => 'l10n_state', 195 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'], 196 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'], 197 ]; 198 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) { 199 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource']; 200 } 201 202 $translationValues = $this->fetchTranslationValues( 203 $tableName, 204 $fieldNames, 205 $this->filterNewItemIds( 206 $tableName, 207 $this->filterNumericIds(array_keys($idValues)) 208 ) 209 ); 210 211 $dependencies = $this->fetchDependencies( 212 $tableName, 213 $this->filterNewItemIds($tableName, array_keys($idValues)) 214 ); 215 216 foreach ($idValues as $id => $values) { 217 $item = $this->findItem($tableName, $id); 218 // build item if it has not been created in a previous iteration 219 if ($item === null) { 220 $recordValues = $translationValues[$id] ?? []; 221 $item = DataMapItem::build( 222 $tableName, 223 $id, 224 $values, 225 $recordValues, 226 $fieldNames 227 ); 228 229 // elements using "all language" cannot be localized 230 if ($item->getLanguage() === -1) { 231 unset($item); 232 continue; 233 } 234 // must be any kind of localization and in connected mode 235 if ($item->getLanguage() > 0 && empty($item->getParent())) { 236 unset($item); 237 continue; 238 } 239 // add dependencies 240 if (!empty($dependencies[$id])) { 241 $item->setDependencies($dependencies[$id]); 242 } 243 } 244 // add item to $this->allItems and $this->nextItems 245 $this->addNextItem($item); 246 } 247 } 248 249 /** 250 * Sanitizes the submitted data-map items and removes fields which are not 251 * defined as custom and thus rely on either parent or source values. 252 * 253 * @param DataMapItem[] $items 254 */ 255 protected function sanitize(array $items) 256 { 257 foreach (['directChild', 'grandChild'] as $type) { 258 foreach ($this->filterItemsByType($type, $items) as $item) { 259 $this->sanitizeTranslationItem($item); 260 } 261 } 262 } 263 264 /** 265 * Handle synchronization of an item list 266 * 267 * @param DataMapItem[] $items 268 */ 269 protected function enrich(array $items) 270 { 271 foreach (['directChild', 'grandChild'] as $type) { 272 foreach ($this->filterItemsByType($type, $items) as $item) { 273 foreach ($item->getApplicableScopes() as $scope) { 274 $fromId = $item->getIdForScope($scope); 275 $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew()); 276 $this->synchronizeTranslationItem($item, $fieldNames, $fromId); 277 } 278 $this->populateTranslationItem($item); 279 $this->finishTranslationItem($item); 280 } 281 } 282 foreach ($this->filterItemsByType('parent', $items) as $item) { 283 $this->populateTranslationItem($item); 284 } 285 } 286 287 /** 288 * Sanitizes the submitted data-map for a particular item and removes 289 * fields which are not defined as custom and thus rely on either parent 290 * or source values. 291 * 292 * @param DataMapItem $item 293 */ 294 protected function sanitizeTranslationItem(DataMapItem $item) 295 { 296 $fieldNames = []; 297 foreach ($item->getApplicableScopes() as $scope) { 298 $fieldNames = array_merge( 299 $fieldNames, 300 $this->getFieldNamesForItemScope($item, $scope, false) 301 ); 302 } 303 304 $fieldNameMap = array_combine($fieldNames, $fieldNames); 305 // separate fields, that are submitted in data-map, but not defined as custom 306 $this->sanitizationMap[$item->getTableName()][$item->getId()] = array_intersect_key( 307 $this->allDataMap[$item->getTableName()][$item->getId()], 308 $fieldNameMap 309 ); 310 // remove fields, that are submitted in data-map, but not defined as custom 311 $this->allDataMap[$item->getTableName()][$item->getId()] = array_diff_key( 312 $this->allDataMap[$item->getTableName()][$item->getId()], 313 $fieldNameMap 314 ); 315 } 316 317 /** 318 * Synchronize a single item 319 * 320 * @param DataMapItem $item 321 * @param array $fieldNames 322 * @param string|int $fromId 323 */ 324 protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, $fromId) 325 { 326 if (empty($fieldNames)) { 327 return; 328 } 329 330 $fieldNameList = 'uid,' . implode(',', $fieldNames); 331 332 $fromRecord = ['uid' => $fromId]; 333 if (MathUtility::canBeInterpretedAsInteger($fromId)) { 334 $fromRecord = BackendUtility::getRecordWSOL( 335 $item->getTableName(), 336 $fromId, 337 $fieldNameList 338 ); 339 } 340 341 $forRecord = []; 342 if (!$item->isNew()) { 343 $forRecord = BackendUtility::getRecordWSOL( 344 $item->getTableName(), 345 $item->getId(), 346 $fieldNameList 347 ); 348 } 349 350 if (is_array($fromRecord) && is_array($forRecord)) { 351 foreach ($fieldNames as $fieldName) { 352 $this->synchronizeFieldValues( 353 $item, 354 $fieldName, 355 $fromRecord, 356 $forRecord 357 ); 358 } 359 } 360 } 361 362 /** 363 * Populates values downwards, either from a parent language item or 364 * a source language item to an accordant dependent translation item. 365 * 366 * @param DataMapItem $item 367 */ 368 protected function populateTranslationItem(DataMapItem $item) 369 { 370 foreach ([DataMapItem::SCOPE_PARENT, DataMapItem::SCOPE_SOURCE] as $scope) { 371 foreach ($item->findDependencies($scope) as $dependentItem) { 372 // use suggested item, if it was submitted in data-map 373 $suggestedDependentItem = $this->findItem( 374 $dependentItem->getTableName(), 375 $dependentItem->getId() 376 ); 377 if ($suggestedDependentItem !== null) { 378 $dependentItem = $suggestedDependentItem; 379 } 380 foreach ([$scope, DataMapItem::SCOPE_EXCLUDE] as $dependentScope) { 381 $fieldNames = $this->getFieldNamesForItemScope( 382 $dependentItem, 383 $dependentScope, 384 false 385 ); 386 $this->synchronizeTranslationItem( 387 $dependentItem, 388 $fieldNames, 389 $item->getId() 390 ); 391 } 392 } 393 } 394 } 395 396 /** 397 * Finishes a translation item by updating states to be persisted. 398 * 399 * @param DataMapItem $item 400 */ 401 protected function finishTranslationItem(DataMapItem $item) 402 { 403 if ( 404 $item->isParentType() 405 || !State::isApplicable($item->getTableName()) 406 ) { 407 return; 408 } 409 410 $this->allDataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export(); 411 } 412 413 /** 414 * Synchronize simple values like text and similar 415 * 416 * @param DataMapItem $item 417 * @param string $fieldName 418 * @param array $fromRecord 419 * @param array $forRecord 420 */ 421 protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord) 422 { 423 // skip if this field has been processed already, assumed that proper sanitation happened 424 if ($this->isSetInDataMap($item->getTableName(), $item->getId(), $fieldName)) { 425 return; 426 } 427 428 $fromId = $fromRecord['uid']; 429 // retrieve value from in-memory data-map 430 if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) { 431 $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName]; 432 } elseif (array_key_exists($fieldName, $fromRecord)) { 433 // retrieve value from record 434 $fromValue = $fromRecord[$fieldName]; 435 } else { 436 // otherwise abort synchronization 437 return; 438 } 439 440 // plain values 441 if (!$this->isRelationField($item->getTableName(), $fieldName)) { 442 $this->modifyDataMap( 443 $item->getTableName(), 444 $item->getId(), 445 [$fieldName => $fromValue] 446 ); 447 } elseif (!$this->isInlineRelationField($item->getTableName(), $fieldName)) { 448 // direct relational values 449 $this->synchronizeDirectRelations($item, $fieldName, $fromRecord); 450 } else { 451 // inline relational values 452 $this->synchronizeInlineRelations($item, $fieldName, $fromRecord, $forRecord); 453 } 454 } 455 456 /** 457 * Synchronize select and group field localizations 458 * 459 * @param DataMapItem $item 460 * @param string $fieldName 461 * @param array $fromRecord 462 */ 463 protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord) 464 { 465 $specialTableName = null; 466 $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName]; 467 $isSpecialLanguageField = ($configuration['config']['special'] ?? null) === 'languages'; 468 469 $fromId = $fromRecord['uid']; 470 if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) { 471 $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName]; 472 } else { 473 $fromValue = $fromRecord[$fieldName]; 474 } 475 476 // non-MM relations are stored as comma separated values, just use them 477 // if values are available in data-map already, just use them as well 478 if ( 479 empty($configuration['config']['MM']) 480 || $this->isSetInDataMap($item->getTableName(), $fromId, $fieldName) 481 || $isSpecialLanguageField 482 ) { 483 $this->modifyDataMap( 484 $item->getTableName(), 485 $item->getId(), 486 [$fieldName => $fromValue] 487 ); 488 return; 489 } 490 // resolve the language special table name 491 if ($isSpecialLanguageField) { 492 $specialTableName = 'sys_language'; 493 } 494 // fetch MM relations from storage 495 $type = $configuration['config']['type']; 496 $manyToManyTable = $configuration['config']['MM']; 497 if ($type === 'group' && $configuration['config']['internal_type'] === 'db') { 498 $tableNames = trim($configuration['config']['allowed'] ?? ''); 499 } elseif ($configuration['config']['type'] === 'select') { 500 $tableNames = ($specialTableName ?? $configuration['config']['foreign_table'] ?? ''); 501 } else { 502 return; 503 } 504 505 $relationHandler = $this->createRelationHandler(); 506 $relationHandler->start( 507 '', 508 $tableNames, 509 $manyToManyTable, 510 $fromId, 511 $item->getTableName(), 512 $configuration['config'] 513 ); 514 515 // provide list of relations, optionally prepended with table name 516 // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28" 517 $this->modifyDataMap( 518 $item->getTableName(), 519 $item->getId(), 520 [$fieldName => implode(',', $relationHandler->getValueArray())] 521 ); 522 } 523 524 /** 525 * Handle synchronization of inline relations. 526 * Inline Relational Record Editing ("IRRE") always is modelled as 1:n composite relation - which means that 527 * direct(!) children cannot exist without their parent. Removing a relative parent results in cascaded removal 528 * of all direct(!) children as well. 529 * 530 * @param DataMapItem $item 531 * @param string $fieldName 532 * @param array $fromRecord 533 * @param array $forRecord 534 * @throws \RuntimeException 535 */ 536 protected function synchronizeInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord) 537 { 538 $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName]; 539 $isLocalizationModeExclude = ($configuration['l10n_mode'] ?? null) === 'exclude'; 540 $foreignTableName = $configuration['config']['foreign_table']; 541 542 $fieldNames = [ 543 'language' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'] ?? null, 544 'parent' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['transOrigPointerField'] ?? null, 545 'source' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['translationSource'] ?? null, 546 ]; 547 $isTranslatable = (!empty($fieldNames['language']) && !empty($fieldNames['parent'])); 548 $isLocalized = !empty($item->getLanguage()); 549 550 $suggestedAncestorIds = $this->resolveSuggestedInlineRelations( 551 $item, 552 $fieldName, 553 $fromRecord 554 ); 555 $persistedIds = $this->resolvePersistedInlineRelations( 556 $item, 557 $fieldName, 558 $forRecord 559 ); 560 561 // The dependent ID map points from language parent/source record to 562 // localization, thus keys: parents/sources & values: localizations 563 $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds, $item->getLanguage()); 564 // filter incomplete structures - this is a drawback of DataHandler's remap stack, since 565 // just created IRRE translations still belong to the language parent - filter them out 566 $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap)); 567 // compile element differences to be resolved 568 // remove elements that are persisted at the language translation, but not required anymore 569 $removeIds = array_diff($persistedIds, array_values($dependentIdMap)); 570 // remove elements that are persisted at the language parent/source, but not required anymore 571 $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds); 572 // missing elements that are persisted at the language parent/source, but not translated yet 573 $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap)); 574 // persisted elements that should be copied or localized 575 $createAncestorIds = $this->filterNumericIds($missingAncestorIds); 576 // non-persisted elements that should be duplicated in data-map directly 577 $populateAncestorIds = array_diff($missingAncestorIds, $createAncestorIds); 578 // this desired state map defines the final result of child elements in their parent translation 579 $desiredIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds); 580 // update existing translations in the desired state map 581 foreach ($dependentIdMap as $ancestorId => $translationId) { 582 if (isset($desiredIdMap[$ancestorId])) { 583 $desiredIdMap[$ancestorId] = $translationId; 584 } 585 } 586 // no children to be synchronized, but element order could have been changed 587 if (empty($removeAncestorIds) && empty($missingAncestorIds)) { 588 $this->modifyDataMap( 589 $item->getTableName(), 590 $item->getId(), 591 [$fieldName => implode(',', array_values($desiredIdMap))] 592 ); 593 return; 594 } 595 // In case only missing elements shall be created, re-use previously sanitized 596 // values IF the relation parent item is new and the count of missing relations 597 // equals the count of previously sanitized relations. 598 // This is caused during copy processes, when the child relations 599 // already have been cloned in DataHandler::copyRecord_procBasedOnFieldType() 600 // without the possibility to resolve the initial connections at this point. 601 // Otherwise child relations would superfluously be duplicated again here. 602 // @todo Invalid manually injected child relations cannot be determined here 603 $sanitizedValue = $this->sanitizationMap[$item->getTableName()][$item->getId()][$fieldName] ?? null; 604 if ( 605 !empty($missingAncestorIds) && $item->isNew() && $sanitizedValue !== null 606 && count(GeneralUtility::trimExplode(',', $sanitizedValue, true)) === count($missingAncestorIds) 607 ) { 608 $this->modifyDataMap( 609 $item->getTableName(), 610 $item->getId(), 611 [$fieldName => $sanitizedValue] 612 ); 613 return; 614 } 615 616 $localCommandMap = []; 617 foreach ($removeIds as $removeId) { 618 $localCommandMap[$foreignTableName][$removeId]['delete'] = true; 619 } 620 foreach ($removeAncestorIds as $removeAncestorId) { 621 $removeId = $dependentIdMap[$removeAncestorId]; 622 $localCommandMap[$foreignTableName][$removeId]['delete'] = true; 623 } 624 foreach ($createAncestorIds as $createAncestorId) { 625 // if child table is not aware of localization, just copy 626 if ($isLocalizationModeExclude || !$isTranslatable) { 627 $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = [ 628 'target' => -$createAncestorId, 629 'ignoreLocalization' => true, 630 ]; 631 } else { 632 // otherwise, trigger the localization process 633 $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage(); 634 } 635 } 636 // execute copy, localize and delete actions on persisted child records 637 if (!empty($localCommandMap)) { 638 $localDataHandler = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater); 639 $localDataHandler->start([], $localCommandMap, $this->backendUser); 640 $localDataHandler->process_cmdmap(); 641 // update copied or localized ids 642 foreach ($createAncestorIds as $createAncestorId) { 643 if (empty($localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId])) { 644 $additionalInformation = ''; 645 if (!empty($localDataHandler->errorLog)) { 646 $additionalInformation = ', reason "' 647 . implode(', ', $localDataHandler->errorLog) . '"'; 648 } 649 throw new \RuntimeException( 650 'Child record was not processed' . $additionalInformation, 651 1486233164 652 ); 653 } 654 $newLocalizationId = $localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId]; 655 $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId; 656 $desiredIdMap[$createAncestorId] = $newLocalizationId; 657 // apply localization references to l10n_mode=exclude children 658 // (without keeping their reference to their origin, synchronization is not possible) 659 if ($isLocalizationModeExclude && $isTranslatable && $isLocalized) { 660 $adjustCopiedValues = $this->applyLocalizationReferences( 661 $foreignTableName, 662 $createAncestorId, 663 $item->getLanguage(), 664 $fieldNames, 665 [] 666 ); 667 $this->modifyDataMap( 668 $foreignTableName, 669 $newLocalizationId, 670 $adjustCopiedValues 671 ); 672 } 673 } 674 } 675 // populate new child records in data-map 676 if (!empty($populateAncestorIds)) { 677 foreach ($populateAncestorIds as $populateAncestorId) { 678 $newLocalizationId = StringUtility::getUniqueId('NEW'); 679 $desiredIdMap[$populateAncestorId] = $newLocalizationId; 680 $duplicatedValues = $this->allDataMap[$foreignTableName][$populateAncestorId] ?? []; 681 // applies localization references to given raw data-map item 682 if ($isTranslatable && $isLocalized) { 683 $duplicatedValues = $this->applyLocalizationReferences( 684 $foreignTableName, 685 $populateAncestorId, 686 $item->getLanguage(), 687 $fieldNames, 688 $duplicatedValues 689 ); 690 } 691 // prefixes language title if applicable for the accordant field name in raw data-map item 692 if ($isTranslatable && $isLocalized && !$isLocalizationModeExclude) { 693 $duplicatedValues = $this->prefixLanguageTitle( 694 $foreignTableName, 695 $populateAncestorId, 696 $item->getLanguage(), 697 $duplicatedValues 698 ); 699 } 700 $this->modifyDataMap( 701 $foreignTableName, 702 $newLocalizationId, 703 $duplicatedValues 704 ); 705 } 706 } 707 // update inline parent field references - required to update pointer fields 708 $this->modifyDataMap( 709 $item->getTableName(), 710 $item->getId(), 711 [$fieldName => implode(',', array_values($desiredIdMap))] 712 ); 713 } 714 715 /** 716 * Determines suggest inline relations of either translation parent or 717 * source record from data-map or storage in case records have been 718 * persisted already. 719 * 720 * @param DataMapItem $item 721 * @param string $fieldName 722 * @param array $fromRecord 723 * @return int[]|string[] 724 */ 725 protected function resolveSuggestedInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord): array 726 { 727 $suggestedAncestorIds = []; 728 $fromId = $fromRecord['uid']; 729 $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName]; 730 $foreignTableName = $configuration['config']['foreign_table']; 731 $manyToManyTable = ($configuration['config']['MM'] ?? ''); 732 733 // determine suggested elements of either translation parent or source record 734 // from data-map, in case the accordant language parent/source record was modified 735 if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) { 736 $suggestedAncestorIds = GeneralUtility::trimExplode( 737 ',', 738 $this->allDataMap[$item->getTableName()][$fromId][$fieldName], 739 true 740 ); 741 } elseif (MathUtility::canBeInterpretedAsInteger($fromId)) { 742 // determine suggested elements of either translation parent or source record from storage 743 $relationHandler = $this->createRelationHandler(); 744 $relationHandler->start( 745 $fromRecord[$fieldName], 746 $foreignTableName, 747 $manyToManyTable, 748 $fromId, 749 $item->getTableName(), 750 $configuration['config'] 751 ); 752 $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray); 753 } 754 755 return array_filter($suggestedAncestorIds); 756 } 757 758 /** 759 * Determine persisted inline relations for current data-map-item. 760 * 761 * @param DataMapItem $item 762 * @param string $fieldName 763 * @param array $forRecord 764 * @return int[] 765 */ 766 private function resolvePersistedInlineRelations(DataMapItem $item, string $fieldName, array $forRecord): array 767 { 768 $persistedIds = []; 769 $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName]; 770 $foreignTableName = $configuration['config']['foreign_table']; 771 $manyToManyTable = ($configuration['config']['MM'] ?? ''); 772 773 // determine persisted elements for the current data-map item 774 if (!$item->isNew()) { 775 $relationHandler = $this->createRelationHandler(); 776 $relationHandler->start( 777 $forRecord[$fieldName] ?? '', 778 $foreignTableName, 779 $manyToManyTable, 780 $item->getId(), 781 $item->getTableName(), 782 $configuration['config'] 783 ); 784 $persistedIds = $this->mapRelationItemId($relationHandler->itemArray); 785 } 786 787 return array_filter($persistedIds); 788 } 789 790 /** 791 * Determines whether a combination of table name, id and field name is 792 * set in data-map. This method considers null values as well, that would 793 * not be considered by a plain isset() invocation. 794 * 795 * @param string $tableName 796 * @param string|int $id 797 * @param string $fieldName 798 * @return bool 799 */ 800 protected function isSetInDataMap(string $tableName, $id, string $fieldName) 801 { 802 return 803 // directly look-up field name 804 isset($this->allDataMap[$tableName][$id][$fieldName]) 805 // check existence of field name as key for null values 806 || isset($this->allDataMap[$tableName][$id]) 807 && is_array($this->allDataMap[$tableName][$id]) 808 && array_key_exists($fieldName, $this->allDataMap[$tableName][$id]); 809 } 810 811 /** 812 * Applies modifications to the data-map, calling this method is essential 813 * to determine new data-map items to be process for synchronizing chained 814 * record localizations. 815 * 816 * @param string $tableName 817 * @param string|int $id 818 * @param array $values 819 * @throws \RuntimeException 820 */ 821 protected function modifyDataMap(string $tableName, $id, array $values) 822 { 823 // avoid superfluous iterations by data-map changes with values 824 // that actually have not been changed and were available already 825 $sameValues = array_intersect_assoc( 826 $this->allDataMap[$tableName][$id] ?? [], 827 $values 828 ); 829 if (!empty($sameValues)) { 830 $fieldNames = implode(', ', array_keys($sameValues)); 831 throw new \RuntimeException( 832 sprintf( 833 'Issued data-map change for table %s with same values ' 834 . 'for these fields names %s', 835 $tableName, 836 $fieldNames 837 ), 838 1488634845 839 ); 840 } 841 842 $this->modifiedDataMap[$tableName][$id] = array_merge( 843 $this->modifiedDataMap[$tableName][$id] ?? [], 844 $values 845 ); 846 $this->allDataMap[$tableName][$id] = array_merge( 847 $this->allDataMap[$tableName][$id] ?? [], 848 $values 849 ); 850 } 851 852 /** 853 * @param DataMapItem $item 854 */ 855 protected function addNextItem(DataMapItem $item) 856 { 857 $identifier = $item->getTableName() . ':' . $item->getId(); 858 if (!isset($this->allItems[$identifier])) { 859 $this->allItems[$identifier] = $item; 860 } 861 $this->nextItems[$identifier] = $item; 862 } 863 864 /** 865 * Fetches translation related field values for the items submitted in 866 * the data-map. 867 * 868 * @param string $tableName 869 * @param array $fieldNames 870 * @param array $ids 871 * @return array 872 */ 873 protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids) 874 { 875 if (empty($ids)) { 876 return []; 877 } 878 879 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 880 ->getQueryBuilderForTable($tableName); 881 $queryBuilder->getRestrictions() 882 ->removeAll() 883 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) 884 ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->backendUser->workspace)); 885 $statement = $queryBuilder 886 ->select(...array_values($fieldNames)) 887 ->from($tableName) 888 ->where( 889 $queryBuilder->expr()->in( 890 'uid', 891 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY) 892 ) 893 ) 894 ->execute(); 895 896 $translationValues = []; 897 foreach ($statement as $record) { 898 $translationValues[$record['uid']] = $record; 899 } 900 return $translationValues; 901 } 902 903 /** 904 * Fetches translation dependencies for a given parent/source record ids. 905 * 906 * Existing records in database: 907 * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0] 908 * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1] 909 * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2] 910 * 911 * Input $ids and their results: 912 * + [5] -> [DataMapItem(6), DataMapItem(7)] # since 5 is parent/source 913 * + [6] -> [DataMapItem(7)] # since 6 is source 914 * + [7] -> [] # since there's nothing 915 * 916 * @param string $tableName 917 * @param int[]|string[] $ids 918 * @return DataMapItem[][] 919 */ 920 protected function fetchDependencies(string $tableName, array $ids) 921 { 922 if (empty($ids) || !BackendUtility::isTableLocalizable($tableName)) { 923 return []; 924 } 925 926 $fieldNames = [ 927 'uid' => 'uid', 928 'l10n_state' => 'l10n_state', 929 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'], 930 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'], 931 ]; 932 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) { 933 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource']; 934 } 935 $fieldNamesMap = array_combine($fieldNames, $fieldNames); 936 937 $persistedIds = $this->filterNumericIds($ids); 938 $createdIds = array_diff($ids, $persistedIds); 939 $dependentElements = $this->fetchDependentElements($tableName, $persistedIds, $fieldNames); 940 941 foreach ($createdIds as $createdId) { 942 $data = $this->allDataMap[$tableName][$createdId] ?? null; 943 if ($data === null) { 944 continue; 945 } 946 $dependentElements[] = array_merge( 947 ['uid' => $createdId], 948 array_intersect_key($data, $fieldNamesMap) 949 ); 950 } 951 952 $dependencyMap = []; 953 foreach ($dependentElements as $dependentElement) { 954 $dependentItem = DataMapItem::build( 955 $tableName, 956 $dependentElement['uid'], 957 [], 958 $dependentElement, 959 $fieldNames 960 ); 961 962 if ($dependentItem->isDirectChildType()) { 963 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem; 964 } 965 if ($dependentItem->isGrandChildType()) { 966 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem; 967 $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem; 968 } 969 } 970 return $dependencyMap; 971 } 972 973 /** 974 * Fetches dependent records that depend on given record id's in in either 975 * their parent or source field for translatable tables or their origin 976 * field for non-translatable tables and creates an id mapping. 977 * 978 * This method expands the search criteria by expanding to ancestors. 979 * 980 * Existing records in database: 981 * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0] 982 * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1] 983 * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2] 984 * 985 * Input $ids and $desiredLanguage and their results: 986 * + $ids=[5], $lang=1 -> [5 => 6] # since 5 is source of 6 987 * + $ids=[5], $lang=2 -> [] # since 5 is parent of 7, but different language 988 * + $ids=[6], $lang=1 -> [] # since there's nothing 989 * + $ids=[6], $lang=2 -> [6 => 7] # since 6 has source 5, which is ancestor of 7 990 * + $ids=[7], $lang=* -> [] # since there's nothing 991 * 992 * @param string $tableName 993 * @param array $ids 994 * @param int $desiredLanguage 995 * @return array 996 */ 997 protected function fetchDependentIdMap(string $tableName, array $ids, int $desiredLanguage) 998 { 999 $ancestorIdMap = []; 1000 if (empty($ids)) { 1001 return []; 1002 } 1003 1004 $ids = $this->filterNumericIds($ids); 1005 $isTranslatable = BackendUtility::isTableLocalizable($tableName); 1006 $originFieldName = ($GLOBALS['TCA'][$tableName]['ctrl']['origUid'] ?? null); 1007 1008 if (!$isTranslatable && $originFieldName === null) { 1009 // @todo Possibly throw an error, since pointing to original entity is not possible (via origin/parent) 1010 return []; 1011 } 1012 1013 if ($isTranslatable) { 1014 $fieldNames = [ 1015 'uid' => 'uid', 1016 'l10n_state' => 'l10n_state', 1017 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'], 1018 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'], 1019 ]; 1020 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) { 1021 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource']; 1022 } 1023 } else { 1024 $fieldNames = [ 1025 'uid' => 'uid', 1026 'origin' => $originFieldName, 1027 ]; 1028 } 1029 1030 $fetchIds = $ids; 1031 if ($isTranslatable) { 1032 // expand search criteria via parent and source elements 1033 $translationValues = $this->fetchTranslationValues($tableName, $fieldNames, $ids); 1034 $ancestorIdMap = $this->buildElementAncestorIdMap($fieldNames, $translationValues); 1035 $fetchIds = array_unique(array_merge($ids, array_keys($ancestorIdMap))); 1036 } 1037 1038 $dependentElements = $this->fetchDependentElements($tableName, $fetchIds, $fieldNames); 1039 1040 $dependentIdMap = []; 1041 foreach ($dependentElements as $dependentElement) { 1042 $dependentId = $dependentElement['uid']; 1043 // implicit: use origin pointer if table cannot be translated 1044 if (!$isTranslatable) { 1045 $ancestorId = (int)$dependentElement[$fieldNames['origin']]; 1046 // only consider element if it reflects the desired language 1047 } elseif ((int)$dependentElement[$fieldNames['language']] === $desiredLanguage) { 1048 $ancestorId = $this->resolveAncestorId($fieldNames, $dependentElement); 1049 } else { 1050 // otherwise skip the element completely 1051 continue; 1052 } 1053 // only keep ancestors that were initially requested before expanding 1054 if (in_array($ancestorId, $ids, true)) { 1055 $dependentIdMap[$ancestorId] = $dependentId; 1056 } elseif (!empty($ancestorIdMap[$ancestorId])) { 1057 // resolve from previously expanded search criteria 1058 $possibleChainedIds = array_intersect( 1059 $ids, 1060 $ancestorIdMap[$ancestorId] 1061 ); 1062 if (!empty($possibleChainedIds)) { 1063 $ancestorId = $possibleChainedIds[0]; 1064 $dependentIdMap[$ancestorId] = $dependentId; 1065 } 1066 } 1067 } 1068 return $dependentIdMap; 1069 } 1070 1071 /** 1072 * Fetch all elements that depend on given record id's in either their 1073 * parent or source field for translatable tables or their origin field 1074 * for non-translatable tables. 1075 * 1076 * @param string $tableName 1077 * @param array $ids 1078 * @param array $fieldNames 1079 * @return array 1080 * @throws \InvalidArgumentException 1081 */ 1082 protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames) 1083 { 1084 if (empty($ids)) { 1085 return []; 1086 } 1087 1088 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1089 ->getQueryBuilderForTable($tableName); 1090 $queryBuilder->getRestrictions() 1091 ->removeAll() 1092 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) 1093 ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->backendUser->workspace)); 1094 1095 $zeroParameter = $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT); 1096 $ids = $this->filterNumericIds($ids); 1097 $idsParameter = $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY); 1098 1099 // fetch by language dependency 1100 if (!empty($fieldNames['language']) && !empty($fieldNames['parent'])) { 1101 $ancestorPredicates = [ 1102 $queryBuilder->expr()->in( 1103 $fieldNames['parent'], 1104 $idsParameter 1105 ) 1106 ]; 1107 if (!empty($fieldNames['source'])) { 1108 $ancestorPredicates[] = $queryBuilder->expr()->in( 1109 $fieldNames['source'], 1110 $idsParameter 1111 ); 1112 } 1113 $predicates = [ 1114 // must be any kind of localization 1115 $queryBuilder->expr()->gt( 1116 $fieldNames['language'], 1117 $zeroParameter 1118 ), 1119 // must be in connected mode 1120 $queryBuilder->expr()->gt( 1121 $fieldNames['parent'], 1122 $zeroParameter 1123 ), 1124 // any parent or source pointers 1125 $queryBuilder->expr()->orX(...$ancestorPredicates), 1126 ]; 1127 } elseif (!empty($fieldNames['origin'])) { 1128 // fetch by origin dependency ("copied from") 1129 $predicates = [ 1130 $queryBuilder->expr()->in( 1131 $fieldNames['origin'], 1132 $idsParameter 1133 ) 1134 ]; 1135 } else { 1136 // otherwise: stop execution 1137 throw new \InvalidArgumentException( 1138 'Invalid combination of query field names given', 1139 1487192370 1140 ); 1141 } 1142 1143 $statement = $queryBuilder 1144 ->select(...array_values($fieldNames)) 1145 ->from($tableName) 1146 ->andWhere(...$predicates) 1147 ->execute(); 1148 1149 $dependentElements = []; 1150 foreach ($statement as $record) { 1151 $dependentElements[] = $record; 1152 } 1153 return $dependentElements; 1154 } 1155 1156 /** 1157 * Return array of data map items that are of given type 1158 * 1159 * @param string $type 1160 * @param DataMapItem[] $items 1161 * @return DataMapItem[] 1162 */ 1163 protected function filterItemsByType(string $type, array $items) 1164 { 1165 return array_filter( 1166 $items, 1167 function (DataMapItem $item) use ($type) { 1168 return $item->getType() === $type; 1169 } 1170 ); 1171 } 1172 1173 /** 1174 * Return only ids that are integer - so no "NEW..." values 1175 * 1176 * @param string[]|int[] $ids 1177 * @return int[] 1178 */ 1179 protected function filterNumericIds(array $ids) 1180 { 1181 $ids = array_filter( 1182 $ids, 1183 function ($id) { 1184 return MathUtility::canBeInterpretedAsInteger($id); 1185 } 1186 ); 1187 return array_map('intval', $ids); 1188 } 1189 1190 /** 1191 * Return only ids that don't have an item equivalent in $this->allItems. 1192 * 1193 * @param string $tableName 1194 * @param int[] $ids 1195 * @return array 1196 */ 1197 protected function filterNewItemIds(string $tableName, array $ids) 1198 { 1199 return array_filter( 1200 $ids, 1201 function ($id) use ($tableName) { 1202 return $this->findItem($tableName, $id) === null; 1203 } 1204 ); 1205 } 1206 1207 /** 1208 * Flatten array 1209 * 1210 * @param array $relationItems 1211 * @return string[] 1212 */ 1213 protected function mapRelationItemId(array $relationItems) 1214 { 1215 return array_map( 1216 function (array $relationItem) { 1217 return (int)$relationItem['id']; 1218 }, 1219 $relationItems 1220 ); 1221 } 1222 1223 /** 1224 * @param array<string, string> $fieldNames 1225 * @param array<string, mixed> $element 1226 * @return int|null either a (non-empty) ancestor uid, or `null` if unresolved 1227 */ 1228 protected function resolveAncestorId(array $fieldNames, array $element) 1229 { 1230 $sourceName = $fieldNames['source'] ?? null; 1231 if ($sourceName !== null && !empty($element[$sourceName])) { 1232 // implicit: use source pointer if given (not empty) 1233 return (int)$element[$sourceName]; 1234 } 1235 $parentName = $fieldNames['parent'] ?? null; 1236 if ($parentName !== null && !empty($element[$parentName])) { 1237 // implicit: use parent pointer if given (not empty) 1238 return (int)$element[$parentName]; 1239 } 1240 return null; 1241 } 1242 1243 /** 1244 * Builds a map from ancestor ids to accordant localization dependents. 1245 * 1246 * The result of e.g. [5 => [6, 7]] refers to ids 6 and 7 being dependents 1247 * (either used in parent or source field) of the ancestor with id 5. 1248 * 1249 * @param array $fieldNames 1250 * @param array $elements 1251 * @return array 1252 */ 1253 protected function buildElementAncestorIdMap(array $fieldNames, array $elements) 1254 { 1255 $ancestorIdMap = []; 1256 foreach ($elements as $element) { 1257 $ancestorId = $this->resolveAncestorId($fieldNames, $element); 1258 if ($ancestorId !== null) { 1259 $ancestorIdMap[$ancestorId][] = (int)$element['uid']; 1260 } 1261 } 1262 return $ancestorIdMap; 1263 } 1264 1265 /** 1266 * See if an items is in item list and return it 1267 * 1268 * @param string $tableName 1269 * @param string|int $id 1270 * @return DataMapItem|null 1271 */ 1272 protected function findItem(string $tableName, $id) 1273 { 1274 return $this->allItems[$tableName . ':' . $id] ?? null; 1275 } 1276 1277 /** 1278 * Duplicates an item from data-map and prefixes language title, 1279 * if applicable for the accordant field name. 1280 * 1281 * @param string $tableName 1282 * @param string|int $fromId 1283 * @param int $language 1284 * @param array $fieldNames 1285 * @param bool $localize 1286 * @return array 1287 * @deprecated Not used anymore, split into applyLocalizationReferences() and prefixLanguageTitle() 1288 */ 1289 protected function duplicateFromDataMap(string $tableName, $fromId, int $language, array $fieldNames, bool $localize): array 1290 { 1291 $data = $this->allDataMap[$tableName][$fromId] ?? []; 1292 // just return if localization cannot be applied 1293 if (empty($language) || !$localize) { 1294 return $data; 1295 } 1296 $data = $this->applyLocalizationReferences($tableName, $fromId, $language, $fieldNames, $data); 1297 $data = $this->prefixLanguageTitle($tableName, $fromId, $language, $data); 1298 return $data; 1299 } 1300 1301 /** 1302 * Applies localization references to given raw data-map item. 1303 * 1304 * @param string $tableName 1305 * @param string|int $fromId 1306 * @param int $language 1307 * @param array $fieldNames 1308 * @param array $data 1309 * @return array 1310 */ 1311 protected function applyLocalizationReferences(string $tableName, $fromId, int $language, array $fieldNames, array $data): array 1312 { 1313 // just return if localization cannot be applied 1314 if (empty($language)) { 1315 return $data; 1316 } 1317 1318 // apply `languageField`, e.g. `sys_language_uid` 1319 $data[$fieldNames['language']] = $language; 1320 // apply `transOrigPointerField`, e.g. `l10n_parent` 1321 if (empty($data[$fieldNames['parent']])) { 1322 // @todo Only $id used in TCA type 'select' is resolved in DataHandler's remapStack 1323 $data[$fieldNames['parent']] = $fromId; 1324 } 1325 // apply `translationSource`, e.g. `l10n_source` 1326 if (!empty($fieldNames['source'])) { 1327 // @todo Not sure, whether $id is resolved in DataHandler's remapStack 1328 $data[$fieldNames['source']] = $fromId; 1329 } 1330 // unset field names that are expected to be handled in this processor 1331 foreach ($this->getFieldNamesToBeHandled($tableName) as $fieldName) { 1332 unset($data[$fieldName]); 1333 } 1334 1335 return $data; 1336 } 1337 1338 /** 1339 * Prefixes language title if applicable for the accordant field name in raw data-map item. 1340 * 1341 * @param string $tableName 1342 * @param string|int $fromId 1343 * @param int $language 1344 * @param array $data 1345 * @return array 1346 */ 1347 protected function prefixLanguageTitle(string $tableName, $fromId, int $language, array $data): array 1348 { 1349 $prefix = ''; 1350 $prefixFieldNames = array_intersect( 1351 array_keys($data), 1352 $this->getPrefixLanguageTitleFieldNames($tableName) 1353 ); 1354 if (empty($prefixFieldNames)) { 1355 return $data; 1356 } 1357 1358 $languageService = $this->getLanguageService(); 1359 $languageRecord = BackendUtility::getRecord('sys_language', $language, 'title'); 1360 [$pageId] = BackendUtility::getTSCpid($tableName, $fromId, $data['pid'] ?? null); 1361 1362 $tsConfigTranslateToMessage = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.']['translateToMessage'] ?? ''; 1363 if (!empty($tsConfigTranslateToMessage)) { 1364 $prefix = $tsConfigTranslateToMessage; 1365 if ($languageService !== null) { 1366 $prefix = $languageService->sL($prefix); 1367 } 1368 $prefix = sprintf($prefix, $languageRecord['title']); 1369 } 1370 if (empty($prefix)) { 1371 $prefix = 'Translate to ' . $languageRecord['title'] . ':'; 1372 } 1373 1374 foreach ($prefixFieldNames as $prefixFieldName) { 1375 // @todo The hook in DataHandler is not applied here 1376 $data[$prefixFieldName] = '[' . $prefix . '] ' . $data[$prefixFieldName]; 1377 } 1378 1379 return $data; 1380 } 1381 1382 /** 1383 * Field names we have to deal with 1384 * 1385 * @param DataMapItem $item 1386 * @param string $scope 1387 * @param bool $modified 1388 * @return string[] 1389 */ 1390 protected function getFieldNamesForItemScope( 1391 DataMapItem $item, 1392 string $scope, 1393 bool $modified 1394 ) { 1395 if ( 1396 $scope === DataMapItem::SCOPE_PARENT 1397 || $scope === DataMapItem::SCOPE_SOURCE 1398 ) { 1399 if (!State::isApplicable($item->getTableName())) { 1400 return []; 1401 } 1402 return $item->getState()->filterFieldNames($scope, $modified); 1403 } 1404 if ($scope === DataMapItem::SCOPE_EXCLUDE) { 1405 return $this->getLocalizationModeExcludeFieldNames( 1406 $item->getTableName() 1407 ); 1408 } 1409 return []; 1410 } 1411 1412 /** 1413 * Field names of TCA table with columns having l10n_mode=exclude 1414 * 1415 * @param string $tableName 1416 * @return string[] 1417 */ 1418 protected function getLocalizationModeExcludeFieldNames(string $tableName) 1419 { 1420 $localizationExcludeFieldNames = []; 1421 if (empty($GLOBALS['TCA'][$tableName]['columns'])) { 1422 return $localizationExcludeFieldNames; 1423 } 1424 1425 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) { 1426 if (($configuration['l10n_mode'] ?? null) === 'exclude' 1427 && ($configuration['config']['type'] ?? null) !== 'none' 1428 ) { 1429 $localizationExcludeFieldNames[] = $fieldName; 1430 } 1431 } 1432 1433 return $localizationExcludeFieldNames; 1434 } 1435 1436 /** 1437 * Gets a list of field names which have to be handled. Basically this 1438 * includes fields using allowLanguageSynchronization or l10n_mode=exclude. 1439 * 1440 * @param string $tableName 1441 * @return string[] 1442 */ 1443 protected function getFieldNamesToBeHandled(string $tableName) 1444 { 1445 return array_merge( 1446 State::getFieldNames($tableName), 1447 $this->getLocalizationModeExcludeFieldNames($tableName) 1448 ); 1449 } 1450 1451 /** 1452 * Field names of TCA table with columns having l10n_mode=prefixLangTitle 1453 * 1454 * @param string $tableName 1455 * @return array 1456 */ 1457 protected function getPrefixLanguageTitleFieldNames(string $tableName) 1458 { 1459 $prefixLanguageTitleFieldNames = []; 1460 if (empty($GLOBALS['TCA'][$tableName]['columns'])) { 1461 return $prefixLanguageTitleFieldNames; 1462 } 1463 1464 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) { 1465 $type = $configuration['config']['type'] ?? null; 1466 if ( 1467 ($configuration['l10n_mode'] ?? null) === 'prefixLangTitle' 1468 && ($type === 'input' || $type === 'text') 1469 ) { 1470 $prefixLanguageTitleFieldNames[] = $fieldName; 1471 } 1472 } 1473 1474 return $prefixLanguageTitleFieldNames; 1475 } 1476 1477 /** 1478 * True if we're dealing with a field that has foreign db relations 1479 * 1480 * @param string $tableName 1481 * @param string $fieldName 1482 * @return bool True if field is type=group with internalType === db or select with foreign_table 1483 */ 1484 protected function isRelationField(string $tableName, string $fieldName): bool 1485 { 1486 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) { 1487 return false; 1488 } 1489 1490 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']; 1491 1492 return 1493 $configuration['type'] === 'group' 1494 && ($configuration['internal_type'] ?? null) === 'db' 1495 && !empty($configuration['allowed']) 1496 || $configuration['type'] === 'select' 1497 && ( 1498 !empty($configuration['foreign_table']) 1499 && !empty($GLOBALS['TCA'][$configuration['foreign_table']]) 1500 || ($configuration['special'] ?? null) === 'languages' 1501 ) 1502 || $this->isInlineRelationField($tableName, $fieldName) 1503 ; 1504 } 1505 1506 /** 1507 * True if we're dealing with an inline field 1508 * 1509 * @param string $tableName 1510 * @param string $fieldName 1511 * @return bool TRUE if field is of type inline with foreign_table set 1512 */ 1513 protected function isInlineRelationField(string $tableName, string $fieldName): bool 1514 { 1515 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) { 1516 return false; 1517 } 1518 1519 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']; 1520 1521 return 1522 $configuration['type'] === 'inline' 1523 && !empty($configuration['foreign_table']) 1524 && !empty($GLOBALS['TCA'][$configuration['foreign_table']]) 1525 ; 1526 } 1527 1528 /** 1529 * Determines whether the table can be localized and either has fields 1530 * with allowLanguageSynchronization enabled or l10n_mode set to exclude. 1531 * 1532 * @param string $tableName 1533 * @return bool 1534 */ 1535 protected function isApplicable(string $tableName): bool 1536 { 1537 return 1538 State::isApplicable($tableName) 1539 || BackendUtility::isTableLocalizable($tableName) 1540 && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0 1541 ; 1542 } 1543 1544 /** 1545 * @return RelationHandler 1546 */ 1547 protected function createRelationHandler() 1548 { 1549 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); 1550 $relationHandler->setWorkspaceId($this->backendUser->workspace); 1551 return $relationHandler; 1552 } 1553 1554 /** 1555 * @return LanguageService|null 1556 */ 1557 protected function getLanguageService() 1558 { 1559 return $GLOBALS['LANG'] ?? null; 1560 } 1561} 1562