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\Backend\Form\FormDataProvider; 17 18use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException; 19use TYPO3\CMS\Backend\Form\FormDataCompiler; 20use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly; 21use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord; 22use TYPO3\CMS\Backend\Form\FormDataProviderInterface; 23use TYPO3\CMS\Backend\Form\InlineStackProcessor; 24use TYPO3\CMS\Backend\Utility\BackendUtility; 25use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; 26use TYPO3\CMS\Core\Database\RelationHandler; 27use TYPO3\CMS\Core\Localization\LanguageService; 28use TYPO3\CMS\Core\Messaging\FlashMessage; 29use TYPO3\CMS\Core\Messaging\FlashMessageService; 30use TYPO3\CMS\Core\Utility\GeneralUtility; 31use TYPO3\CMS\Core\Utility\MathUtility; 32use TYPO3\CMS\Core\Versioning\VersionState; 33 34/** 35 * Resolve and prepare inline data. 36 */ 37class TcaInline extends AbstractDatabaseRecordProvider implements FormDataProviderInterface 38{ 39 /** 40 * Resolve inline fields 41 * 42 * @param array $result 43 * @return array 44 */ 45 public function addData(array $result) 46 { 47 $result = $this->addInlineFirstPid($result); 48 49 foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) { 50 if (!$this->isInlineField($fieldConfig)) { 51 continue; 52 } 53 $result['processedTca']['columns'][$fieldName]['children'] = []; 54 if (!$this->isUserAllowedToModify($fieldConfig)) { 55 continue; 56 } 57 if ($result['inlineResolveExistingChildren']) { 58 $result = $this->resolveRelatedRecords($result, $fieldName); 59 $result = $this->addForeignSelectorAndUniquePossibleRecords($result, $fieldName); 60 } 61 } 62 63 return $result; 64 } 65 66 /** 67 * Is column of type "inline" 68 * 69 * @param array $fieldConfig 70 * @return bool 71 */ 72 protected function isInlineField($fieldConfig) 73 { 74 return !empty($fieldConfig['config']['type']) && $fieldConfig['config']['type'] === 'inline'; 75 } 76 77 /** 78 * Is user allowed to modify child elements 79 * 80 * @param array $fieldConfig 81 * @return bool 82 */ 83 protected function isUserAllowedToModify($fieldConfig) 84 { 85 return $this->getBackendUser()->check('tables_modify', $fieldConfig['config']['foreign_table']); 86 } 87 88 /** 89 * The "entry" pid for inline records. Nested inline records can potentially hang around on different 90 * pid's, but the entry pid is needed for AJAX calls, so that they would know where the action takes place on the page structure. 91 * 92 * @param array $result Incoming result 93 * @return array Modified result 94 * @todo: Find out when and if this is different from 'effectivePid' 95 */ 96 protected function addInlineFirstPid(array $result) 97 { 98 if ($result['inlineFirstPid'] === null) { 99 $table = $result['tableName']; 100 $row = $result['databaseRow']; 101 // If the parent is a page, use the uid(!) of the (new?) page as pid for the child records: 102 if ($table === 'pages') { 103 $liveVersionId = BackendUtility::getLiveVersionIdOfRecord('pages', $row['uid']); 104 $pid = $liveVersionId ?? $row['uid']; 105 } elseif (($row['pid'] ?? 0) < 0) { 106 $prevRec = BackendUtility::getRecord($table, (int)abs($row['pid'])); 107 $pid = $prevRec['pid']; 108 } else { 109 $pid = $row['pid'] ?? 0; 110 } 111 if (MathUtility::canBeInterpretedAsInteger($pid)) { 112 $pageRecord = BackendUtility::getRecord('pages', (int)$pid); 113 if ((int)$pageRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] > 0) { 114 $pid = (int)$pageRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']]; 115 } 116 } elseif (strpos($pid, 'NEW') !== 0) { 117 throw new \RuntimeException( 118 'inlineFirstPid should either be an integer or a "NEW..." string', 119 1521220142 120 ); 121 } 122 $result['inlineFirstPid'] = $pid; 123 } 124 return $result; 125 } 126 127 /** 128 * Substitute the value in databaseRow of this inline field with an array 129 * that contains the databaseRows of currently connected records and some meta information. 130 * 131 * @param array $result Result array 132 * @param string $fieldName Current handle field name 133 * @return array Modified item array 134 */ 135 protected function resolveRelatedRecordsOverlays(array $result, $fieldName) 136 { 137 $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table']; 138 139 $connectedUidsOfLocalizedOverlay = []; 140 if ($result['command'] === 'edit') { 141 $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids( 142 $result['processedTca']['columns'][$fieldName]['config'], 143 $result['tableName'], 144 $result['databaseRow']['uid'], 145 $result['databaseRow'][$fieldName] 146 ); 147 } 148 $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay); 149 $connectedUidsOfLocalizedOverlay = $this->getWorkspacedUids($connectedUidsOfLocalizedOverlay, $childTableName); 150 if ($result['inlineCompileExistingChildren']) { 151 $tableNameWithDefaultRecords = $result['tableName']; 152 $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids( 153 $result['processedTca']['columns'][$fieldName]['config'], 154 $tableNameWithDefaultRecords, 155 $result['defaultLanguageRow']['uid'], 156 $result['defaultLanguageRow'][$fieldName] 157 ); 158 $connectedUidsOfDefaultLanguageRecord = $this->getWorkspacedUids($connectedUidsOfDefaultLanguageRecord, $childTableName); 159 160 $showPossible = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords']; 161 162 // Find which records are localized, which records are not localized and which are 163 // localized but miss default language record 164 $fieldNameWithDefaultLanguageUid = $GLOBALS['TCA'][$childTableName]['ctrl']['transOrigPointerField']; 165 foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) { 166 try { 167 $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid); 168 } catch (DatabaseRecordException $e) { 169 // The child could not be compiled, probably it was deleted and a dangling mm record exists 170 $this->logger->warning( 171 $e->getMessage(), 172 [ 173 'table' => $childTableName, 174 'uid' => $localizedUid, 175 'exception' => $e 176 ] 177 ); 178 continue; 179 } 180 $uidOfDefaultLanguageRecord = $localizedRecord[$fieldNameWithDefaultLanguageUid]; 181 if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord)) { 182 // This localized child has a default language record. Remove this record from list of default language records 183 $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]); 184 } 185 // Compile localized record 186 $compiledChild = $this->compileChild($result, $fieldName, $localizedUid); 187 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; 188 } 189 if ($showPossible) { 190 foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) { 191 // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that 192 // exist in default language, but are not localized yet. Compile and mark those 193 try { 194 $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid); 195 } catch (DatabaseRecordException $e) { 196 // The child could not be compiled, probably it was deleted and a dangling mm record exists 197 $this->logger->warning( 198 $e->getMessage(), 199 [ 200 'table' => $childTableName, 201 'uid' => $defaultLanguageUid, 202 'exception' => $e 203 ] 204 ); 205 continue; 206 } 207 $compiledChild['isInlineDefaultLanguageRecordInLocalizedParentContext'] = true; 208 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; 209 } 210 } 211 } 212 213 return $result; 214 } 215 216 /** 217 * Substitute the value in databaseRow of this inline field with an array 218 * that contains the databaseRows of currently connected records and some meta information. 219 * 220 * @param array $result Result array 221 * @param string $fieldName Current handle field name 222 * @return array Modified item array 223 */ 224 protected function resolveRelatedRecords(array $result, $fieldName) 225 { 226 if ($result['defaultLanguageRow'] !== null) { 227 return $this->resolveRelatedRecordsOverlays($result, $fieldName); 228 } 229 230 $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table']; 231 $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids( 232 $result['processedTca']['columns'][$fieldName]['config'], 233 $result['tableName'], 234 $result['databaseRow']['uid'], 235 $result['databaseRow'][$fieldName] 236 ); 237 $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfDefaultLanguageRecord); 238 239 $connectedUidsOfDefaultLanguageRecord = $this->getWorkspacedUids($connectedUidsOfDefaultLanguageRecord, $childTableName); 240 241 if ($result['inlineCompileExistingChildren']) { 242 foreach ($connectedUidsOfDefaultLanguageRecord as $uid) { 243 try { 244 $compiledChild = $this->compileChild($result, $fieldName, $uid); 245 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild; 246 } catch (DatabaseRecordException $e) { 247 // Nothing to do here, missing child is just not being rendered. 248 } 249 } 250 } 251 return $result; 252 } 253 254 /** 255 * If there is a foreign_selector or foreign_unique configuration, fetch 256 * the list of possible records that can be connected and attach the to the 257 * inline configuration. 258 * 259 * @param array $result Result array 260 * @param string $fieldName Current handle field name 261 * @return array Modified item array 262 */ 263 protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName) 264 { 265 if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'])) { 266 return $result; 267 } 268 269 $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration']; 270 $foreignFieldName = $selectorOrUniqueConfiguration['fieldName']; 271 $selectorOrUniquePossibleRecords = []; 272 273 if ($selectorOrUniqueConfiguration['config']['type'] === 'select') { 274 // Compile child table data for this field only 275 $selectDataInput = [ 276 'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'], 277 'command' => 'new', 278 // Since there is no existing record that may have a type, it does not make sense to 279 // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is 280 'pageTsConfig' => $result['pageTsConfig'], 281 'userTsConfig' => $result['userTsConfig'], 282 'databaseRow' => $result['databaseRow'], 283 'processedTca' => [ 284 'ctrl' => [], 285 'columns' => [ 286 $foreignFieldName => [ 287 'config' => $selectorOrUniqueConfiguration['config'], 288 ], 289 ], 290 ], 291 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'], 292 ]; 293 /** @var OnTheFly $formDataGroup */ 294 $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class); 295 $formDataGroup->setProviderList([TcaSelectItems::class]); 296 /** @var FormDataCompiler $formDataCompiler */ 297 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup); 298 $compilerResult = $formDataCompiler->compile($selectDataInput); 299 $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items']; 300 } 301 302 $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords; 303 304 return $result; 305 } 306 307 /** 308 * Compile a full child record 309 * 310 * @param array $result Result array of parent 311 * @param string $parentFieldName Name of parent field 312 * @param int $childUid Uid of child to compile 313 * @return array Full result array 314 */ 315 protected function compileChild(array $result, $parentFieldName, $childUid) 316 { 317 $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config']; 318 $childTableName = $parentConfig['foreign_table']; 319 320 /** @var InlineStackProcessor $inlineStackProcessor */ 321 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class); 322 $inlineStackProcessor->initializeByGivenStructure($result['inlineStructure']); 323 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0); 324 325 /** @var TcaDatabaseRecord $formDataGroup */ 326 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class); 327 /** @var FormDataCompiler $formDataCompiler */ 328 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup); 329 $formDataCompilerInput = [ 330 'command' => 'edit', 331 'tableName' => $childTableName, 332 'vanillaUid' => (int)$childUid, 333 // Give incoming returnUrl down to children so they generate a returnUrl back to 334 // the originally opening record, also see "originalReturnUrl" in inline container 335 // and FormInlineAjaxController 336 'returnUrl' => $result['returnUrl'], 337 'isInlineChild' => true, 338 'inlineStructure' => $result['inlineStructure'], 339 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'], 340 'inlineFirstPid' => $result['inlineFirstPid'], 341 'inlineParentConfig' => $parentConfig, 342 343 // values of the current parent element 344 // it is always a string either an id or new... 345 'inlineParentUid' => $result['databaseRow']['uid'], 346 'inlineParentTableName' => $result['tableName'], 347 'inlineParentFieldName' => $parentFieldName, 348 349 // values of the top most parent element set on first level and not overridden on following levels 350 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'], 351 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'], 352 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'], 353 ]; 354 355 // For foreign_selector with useCombination $mainChild is the mm record 356 // and $combinationChild is the child-child. For 1:n "normal" relations, 357 // $mainChild is just the normal child record and $combinationChild is empty. 358 $mainChild = $formDataCompiler->compile($formDataCompilerInput); 359 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) { 360 try { 361 $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig); 362 } catch (DatabaseRecordException $e) { 363 // The child could not be compiled, probably it was deleted and a dangling mm record 364 // exists. This is a data inconsistency, we catch this exception and create a flash message 365 $message = vsprintf( 366 $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'), 367 [$e->getTableName(), $e->getUid(), $childTableName, (int)$childUid] 368 ); 369 $flashMessage = GeneralUtility::makeInstance( 370 FlashMessage::class, 371 $message, 372 '', 373 FlashMessage::ERROR 374 ); 375 GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier()->enqueue($flashMessage); 376 } 377 } 378 return $mainChild; 379 } 380 381 /** 382 * With useCombination set, not only content of the intermediate table, but also 383 * the connected child should be rendered in one go. Prepare this here. 384 * 385 * @param array $child Full data array of "mm" record 386 * @param array $parentConfig TCA configuration of "parent" 387 * @return array Full data array of child 388 */ 389 protected function compileChildChild(array $child, array $parentConfig) 390 { 391 // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already 392 $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0]; 393 // child-child table name is set in child tca "the selector field" foreign_table 394 $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table']; 395 /** @var TcaDatabaseRecord $formDataGroup */ 396 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class); 397 /** @var FormDataCompiler $formDataCompiler */ 398 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup); 399 400 $formDataCompilerInput = [ 401 'command' => 'edit', 402 'tableName' => $childChildTableName, 403 'vanillaUid' => (int)$childChildUid, 404 'isInlineChild' => true, 405 'isInlineChildExpanded' => $child['isInlineChildExpanded'], 406 // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too? 407 'inlineStructure' => $child['inlineStructure'], 408 'inlineFirstPid' => $child['inlineFirstPid'], 409 // values of the top most parent element set on first level and not overridden on following levels 410 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'], 411 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'], 412 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'], 413 ]; 414 $childChild = $formDataCompiler->compile($formDataCompilerInput); 415 return $childChild; 416 } 417 418 /** 419 * Substitute given list of uids in child table with workspace uid if needed 420 * 421 * @param array $connectedUids List of connected uids 422 * @param string $childTableName Name of child table 423 * @return array List of uids in workspace 424 */ 425 protected function getWorkspacedUids(array $connectedUids, $childTableName) 426 { 427 $backendUser = $this->getBackendUser(); 428 $newConnectedUids = []; 429 foreach ($connectedUids as $uid) { 430 // Fetch workspace version of a record (if any): 431 // @todo: Needs handling 432 if ($backendUser->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($childTableName)) { 433 $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state'); 434 if (!empty($workspaceVersion)) { 435 $versionState = VersionState::cast($workspaceVersion['t3ver_state']); 436 if ($versionState->equals(VersionState::DELETE_PLACEHOLDER)) { 437 continue; 438 } 439 $uid = $workspaceVersion['uid']; 440 } 441 } 442 $newConnectedUids[] = $uid; 443 } 444 return $newConnectedUids; 445 } 446 447 /** 448 * Use RelationHandler to resolve connected uids. 449 * 450 * @param array $parentConfig TCA config section of parent 451 * @param string $parentTableName Name of parent table 452 * @param int $parentUid Uid of parent record 453 * @param string $parentFieldValue Database value of parent record of this inline field 454 * @return array Array with connected uids 455 * @todo: Cover with unit tests 456 */ 457 protected function resolveConnectedRecordUids(array $parentConfig, $parentTableName, $parentUid, $parentFieldValue) 458 { 459 $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue); 460 if (empty($parentConfig['MM'])) { 461 $parentUid = $this->getLiveDefaultId($parentTableName, $parentUid); 462 } 463 /** @var RelationHandler $relationHandler */ 464 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); 465 $relationHandler->registerNonTableValues = (bool)$parentConfig['allowedIdValues']; 466 $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'], $parentConfig['MM'], $parentUid, $parentTableName, $parentConfig); 467 $foreignRecordUids = $relationHandler->getValueArray(); 468 $resolvedForeignRecordUids = []; 469 foreach ($foreignRecordUids as $aForeignRecordUid) { 470 if ($parentConfig['MM'] || $parentConfig['foreign_field']) { 471 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid; 472 } else { 473 foreach ($directlyConnectedIds as $id) { 474 if ((int)$aForeignRecordUid === (int)$id) { 475 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid; 476 } 477 } 478 } 479 } 480 return $resolvedForeignRecordUids; 481 } 482 483 /** 484 * Gets the record uid of the live default record. If already 485 * pointing to the live record, the submitted record uid is returned. 486 * 487 * @param string $tableName 488 * @param int $uid 489 * @return int 490 * @todo: the workspace mess still must be resolved somehow 491 */ 492 protected function getLiveDefaultId($tableName, $uid) 493 { 494 $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $uid); 495 if ($liveDefaultId === null) { 496 $liveDefaultId = $uid; 497 } 498 return $liveDefaultId; 499 } 500 501 /** 502 * @return BackendUserAuthentication 503 */ 504 protected function getBackendUser() 505 { 506 return $GLOBALS['BE_USER']; 507 } 508 509 /** 510 * @return LanguageService 511 */ 512 protected function getLanguageService() 513 { 514 return $GLOBALS['LANG']; 515 } 516} 517