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\Workspaces\Hook; 17 18use Doctrine\DBAL\Exception as DBALException; 19use Doctrine\DBAL\Platforms\PostgreSQL94Platform as PostgreSQLPlatform; 20use Doctrine\DBAL\Platforms\SQLServer2012Platform as SQLServerPlatform; 21use TYPO3\CMS\Backend\Utility\BackendUtility; 22use TYPO3\CMS\Core\Cache\CacheManager; 23use TYPO3\CMS\Core\Context\Context; 24use TYPO3\CMS\Core\Context\WorkspaceAspect; 25use TYPO3\CMS\Core\Database\Connection; 26use TYPO3\CMS\Core\Database\ConnectionPool; 27use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; 28use TYPO3\CMS\Core\Database\RelationHandler; 29use TYPO3\CMS\Core\DataHandling\DataHandler; 30use TYPO3\CMS\Core\Localization\LanguageService; 31use TYPO3\CMS\Core\SysLog\Action\Database as DatabaseAction; 32use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification; 33use TYPO3\CMS\Core\Type\Bitmask\Permission; 34use TYPO3\CMS\Core\Utility\ArrayUtility; 35use TYPO3\CMS\Core\Utility\GeneralUtility; 36use TYPO3\CMS\Core\Versioning\VersionState; 37use TYPO3\CMS\Workspaces\DataHandler\CommandMap; 38use TYPO3\CMS\Workspaces\Notification\StageChangeNotification; 39use TYPO3\CMS\Workspaces\Service\StagesService; 40use TYPO3\CMS\Workspaces\Service\WorkspaceService; 41 42/** 43 * Contains some parts for staging, versioning and workspaces 44 * to interact with the TYPO3 Core Engine 45 * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. 46 */ 47class DataHandlerHook 48{ 49 /** 50 * For accumulating information about workspace stages raised 51 * on elements so a single mail is sent as notification. 52 * 53 * @var array 54 */ 55 protected $notificationEmailInfo = []; 56 57 /** 58 * Contains remapped IDs. 59 * 60 * @var array 61 */ 62 protected $remappedIds = []; 63 64 /**************************** 65 ***** Cmdmap Hooks ****** 66 ****************************/ 67 /** 68 * hook that is called before any cmd of the commandmap is executed 69 * 70 * @param DataHandler $dataHandler reference to the main DataHandler object 71 */ 72 public function processCmdmap_beforeStart(DataHandler $dataHandler) 73 { 74 // Reset notification array 75 $this->notificationEmailInfo = []; 76 // Resolve dependencies of version/workspaces actions: 77 $dataHandler->cmdmap = $this->getCommandMap($dataHandler)->process()->get(); 78 } 79 80 /** 81 * hook that is called when no prepared command was found 82 * 83 * @param string $command the command to be executed 84 * @param string $table the table of the record 85 * @param int $id the ID of the record 86 * @param mixed $value the value containing the data 87 * @param bool $commandIsProcessed can be set so that other hooks or 88 * @param DataHandler $dataHandler reference to the main DataHandler object 89 */ 90 public function processCmdmap($command, $table, $id, $value, &$commandIsProcessed, DataHandler $dataHandler) 91 { 92 // custom command "version" 93 if ($command !== 'version') { 94 return; 95 } 96 $commandIsProcessed = true; 97 $action = (string)$value['action']; 98 $comment = $value['comment'] ?? ''; 99 $notificationAlternativeRecipients = $value['notificationAlternativeRecipients'] ?? []; 100 switch ($action) { 101 case 'new': 102 $dataHandler->versionizeRecord($table, $id, $value['label']); 103 break; 104 case 'swap': 105 case 'publish': 106 $this->version_swap( 107 $table, 108 $id, 109 $value['swapWith'], 110 $dataHandler, 111 $comment, 112 $notificationAlternativeRecipients 113 ); 114 break; 115 case 'clearWSID': 116 case 'flush': 117 $dataHandler->discard($table, (int)$id); 118 break; 119 case 'setStage': 120 $elementIds = GeneralUtility::intExplode(',', (string)$id, true); 121 foreach ($elementIds as $elementId) { 122 $this->version_setStage( 123 $table, 124 $elementId, 125 $value['stageId'], 126 $comment, 127 $dataHandler, 128 $notificationAlternativeRecipients 129 ); 130 } 131 break; 132 default: 133 // Do nothing 134 } 135 } 136 137 /** 138 * hook that is called AFTER all commands of the commandmap was 139 * executed 140 * 141 * @param DataHandler $dataHandler reference to the main DataHandler object 142 */ 143 public function processCmdmap_afterFinish(DataHandler $dataHandler) 144 { 145 // Empty accumulation array 146 $emailNotificationService = GeneralUtility::makeInstance(StageChangeNotification::class); 147 $this->sendStageChangeNotification( 148 $this->notificationEmailInfo, 149 $emailNotificationService, 150 $dataHandler 151 ); 152 153 // Reset notification array 154 $this->notificationEmailInfo = []; 155 // Reset remapped IDs 156 $this->remappedIds = []; 157 158 $this->flushWorkspaceCacheEntriesByWorkspaceId((int)$dataHandler->BE_USER->workspace); 159 } 160 161 protected function sendStageChangeNotification( 162 array $accumulatedNotificationInformation, 163 StageChangeNotification $notificationService, 164 DataHandler $dataHandler 165 ): void { 166 foreach ($accumulatedNotificationInformation as $groupedNotificationInformation) { 167 $emails = (array)$groupedNotificationInformation['recipients']; 168 if (empty($emails)) { 169 continue; 170 } 171 $workspaceRec = $groupedNotificationInformation['shared'][0]; 172 if (!is_array($workspaceRec)) { 173 continue; 174 } 175 $notificationService->notifyStageChange( 176 $workspaceRec, 177 (int)$groupedNotificationInformation['shared'][1], 178 $groupedNotificationInformation['elements'], 179 $groupedNotificationInformation['shared'][2], 180 $emails, 181 $dataHandler->BE_USER 182 ); 183 184 if ($dataHandler->enableLogging) { 185 [$elementTable, $elementUid] = reset($groupedNotificationInformation['elements']); 186 $propertyArray = $dataHandler->getRecordProperties($elementTable, $elementUid); 187 $pid = $propertyArray['pid']; 188 $dataHandler->log($elementTable, $elementUid, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Notification email for stage change was sent to "' . implode('", "', array_column($emails, 'email')) . '"', -1, [], $dataHandler->eventPid($elementTable, $elementUid, $pid)); 189 } 190 } 191 } 192 193 /** 194 * hook that is called when an element shall get deleted 195 * 196 * @param string $table the table of the record 197 * @param int $id the ID of the record 198 * @param array $record The accordant database record 199 * @param bool $recordWasDeleted can be set so that other hooks or 200 * @param DataHandler $dataHandler reference to the main DataHandler object 201 */ 202 public function processCmdmap_deleteAction($table, $id, array $record, &$recordWasDeleted, DataHandler $dataHandler) 203 { 204 // only process the hook if it wasn't processed 205 // by someone else before 206 if ($recordWasDeleted) { 207 return; 208 } 209 $recordWasDeleted = true; 210 // For Live version, try if there is a workspace version because if so, rather "delete" that instead 211 // Look, if record is an offline version, then delete directly: 212 if ((int)($record['t3ver_oid'] ?? 0) === 0) { 213 if ($wsVersion = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $id)) { 214 $record = $wsVersion; 215 $id = $record['uid']; 216 } 217 } 218 $recordVersionState = VersionState::cast($record['t3ver_state'] ?? 0); 219 // Look, if record is an offline version, then delete directly: 220 if ((int)($record['t3ver_oid'] ?? 0) > 0) { 221 if (BackendUtility::isTableWorkspaceEnabled($table)) { 222 // In Live workspace, delete any. In other workspaces there must be match. 223 if ($dataHandler->BE_USER->workspace == 0 || (int)$record['t3ver_wsid'] == $dataHandler->BE_USER->workspace) { 224 $liveRec = BackendUtility::getLiveVersionOfRecord($table, $id, 'uid,t3ver_state'); 225 // Processing can be skipped if a delete placeholder shall be published 226 // during the current request. Thus it will be deleted later on... 227 $liveRecordVersionState = VersionState::cast($liveRec['t3ver_state']); 228 if ($recordVersionState->equals(VersionState::DELETE_PLACEHOLDER) && !empty($liveRec['uid']) 229 && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action']) 230 && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith']) 231 && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action'] === 'swap' 232 && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith'] == $id 233 ) { 234 return null; 235 } 236 237 if ($record['t3ver_wsid'] > 0 && $recordVersionState->equals(VersionState::DEFAULT_STATE)) { 238 // Change normal versioned record to delete placeholder 239 // Happens when an edited record is deleted 240 GeneralUtility::makeInstance(ConnectionPool::class) 241 ->getConnectionForTable($table) 242 ->update( 243 $table, 244 ['t3ver_state' => VersionState::DELETE_PLACEHOLDER], 245 ['uid' => $id] 246 ); 247 248 // Delete localization overlays: 249 $dataHandler->deleteL10nOverlayRecords($table, $id); 250 } elseif ($record['t3ver_wsid'] == 0 || !$liveRecordVersionState->indicatesPlaceholder()) { 251 // Delete those in WS 0 + if their live records state was not "Placeholder". 252 $dataHandler->deleteEl($table, $id); 253 } elseif ($recordVersionState->equals(VersionState::NEW_PLACEHOLDER)) { 254 $placeholderRecord = BackendUtility::getLiveVersionOfRecord($table, (int)$id); 255 $dataHandler->deleteEl($table, (int)$id); 256 if (is_array($placeholderRecord)) { 257 $this->softOrHardDeleteSingleRecord($table, (int)$placeholderRecord['uid']); 258 } 259 } 260 } else { 261 $dataHandler->log($table, (int)$id, DatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Tried to delete record from another workspace'); 262 } 263 } else { 264 $dataHandler->log($table, (int)$id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Versioning not enabled for record with an online ID (t3ver_oid) given'); 265 } 266 } elseif ($recordVersionState->equals(VersionState::NEW_PLACEHOLDER)) { 267 // If it is a new versioned record, delete it directly. 268 $dataHandler->deleteEl($table, $id); 269 } elseif ($dataHandler->BE_USER->workspaceAllowsLiveEditingInTable($table)) { 270 // Look, if record is "online" then delete directly. 271 $dataHandler->deleteEl($table, $id); 272 } else { 273 // Otherwise, try to delete by versioning: 274 $copyMappingArray = $dataHandler->copyMappingArray; 275 $dataHandler->versionizeRecord($table, $id, 'DELETED!', true); 276 // Determine newly created versions: 277 // (remove placeholders are copied and modified, thus they appear in the copyMappingArray) 278 $versionizedElements = ArrayUtility::arrayDiffKeyRecursive($dataHandler->copyMappingArray, $copyMappingArray); 279 // Delete localization overlays: 280 foreach ($versionizedElements as $versionizedTableName => $versionizedOriginalIds) { 281 foreach ($versionizedOriginalIds as $versionizedOriginalId => $_) { 282 $dataHandler->deleteL10nOverlayRecords($versionizedTableName, $versionizedOriginalId); 283 } 284 } 285 } 286 } 287 288 /** 289 * In case a sys_workspace_stage record is deleted we do a hard reset 290 * for all existing records in that stage to avoid that any of these end up 291 * as orphan records. 292 * 293 * @param string $command 294 * @param string $table 295 * @param string $id 296 * @param string $value 297 * @param DataHandler $dataHandler 298 */ 299 public function processCmdmap_postProcess($command, $table, $id, $value, DataHandler $dataHandler) 300 { 301 if ($command === 'delete') { 302 if ($table === StagesService::TABLE_STAGE) { 303 $this->resetStageOfElements((int)$id); 304 } elseif ($table === WorkspaceService::TABLE_WORKSPACE) { 305 $this->flushWorkspaceElements((int)$id); 306 $this->emitUpdateTopbarSignal(); 307 } 308 } 309 } 310 311 public function processDatamap_afterAllOperations(DataHandler $dataHandler): void 312 { 313 if (isset($dataHandler->datamap[WorkspaceService::TABLE_WORKSPACE])) { 314 $this->emitUpdateTopbarSignal(); 315 } 316 } 317 318 /** 319 * Hook for \TYPO3\CMS\Core\DataHandling\DataHandler::moveRecord that cares about 320 * moving records that are *not* in the live workspace 321 * 322 * @param string $table the table of the record 323 * @param int $uid the ID of the record 324 * @param int $destPid Position to move to: $destPid: >=0 then it points to 325 * @param array $propArr Record properties, like header and pid (includes workspace overlay) 326 * @param array $moveRec Record properties, like header and pid (without workspace overlay) 327 * @param int $resolvedPid The final page ID of the record 328 * @param bool $recordWasMoved can be set so that other hooks or 329 * @param DataHandler $dataHandler 330 */ 331 public function moveRecord($table, $uid, $destPid, array $propArr, array $moveRec, $resolvedPid, &$recordWasMoved, DataHandler $dataHandler) 332 { 333 // Only do something in Draft workspace 334 if ($dataHandler->BE_USER->workspace === 0) { 335 return; 336 } 337 $tableSupportsVersioning = BackendUtility::isTableWorkspaceEnabled($table); 338 $recordWasMoved = true; 339 $moveRecVersionState = VersionState::cast((int)($moveRec['t3ver_state'] ?? VersionState::DEFAULT_STATE)); 340 // Get workspace version of the source record, if any: 341 $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid'); 342 if ($tableSupportsVersioning) { 343 // Create version of record first, if it does not exist 344 if (empty($versionedRecord['uid'])) { 345 $dataHandler->versionizeRecord($table, $uid, 'MovePointer'); 346 $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid'); 347 if ((int)$resolvedPid !== (int)$propArr['pid']) { 348 $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid); 349 } 350 } elseif ($dataHandler->isRecordCopied($table, $uid) && (int)$dataHandler->copyMappingArray[$table][$uid] === (int)$versionedRecord['uid']) { 351 // If the record has been versioned before (e.g. cascaded parent-child structure), create only the move-placeholders 352 if ((int)$resolvedPid !== (int)$propArr['pid']) { 353 $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid); 354 } 355 } 356 } 357 // Check workspace permissions: 358 $workspaceAccessBlocked = []; 359 // Element was in "New/Deleted/Moved" so it can be moved... 360 $recIsNewVersion = $moveRecVersionState->equals(VersionState::NEW_PLACEHOLDER) || $moveRecVersionState->indicatesPlaceholder(); 361 $recordMustNotBeVersionized = $dataHandler->BE_USER->workspaceAllowsLiveEditingInTable($table); 362 $canMoveRecord = $recIsNewVersion || $tableSupportsVersioning; 363 // Workspace source check: 364 if (!$recIsNewVersion) { 365 $errorCode = $dataHandler->workspaceCannotEditRecord($table, $versionedRecord['uid'] ?: $uid); 366 if ($errorCode) { 367 $workspaceAccessBlocked['src1'] = 'Record could not be edited in workspace: ' . $errorCode . ' '; 368 } elseif (!$canMoveRecord && !$recordMustNotBeVersionized) { 369 $workspaceAccessBlocked['src2'] = 'Could not remove record from table "' . $table . '" from its page "' . $moveRec['pid'] . '" '; 370 } 371 } 372 // Workspace destination check: 373 // All records can be inserted if $recordMustNotBeVersionized is true. 374 // Only new versions can be inserted if $recordMustNotBeVersionized is FALSE. 375 if (!($recordMustNotBeVersionized || $canMoveRecord && !$recordMustNotBeVersionized)) { 376 $workspaceAccessBlocked['dest1'] = 'Could not insert record from table "' . $table . '" in destination PID "' . $resolvedPid . '" '; 377 } 378 379 if (empty($workspaceAccessBlocked)) { 380 $versionedRecordUid = (int)$versionedRecord['uid']; 381 // custom moving not needed, just behave like in live workspace (also for newly versioned records) 382 if (!$versionedRecordUid || !$tableSupportsVersioning || $recIsNewVersion) { 383 $recordWasMoved = false; 384 } else { 385 // If the move operation is done on a versioned record, which is 386 // NOT new/deleted placeholder, then mark the versioned record as "moved" 387 $this->moveRecord_moveVersionedRecord($table, (int)$uid, (int)$destPid, $versionedRecordUid, $dataHandler); 388 } 389 } else { 390 $dataHandler->log($table, $versionedRecord['uid'] ?: $uid, DatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Move attempt failed due to workspace restrictions: ' . implode(' // ', $workspaceAccessBlocked)); 391 } 392 } 393 394 /** 395 * Processes fields of a moved record and follows references. 396 * 397 * @param DataHandler $dataHandler Calling DataHandler instance 398 * @param int $resolvedPageId Resolved real destination page id 399 * @param string $table Name of parent table 400 * @param int $uid UID of the parent record 401 */ 402 protected function moveRecord_processFields(DataHandler $dataHandler, $resolvedPageId, $table, $uid) 403 { 404 $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid); 405 if (empty($versionedRecord)) { 406 return; 407 } 408 foreach ($versionedRecord as $field => $value) { 409 if (empty($GLOBALS['TCA'][$table]['columns'][$field]['config'])) { 410 continue; 411 } 412 $this->moveRecord_processFieldValue( 413 $dataHandler, 414 $resolvedPageId, 415 $table, 416 $uid, 417 $value, 418 $GLOBALS['TCA'][$table]['columns'][$field]['config'] 419 ); 420 } 421 } 422 423 /** 424 * Processes a single field of a moved record and follows references. 425 * 426 * @param DataHandler $dataHandler Calling DataHandler instance 427 * @param int $resolvedPageId Resolved real destination page id 428 * @param string $table Name of parent table 429 * @param int $uid UID of the parent record 430 * @param string $value Value of the field of the parent record 431 * @param array $configuration TCA field configuration of the parent record 432 */ 433 protected function moveRecord_processFieldValue(DataHandler $dataHandler, $resolvedPageId, $table, $uid, $value, array $configuration): void 434 { 435 $inlineFieldType = $dataHandler->getInlineFieldType($configuration); 436 $inlineProcessing = ( 437 ($inlineFieldType === 'list' || $inlineFieldType === 'field') 438 && BackendUtility::isTableWorkspaceEnabled($configuration['foreign_table']) 439 && (!isset($configuration['behaviour']['disableMovingChildrenWithParent']) || !$configuration['behaviour']['disableMovingChildrenWithParent']) 440 ); 441 442 if ($inlineProcessing) { 443 if ($table === 'pages') { 444 // If the inline elements are related to a page record, 445 // make sure they reside at that page and not at its parent 446 $resolvedPageId = $uid; 447 } 448 449 $dbAnalysis = $this->createRelationHandlerInstance(); 450 $dbAnalysis->start($value, $configuration['foreign_table'], '', $uid, $table, $configuration); 451 452 // Moving records to a positive destination will insert each 453 // record at the beginning, thus the order is reversed here: 454 foreach ($dbAnalysis->itemArray as $item) { 455 $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $item['table'], $item['id'], 'uid,t3ver_state'); 456 if (empty($versionedRecord)) { 457 continue; 458 } 459 $versionState = VersionState::cast($versionedRecord['t3ver_state']); 460 if ($versionState->indicatesPlaceholder()) { 461 continue; 462 } 463 $dataHandler->moveRecord($item['table'], $item['id'], $resolvedPageId); 464 } 465 } 466 } 467 468 /**************************** 469 ***** Stage Changes ****** 470 ****************************/ 471 /** 472 * Setting stage of record 473 * 474 * @param string $table Table name 475 * @param int $id 476 * @param int $stageId Stage ID to set 477 * @param string $comment Comment that goes into log 478 * @param DataHandler $dataHandler DataHandler object 479 * @param array $notificationAlternativeRecipients comma separated list of recipients to notify instead of normal be_users 480 */ 481 protected function version_setStage($table, $id, $stageId, string $comment, DataHandler $dataHandler, array $notificationAlternativeRecipients = []) 482 { 483 $record = BackendUtility::getRecord($table, $id); 484 if (!is_array($record)) { 485 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed: No Record'); 486 } elseif ($errorCode = $dataHandler->workspaceCannotEditOfflineVersion($table, $record)) { 487 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed: ' . $errorCode); 488 } elseif ($dataHandler->checkRecordUpdateAccess($table, $id)) { 489 $workspaceInfo = $dataHandler->BE_USER->checkWorkspace($record['t3ver_wsid']); 490 // check if the user is allowed to the current stage, so it's also allowed to send to next stage 491 if ($dataHandler->BE_USER->workspaceCheckStageForCurrent($record['t3ver_stage'])) { 492 // Set stage of record: 493 GeneralUtility::makeInstance(ConnectionPool::class) 494 ->getConnectionForTable($table) 495 ->update( 496 $table, 497 [ 498 't3ver_stage' => $stageId, 499 ], 500 ['uid' => (int)$id] 501 ); 502 503 if ($dataHandler->enableLogging) { 504 $propertyArray = $dataHandler->getRecordProperties($table, $id); 505 $pid = $propertyArray['pid']; 506 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid)); 507 } 508 // TEMPORARY, except 6-30 as action/detail number which is observed elsewhere! 509 $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Stage raised...', 30, ['comment' => $comment, 'stage' => $stageId]); 510 if ((int)$workspaceInfo['stagechg_notification'] > 0) { 511 $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['shared'] = [$workspaceInfo, $stageId, $comment]; 512 $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['elements'][] = [$table, $id]; 513 $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['recipients'] = $notificationAlternativeRecipients; 514 } 515 } else { 516 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'The member user tried to set a stage value "' . $stageId . '" that was not allowed'); 517 } 518 } else { 519 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed because you do not have edit access'); 520 } 521 } 522 523 /***************************** 524 ***** CMD versioning ****** 525 *****************************/ 526 527 /** 528 * Publishing / Swapping (= switching) versions of a record 529 * Version from archive (future/past, called "swap version") will get the uid of the "t3ver_oid", the official element with uid = "t3ver_oid" will get the new versions old uid. PIDs are swapped also 530 * 531 * @param string $table Table name 532 * @param int $id UID of the online record to swap 533 * @param int $swapWith UID of the archived version to swap with! 534 * @param DataHandler $dataHandler DataHandler object 535 * @param string $comment Notification comment 536 * @param array $notificationAlternativeRecipients comma separated list of recipients to notify instead of normal be_users 537 */ 538 protected function version_swap($table, $id, $swapWith, DataHandler $dataHandler, string $comment, $notificationAlternativeRecipients = []) 539 { 540 // Check prerequisites before start publishing 541 // Skip records that have been deleted during the current execution 542 if ($dataHandler->hasDeletedRecord($table, $id)) { 543 return; 544 } 545 546 // First, check if we may actually edit the online record 547 if (!$dataHandler->checkRecordUpdateAccess($table, $id)) { 548 $dataHandler->log( 549 $table, 550 $id, 551 DatabaseAction::PUBLISH, 552 0, 553 SystemLogErrorClassification::USER_ERROR, 554 'Error: You cannot swap versions for record %s:%d you do not have access to edit!', 555 -1, 556 [$table, $id] 557 ); 558 return; 559 } 560 // Select the two versions: 561 // Currently live version, contents will be removed. 562 $curVersion = BackendUtility::getRecord($table, $id, '*'); 563 // Versioned records which contents will be moved into $curVersion 564 $isNewRecord = ((int)($curVersion['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER); 565 if ($isNewRecord && is_array($curVersion)) { 566 // @todo: This early return is odd. It means version_swap_processFields() and versionPublishManyToManyRelations() 567 // below are not called for new records to be published. This is "fine" for mm since mm tables have no 568 // t3ver_wsid and need no publish as such. For inline relation publishing, this is indirectly resolved by the 569 // processCmdmap_beforeStart() hook, which adds additional commands for child records - a construct we 570 // may want to avoid altogether due to its complexity. It would be easier to follow if publish here would 571 // handle that instead. 572 $this->publishNewRecord($table, $curVersion, $dataHandler, $comment, (array)$notificationAlternativeRecipients); 573 return; 574 } 575 $swapVersion = BackendUtility::getRecord($table, $swapWith, '*'); 576 if (!(is_array($curVersion) && is_array($swapVersion))) { 577 $dataHandler->log( 578 $table, 579 $id, 580 DatabaseAction::PUBLISH, 581 0, 582 SystemLogErrorClassification::SYSTEM_ERROR, 583 'Error: Either online or swap version for %s:%d->%d could not be selected!', 584 -1, 585 [$table, $id, $swapWith] 586 ); 587 return; 588 } 589 $workspaceId = (int)$swapVersion['t3ver_wsid']; 590 if (!$dataHandler->BE_USER->workspacePublishAccess($workspaceId)) { 591 $dataHandler->log($table, (int)$id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'User could not publish records from workspace #' . $workspaceId); 592 return; 593 } 594 $wsAccess = $dataHandler->BE_USER->checkWorkspace($workspaceId); 595 if (!($workspaceId <= 0 || !($wsAccess['publish_access'] & 1) || (int)$swapVersion['t3ver_stage'] === StagesService::STAGE_PUBLISH_ID)) { 596 $dataHandler->log($table, (int)$id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'Records in workspace #' . $workspaceId . ' can only be published when in "Publish" stage.'); 597 return; 598 } 599 if (!($dataHandler->doesRecordExist($table, $swapWith, Permission::PAGE_SHOW) && $dataHandler->checkRecordUpdateAccess($table, $swapWith))) { 600 $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot publish a record you do not have edit and show permissions for'); 601 return; 602 } 603 // Check if the swapWith record really IS a version of the original! 604 if (!(((int)$swapVersion['t3ver_oid'] > 0 && (int)$curVersion['t3ver_oid'] === 0) && (int)$swapVersion['t3ver_oid'] === (int)$id)) { 605 $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'In offline record, either t3ver_oid was not set or the t3ver_oid didn\'t match the id of the online version as it must!'); 606 return; 607 } 608 $versionState = new VersionState($swapVersion['t3ver_state']); 609 610 // Find fields to keep 611 $keepFields = $this->getUniqueFields($table); 612 // Sorting needs to be exchanged for moved records 613 if (!empty($GLOBALS['TCA'][$table]['ctrl']['sortby']) && !$versionState->equals(VersionState::MOVE_POINTER)) { 614 $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['sortby']; 615 } 616 // l10n-fields must be kept otherwise the localization 617 // will be lost during the publishing 618 if ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']) { 619 $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']; 620 } 621 // Swap "keepfields" 622 foreach ($keepFields as $fN) { 623 $tmp = $swapVersion[$fN]; 624 $swapVersion[$fN] = $curVersion[$fN]; 625 $curVersion[$fN] = $tmp; 626 } 627 // Preserve states: 628 $t3ver_state = []; 629 $t3ver_state['swapVersion'] = $swapVersion['t3ver_state']; 630 // Modify offline version to become online: 631 // Set pid for ONLINE (but not for moved records) 632 if (!$versionState->equals(VersionState::MOVE_POINTER)) { 633 $swapVersion['pid'] = (int)$curVersion['pid']; 634 } 635 // We clear this because t3ver_oid only make sense for offline versions 636 // and we want to prevent unintentional misuse of this 637 // value for online records. 638 $swapVersion['t3ver_oid'] = 0; 639 // In case of swapping and the offline record has a state 640 // (like 2 or 4 for deleting or move-pointer) we set the 641 // current workspace ID so the record is not deselected. 642 // @todo: It is odd these information are updated in $swapVersion *before* version_swap_processFields 643 // version_swap_processFields() and versionPublishManyToManyRelations() are called. This leads 644 // to the situation that versionPublishManyToManyRelations() needs another argument to transfer 645 // the "from workspace" information which would usually be retrieved by accessing $swapVersion['t3ver_wsid'] 646 $swapVersion['t3ver_wsid'] = 0; 647 $swapVersion['t3ver_stage'] = 0; 648 $swapVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE); 649 // Take care of relations in each field (e.g. IRRE): 650 if (is_array($GLOBALS['TCA'][$table]['columns'])) { 651 foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $fieldConf) { 652 if (isset($fieldConf['config']) && is_array($fieldConf['config'])) { 653 $this->version_swap_processFields($table, $fieldConf['config'], $curVersion, $swapVersion, $dataHandler); 654 } 655 } 656 } 657 $dataHandler->versionPublishManyToManyRelations($table, $curVersion, $swapVersion, $workspaceId); 658 unset($swapVersion['uid']); 659 // Modify online version to become offline: 660 unset($curVersion['uid']); 661 // Mark curVersion to contain the oid 662 $curVersion['t3ver_oid'] = (int)$id; 663 $curVersion['t3ver_wsid'] = 0; 664 // Increment lifecycle counter 665 $curVersion['t3ver_stage'] = 0; 666 $curVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE); 667 // Generating proper history data to prepare logging 668 $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $id, $swapVersion); 669 $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $swapWith, $curVersion); 670 671 // Execute swapping: 672 $sqlErrors = []; 673 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); 674 675 $platform = $connection->getDatabasePlatform(); 676 $tableDetails = null; 677 if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) { 678 // mssql and postgres needs to set proper PARAM_LOB and others to update fields. 679 $tableDetails = $connection->createSchemaManager()->listTableDetails($table); 680 } 681 682 try { 683 $types = []; 684 685 if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) { 686 foreach ($curVersion as $columnName => $columnValue) { 687 $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType(); 688 } 689 } 690 691 $connection->update( 692 $table, 693 $swapVersion, 694 ['uid' => (int)$id], 695 $types 696 ); 697 } catch (DBALException $e) { 698 $sqlErrors[] = $e->getPrevious()->getMessage(); 699 } 700 701 if (empty($sqlErrors)) { 702 try { 703 $types = []; 704 if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) { 705 foreach ($curVersion as $columnName => $columnValue) { 706 $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType(); 707 } 708 } 709 710 $connection->update( 711 $table, 712 $curVersion, 713 ['uid' => (int)$swapWith], 714 $types 715 ); 716 } catch (DBALException $e) { 717 $sqlErrors[] = $e->getPrevious()->getMessage(); 718 } 719 } 720 721 if (!empty($sqlErrors)) { 722 $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'During Swapping: SQL errors happened: ' . implode('; ', $sqlErrors)); 723 } else { 724 // Update localized elements to use the live l10n_parent now 725 $this->updateL10nOverlayRecordsOnPublish($table, $id, $swapWith, $workspaceId, $dataHandler); 726 // Register swapped ids for later remapping: 727 $this->remappedIds[$table][$id] = $swapWith; 728 $this->remappedIds[$table][$swapWith] = $id; 729 if ((int)$t3ver_state['swapVersion'] === VersionState::DELETE_PLACEHOLDER) { 730 // We're publishing a delete placeholder t3ver_state = 2. This means the live record should 731 // be set to deleted. We're currently in some workspace and deal with a live record here. Thus, 732 // we temporarily set backend user workspace to 0 so all operations happen as in live. 733 $currentUserWorkspace = $dataHandler->BE_USER->workspace; 734 $dataHandler->BE_USER->workspace = 0; 735 $dataHandler->deleteEl($table, $id, true); 736 $dataHandler->BE_USER->workspace = $currentUserWorkspace; 737 } 738 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::MESSAGE, 'Publishing successful for table "' . $table . '" uid ' . $id . '=>' . $swapWith, -1, [], $dataHandler->eventPid($table, $id, $swapVersion['pid'])); 739 740 // Set log entry for live record: 741 $propArr = $dataHandler->getRecordPropertiesFromRow($table, $swapVersion); 742 if (($propArr['t3ver_oid'] ?? 0) > 0) { 743 $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated'); 744 } else { 745 $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated'); 746 } 747 $dataHandler->log($table, $id, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']); 748 $dataHandler->setHistory($table, $id); 749 // Set log entry for offline record: 750 $propArr = $dataHandler->getRecordPropertiesFromRow($table, $curVersion); 751 if (($propArr['t3ver_oid'] ?? 0) > 0) { 752 $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated'); 753 } else { 754 $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated'); 755 } 756 $dataHandler->log($table, $swapWith, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $swapWith], $propArr['event_pid']); 757 $dataHandler->setHistory($table, $swapWith); 758 759 $stageId = StagesService::STAGE_PUBLISH_EXECUTE_ID; 760 $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment; 761 $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment]; 762 $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = [$table, $id]; 763 $this->notificationEmailInfo[$notificationEmailInfoKey]['recipients'] = $notificationAlternativeRecipients; 764 // Write to log with stageId -20 (STAGE_PUBLISH_EXECUTE_ID) 765 if ($dataHandler->enableLogging) { 766 $propArr = $dataHandler->getRecordProperties($table, $id); 767 $pid = $propArr['pid']; 768 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid)); 769 } 770 $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Published', 30, ['comment' => $comment, 'stage' => $stageId]); 771 772 // Clear cache: 773 $dataHandler->registerRecordIdForPageCacheClearing($table, $id); 774 // If published, delete the record from the database 775 if ($table === 'pages') { 776 // Note on fifth argument false: At this point both $curVersion and $swapVersion page records are 777 // identical in DB. deleteEl() would now usually find all records assigned to our obsolete 778 // page which at the same time belong to our current version page, and would delete them. 779 // To suppress this, false tells deleteEl() to only delete the obsolete page but not its assigned records. 780 $dataHandler->deleteEl($table, $swapWith, true, true, false); 781 } else { 782 $dataHandler->deleteEl($table, $swapWith, true, true); 783 } 784 785 // Update reference index of the live record - which could have been a workspace record in case 'new' 786 $dataHandler->updateRefIndex($table, $id, 0); 787 // The 'swapWith' record has been deleted, so we can drop any reference index the record is involved in 788 $dataHandler->registerReferenceIndexRowsForDrop($table, $swapWith, (int)$dataHandler->BE_USER->workspace); 789 } 790 } 791 792 /** 793 * If an editor is doing "partial" publishing, the translated children need to be "linked" to the now pointed 794 * live record, as if the versioned record (which is deleted) would have never existed. 795 * 796 * This is related to the l10n_source and l10n_parent fields. 797 * 798 * This needs to happen before the hook calls DataHandler->deleteEl() otherwise the children get deleted as well. 799 * 800 * @param string $table the database table of the published record 801 * @param int $liveId the live version / online version of the record that was just published 802 * @param int $previouslyUsedVersionId the versioned record ID (wsid>0) which is about to be deleted 803 * @param int $workspaceId the workspace ID 804 * @param DataHandler $dataHandler 805 */ 806 protected function updateL10nOverlayRecordsOnPublish(string $table, int $liveId, int $previouslyUsedVersionId, int $workspaceId, DataHandler $dataHandler): void 807 { 808 if (!BackendUtility::isTableLocalizable($table)) { 809 return; 810 } 811 if (!BackendUtility::isTableWorkspaceEnabled($table)) { 812 return; 813 } 814 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); 815 $queryBuilder = $connection->createQueryBuilder(); 816 $queryBuilder->getRestrictions()->removeAll(); 817 818 $l10nParentFieldName = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']; 819 $constraints = $queryBuilder->expr()->eq( 820 $l10nParentFieldName, 821 $queryBuilder->createNamedParameter($previouslyUsedVersionId, \PDO::PARAM_INT) 822 ); 823 $translationSourceFieldName = $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null; 824 if ($translationSourceFieldName) { 825 $constraints = $queryBuilder->expr()->orX( 826 $constraints, 827 $queryBuilder->expr()->eq( 828 $translationSourceFieldName, 829 $queryBuilder->createNamedParameter($previouslyUsedVersionId, \PDO::PARAM_INT) 830 ) 831 ); 832 } 833 834 $queryBuilder 835 ->select('uid', $l10nParentFieldName) 836 ->from($table) 837 ->where( 838 $constraints, 839 $queryBuilder->expr()->eq( 840 't3ver_wsid', 841 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 842 ) 843 ); 844 845 if ($translationSourceFieldName) { 846 $queryBuilder->addSelect($translationSourceFieldName); 847 } 848 849 $statement = $queryBuilder->executeQuery(); 850 while ($record = $statement->fetchAssociative()) { 851 $updateFields = []; 852 $dataTypes = [\PDO::PARAM_INT]; 853 if ((int)$record[$l10nParentFieldName] === $previouslyUsedVersionId) { 854 $updateFields[$l10nParentFieldName] = $liveId; 855 $dataTypes[] = \PDO::PARAM_INT; 856 } 857 if ($translationSourceFieldName && (int)$record[$translationSourceFieldName] === $previouslyUsedVersionId) { 858 $updateFields[$translationSourceFieldName] = $liveId; 859 $dataTypes[] = \PDO::PARAM_INT; 860 } 861 862 if (empty($updateFields)) { 863 continue; 864 } 865 866 $connection->update( 867 $table, 868 $updateFields, 869 ['uid' => (int)$record['uid']], 870 $dataTypes 871 ); 872 $dataHandler->updateRefIndex($table, $record['uid']); 873 } 874 } 875 876 /** 877 * Processes fields of a record for the publishing/swapping process. 878 * Basically this takes care of IRRE (type "inline") child references. 879 * 880 * @param string $tableName Table name 881 * @param array $configuration TCA field configuration 882 * @param array $liveData Live record data 883 * @param array $versionData Version record data 884 * @param DataHandler $dataHandler Calling data-handler object 885 */ 886 protected function version_swap_processFields($tableName, array $configuration, array $liveData, array $versionData, DataHandler $dataHandler) 887 { 888 $inlineType = $dataHandler->getInlineFieldType($configuration); 889 if ($inlineType !== 'field') { 890 return; 891 } 892 $foreignTable = $configuration['foreign_table']; 893 // Read relations that point to the current record (e.g. live record): 894 $liveRelations = $this->createRelationHandlerInstance(); 895 $liveRelations->setWorkspaceId(0); 896 $liveRelations->start('', $foreignTable, '', $liveData['uid'], $tableName, $configuration); 897 // Read relations that point to the record to be swapped with e.g. draft record): 898 $versionRelations = $this->createRelationHandlerInstance(); 899 $versionRelations->setUseLiveReferenceIds(false); 900 $versionRelations->start('', $foreignTable, '', $versionData['uid'], $tableName, $configuration); 901 // Update relations for both (workspace/versioning) sites: 902 if (!empty($liveRelations->itemArray)) { 903 $dataHandler->addRemapAction( 904 $tableName, 905 $liveData['uid'], 906 [$this, 'updateInlineForeignFieldSorting'], 907 [$liveData['uid'], $foreignTable, $liveRelations->tableArray[$foreignTable], $configuration, $dataHandler->BE_USER->workspace] 908 ); 909 } 910 if (!empty($versionRelations->itemArray)) { 911 $dataHandler->addRemapAction( 912 $tableName, 913 $liveData['uid'], 914 [$this, 'updateInlineForeignFieldSorting'], 915 [$liveData['uid'], $foreignTable, $versionRelations->tableArray[$foreignTable], $configuration, 0] 916 ); 917 } 918 } 919 920 /** 921 * When a new record in a workspace is published, there is no "replacing" the online version with 922 * the versioned record, but instead the workspace ID and the state is changed. 923 * 924 * @param string $table 925 * @param array $newRecordInWorkspace 926 * @param DataHandler $dataHandler 927 * @param string $comment 928 * @param array $notificationAlternativeRecipients 929 */ 930 protected function publishNewRecord(string $table, array $newRecordInWorkspace, DataHandler $dataHandler, string $comment, array $notificationAlternativeRecipients): void 931 { 932 $id = (int)$newRecordInWorkspace['uid']; 933 $workspaceId = (int)$newRecordInWorkspace['t3ver_wsid']; 934 if (!$dataHandler->BE_USER->workspacePublishAccess($workspaceId)) { 935 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'User could not publish records from workspace #' . $workspaceId); 936 return; 937 } 938 $wsAccess = $dataHandler->BE_USER->checkWorkspace($workspaceId); 939 if (!($workspaceId <= 0 || !($wsAccess['publish_access'] & 1) || (int)$newRecordInWorkspace['t3ver_stage'] === StagesService::STAGE_PUBLISH_ID)) { 940 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'Records in workspace #' . $workspaceId . ' can only be published when in "Publish" stage.'); 941 return; 942 } 943 if (!($dataHandler->doesRecordExist($table, $id, Permission::PAGE_SHOW) && $dataHandler->checkRecordUpdateAccess($table, $id))) { 944 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot publish a record you do not have edit and show permissions for'); 945 return; 946 } 947 948 // Modify versioned record to become online 949 $updatedFields = [ 950 't3ver_oid' => 0, 951 't3ver_wsid' => 0, 952 't3ver_stage' => 0, 953 't3ver_state' => VersionState::DEFAULT_STATE, 954 ]; 955 956 try { 957 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); 958 $connection->update( 959 $table, 960 $updatedFields, 961 [ 962 'uid' => (int)$id, 963 ], 964 [ 965 \PDO::PARAM_INT, 966 \PDO::PARAM_INT, 967 \PDO::PARAM_INT, 968 \PDO::PARAM_INT, 969 \PDO::PARAM_INT, 970 ] 971 ); 972 } catch (DBALException $e) { 973 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'During Publishing: SQL errors happened: ' . $e->getPrevious()->getMessage()); 974 } 975 976 if ($dataHandler->enableLogging) { 977 $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::MESSAGE, 'Publishing successful for table "' . $table . '" uid ' . $id . ' (new record)', -1, [], $dataHandler->eventPid($table, $id, $newRecordInWorkspace['pid'])); 978 } 979 980 // Set log entry for record 981 $propArr = $dataHandler->getRecordPropertiesFromRow($table, $newRecordInWorkspace); 982 $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated'); 983 $dataHandler->log($table, $id, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']); 984 $dataHandler->setHistory($table, $id); 985 986 $stageId = StagesService::STAGE_PUBLISH_EXECUTE_ID; 987 $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment; 988 $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment]; 989 $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = [$table, $id]; 990 $this->notificationEmailInfo[$notificationEmailInfoKey]['recipients'] = $notificationAlternativeRecipients; 991 // Write to log with stageId -20 (STAGE_PUBLISH_EXECUTE_ID) 992 $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $newRecordInWorkspace['pid'])); 993 $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Published', 30, ['comment' => $comment, 'stage' => $stageId]); 994 995 // Clear cache 996 $dataHandler->registerRecordIdForPageCacheClearing($table, $id); 997 // Update the reference index: Drop the references in the workspace, but update them in the live workspace 998 $dataHandler->registerReferenceIndexRowsForDrop($table, $id, $workspaceId); 999 $dataHandler->updateRefIndex($table, $id, 0); 1000 $this->updateReferenceIndexForL10nOverlays($table, $id, $workspaceId, $dataHandler); 1001 1002 // When dealing with mm relations on local side, existing refindex rows of the new workspace record 1003 // need to be re-calculated for the now live record. Scenario ManyToMany Publish createContentAndAddRelation 1004 // These calls are similar to what is done in DH->versionPublishManyToManyRelations() and can not be 1005 // used from there since publishing new records does not call that method, see @todo in version_swap(). 1006 $dataHandler->registerReferenceIndexUpdateForReferencesToItem($table, $id, $workspaceId, 0); 1007 $dataHandler->registerReferenceIndexUpdateForReferencesToItem($table, $id, $workspaceId); 1008 } 1009 1010 /** 1011 * A new record was just published, but the reference index for the localized elements needs 1012 * an update too. 1013 * 1014 * @param string $table 1015 * @param int $newVersionedRecordId 1016 * @param int $workspaceId 1017 * @param DataHandler $dataHandler 1018 */ 1019 protected function updateReferenceIndexForL10nOverlays(string $table, int $newVersionedRecordId, int $workspaceId, DataHandler $dataHandler): void 1020 { 1021 if (!BackendUtility::isTableLocalizable($table)) { 1022 return; 1023 } 1024 if (!BackendUtility::isTableWorkspaceEnabled($table)) { 1025 return; 1026 } 1027 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); 1028 $queryBuilder = $connection->createQueryBuilder(); 1029 $queryBuilder->getRestrictions()->removeAll(); 1030 1031 $l10nParentFieldName = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']; 1032 $constraints = $queryBuilder->expr()->eq( 1033 $l10nParentFieldName, 1034 $queryBuilder->createNamedParameter($newVersionedRecordId, \PDO::PARAM_INT) 1035 ); 1036 $translationSourceFieldName = $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null; 1037 if ($translationSourceFieldName) { 1038 $constraints = $queryBuilder->expr()->orX( 1039 $constraints, 1040 $queryBuilder->expr()->eq( 1041 $translationSourceFieldName, 1042 $queryBuilder->createNamedParameter($newVersionedRecordId, \PDO::PARAM_INT) 1043 ) 1044 ); 1045 } 1046 1047 $queryBuilder 1048 ->select('uid', $l10nParentFieldName) 1049 ->from($table) 1050 ->where( 1051 $constraints, 1052 $queryBuilder->expr()->eq( 1053 't3ver_wsid', 1054 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 1055 ) 1056 ); 1057 1058 if ($translationSourceFieldName) { 1059 $queryBuilder->addSelect($translationSourceFieldName); 1060 } 1061 1062 $statement = $queryBuilder->executeQuery(); 1063 while ($record = $statement->fetchAssociative()) { 1064 $dataHandler->updateRefIndex($table, $record['uid']); 1065 } 1066 } 1067 1068 /** 1069 * Updates foreign field sorting values of versioned and live 1070 * parents after(!) the whole structure has been published. 1071 * 1072 * This method is used as callback function in 1073 * DataHandlerHook::version_swap_procBasedOnFieldType(). 1074 * Sorting fields ("sortby") are not modified during the 1075 * workspace publishing/swapping process directly. 1076 * 1077 * @param string $parentId 1078 * @param string $foreignTableName 1079 * @param int[] $foreignIds 1080 * @param array $configuration 1081 * @param int $targetWorkspaceId 1082 * @internal 1083 */ 1084 public function updateInlineForeignFieldSorting($parentId, $foreignTableName, $foreignIds, array $configuration, $targetWorkspaceId) 1085 { 1086 $remappedIds = []; 1087 // Use remapped ids (live id <-> version id) 1088 foreach ($foreignIds as $foreignId) { 1089 if (!empty($this->remappedIds[$foreignTableName][$foreignId])) { 1090 $remappedIds[] = $this->remappedIds[$foreignTableName][$foreignId]; 1091 } else { 1092 $remappedIds[] = $foreignId; 1093 } 1094 } 1095 1096 $relationHandler = $this->createRelationHandlerInstance(); 1097 $relationHandler->setWorkspaceId($targetWorkspaceId); 1098 $relationHandler->setUseLiveReferenceIds(false); 1099 $relationHandler->start(implode(',', $remappedIds), $foreignTableName); 1100 $relationHandler->processDeletePlaceholder(); 1101 $relationHandler->writeForeignField($configuration, $parentId); 1102 } 1103 1104 /** 1105 * In case a sys_workspace_stage record is deleted we do a hard reset 1106 * for all existing records in that stage to avoid that any of these end up 1107 * as orphan records. 1108 * 1109 * @param int $stageId Elements with this stage are reset 1110 */ 1111 protected function resetStageOfElements(int $stageId): void 1112 { 1113 foreach ($this->getTcaTables() as $tcaTable) { 1114 if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) { 1115 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1116 ->getQueryBuilderForTable($tcaTable); 1117 1118 $queryBuilder 1119 ->update($tcaTable) 1120 ->set('t3ver_stage', StagesService::STAGE_EDIT_ID) 1121 ->where( 1122 $queryBuilder->expr()->eq( 1123 't3ver_stage', 1124 $queryBuilder->createNamedParameter($stageId, \PDO::PARAM_INT) 1125 ), 1126 $queryBuilder->expr()->gt( 1127 't3ver_wsid', 1128 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1129 ) 1130 ) 1131 ->executeStatement(); 1132 } 1133 } 1134 } 1135 1136 /** 1137 * Flushes (remove, no soft delete!) elements of a particular workspace to avoid orphan records. 1138 * This is used if an admin deletes a sys_workspace record. 1139 * 1140 * @param int $workspaceId The workspace to be flushed 1141 */ 1142 protected function flushWorkspaceElements(int $workspaceId): void 1143 { 1144 $command = []; 1145 foreach ($this->getTcaTables() as $tcaTable) { 1146 if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) { 1147 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1148 ->getQueryBuilderForTable($tcaTable); 1149 $queryBuilder->getRestrictions()->removeAll(); 1150 $result = $queryBuilder 1151 ->select('uid') 1152 ->from($tcaTable) 1153 ->where( 1154 $queryBuilder->expr()->eq( 1155 't3ver_wsid', 1156 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 1157 ), 1158 // t3ver_oid >= 0 basically omits placeholder records here, those would otherwise 1159 // fail to delete later in DH->discard() and would create "can't do that" log entries. 1160 $queryBuilder->expr()->orX( 1161 $queryBuilder->expr()->gt( 1162 't3ver_oid', 1163 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1164 ), 1165 $queryBuilder->expr()->eq( 1166 't3ver_state', 1167 $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER, \PDO::PARAM_INT) 1168 ) 1169 ) 1170 ) 1171 ->orderBy('uid') 1172 ->executeQuery(); 1173 1174 while (($recordId = $result->fetchOne()) !== false) { 1175 $command[$tcaTable][$recordId]['version']['action'] = 'flush'; 1176 } 1177 } 1178 } 1179 if (!empty($command)) { 1180 // Execute the command array via DataHandler to flush all records from this workspace. 1181 // Switch to target workspace temporarily, otherwise DH->discard() do not 1182 // operate on correct workspace if fetching additional records. 1183 $backendUser = $GLOBALS['BE_USER']; 1184 $savedWorkspace = $backendUser->workspace; 1185 $backendUser->workspace = $workspaceId; 1186 $context = GeneralUtility::makeInstance(Context::class); 1187 $savedWorkspaceContext = $context->getAspect('workspace'); 1188 $context->setAspect('workspace', new WorkspaceAspect($workspaceId)); 1189 1190 $dataHandler = GeneralUtility::makeInstance(DataHandler::class); 1191 $dataHandler->start([], $command, $backendUser); 1192 $dataHandler->process_cmdmap(); 1193 1194 $backendUser->workspace = $savedWorkspace; 1195 $context->setAspect('workspace', $savedWorkspaceContext); 1196 } 1197 } 1198 1199 /** 1200 * Gets all defined TCA tables. 1201 * 1202 * @return array 1203 */ 1204 protected function getTcaTables(): array 1205 { 1206 return array_keys($GLOBALS['TCA']); 1207 } 1208 1209 /** 1210 * Flushes the workspace cache for current workspace and for the virtual "all workspaces" too. 1211 * 1212 * @param int $workspaceId The workspace to be flushed in cache 1213 */ 1214 protected function flushWorkspaceCacheEntriesByWorkspaceId(int $workspaceId): void 1215 { 1216 $workspacesCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('workspaces_cache'); 1217 $workspacesCache->flushByTag($workspaceId); 1218 } 1219 1220 /******************************* 1221 ***** helper functions ****** 1222 *******************************/ 1223 1224 /** 1225 * Finds all elements for swapping versions in workspace 1226 * 1227 * @param string $table Table name of the original element to swap 1228 * @param int $id UID of the original element to swap (online) 1229 * @param int $offlineId As above but offline 1230 * @return array Element data. Key is table name, values are array with first element as online UID, second - offline UID 1231 */ 1232 public function findPageElementsForVersionSwap($table, $id, $offlineId) 1233 { 1234 $rec = BackendUtility::getRecord($table, $offlineId, 't3ver_wsid'); 1235 $workspaceId = (int)$rec['t3ver_wsid']; 1236 $elementData = []; 1237 if ($workspaceId === 0) { 1238 return $elementData; 1239 } 1240 // Get page UID for LIVE and workspace 1241 if ($table !== 'pages') { 1242 $rec = BackendUtility::getRecord($table, $id, 'pid'); 1243 $pageId = $rec['pid']; 1244 $rec = BackendUtility::getRecord('pages', $pageId); 1245 BackendUtility::workspaceOL('pages', $rec, $workspaceId); 1246 $offlinePageId = $rec['_ORIG_uid']; 1247 } else { 1248 $pageId = $id; 1249 $offlinePageId = $offlineId; 1250 } 1251 // Traversing all tables supporting versioning: 1252 foreach ($GLOBALS['TCA'] as $table => $cfg) { 1253 if (BackendUtility::isTableWorkspaceEnabled($table) && $table !== 'pages') { 1254 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1255 ->getQueryBuilderForTable($table); 1256 1257 $queryBuilder->getRestrictions() 1258 ->removeAll() 1259 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); 1260 1261 $statement = $queryBuilder 1262 ->select('A.uid AS offlineUid', 'B.uid AS uid') 1263 ->from($table, 'A') 1264 ->from($table, 'B') 1265 ->where( 1266 $queryBuilder->expr()->gt( 1267 'A.t3ver_oid', 1268 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1269 ), 1270 $queryBuilder->expr()->eq( 1271 'B.pid', 1272 $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT) 1273 ), 1274 $queryBuilder->expr()->eq( 1275 'A.t3ver_wsid', 1276 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 1277 ), 1278 $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid')) 1279 ) 1280 ->executeQuery(); 1281 1282 while ($row = $statement->fetchAssociative()) { 1283 $elementData[$table][] = [$row['uid'], $row['offlineUid']]; 1284 } 1285 } 1286 } 1287 if ($offlinePageId && $offlinePageId != $pageId) { 1288 $elementData['pages'][] = [$pageId, $offlinePageId]; 1289 } 1290 1291 return $elementData; 1292 } 1293 1294 /** 1295 * Searches for all elements from all tables on the given pages in the same workspace. 1296 * 1297 * @param array $pageIdList List of PIDs to search 1298 * @param int $workspaceId Workspace ID 1299 * @param array $elementList List of found elements. Key is table name, value is array of element UIDs 1300 */ 1301 public function findPageElementsForVersionStageChange(array $pageIdList, $workspaceId, array &$elementList) 1302 { 1303 if ($workspaceId == 0) { 1304 return; 1305 } 1306 // Traversing all tables supporting versioning: 1307 foreach ($GLOBALS['TCA'] as $table => $cfg) { 1308 if (BackendUtility::isTableWorkspaceEnabled($table) && $table !== 'pages') { 1309 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1310 ->getQueryBuilderForTable($table); 1311 1312 $queryBuilder->getRestrictions() 1313 ->removeAll() 1314 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); 1315 1316 $statement = $queryBuilder 1317 ->select('A.uid') 1318 ->from($table, 'A') 1319 ->from($table, 'B') 1320 ->where( 1321 $queryBuilder->expr()->gt( 1322 'A.t3ver_oid', 1323 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1324 ), 1325 $queryBuilder->expr()->in( 1326 'B.pid', 1327 $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY) 1328 ), 1329 $queryBuilder->expr()->eq( 1330 'A.t3ver_wsid', 1331 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 1332 ), 1333 $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid')) 1334 ) 1335 ->groupBy('A.uid') 1336 ->executeQuery(); 1337 1338 while ($row = $statement->fetchAssociative()) { 1339 $elementList[$table][] = $row['uid']; 1340 } 1341 if (is_array($elementList[$table])) { 1342 // Yes, it is possible to get non-unique array even with DISTINCT above! 1343 // It happens because several UIDs are passed in the array already. 1344 $elementList[$table] = array_unique($elementList[$table]); 1345 } 1346 } 1347 } 1348 } 1349 1350 /** 1351 * Finds page UIDs for the element from table <code>$table</code> with UIDs from <code>$idList</code> 1352 * 1353 * @param string $table Table to search 1354 * @param array $idList List of records' UIDs 1355 * @param int $workspaceId Workspace ID. We need this parameter because user can be in LIVE but he still can publish DRAFT from ws module! 1356 * @param array $pageIdList List of found page UIDs 1357 * @param array $elementList List of found element UIDs. Key is table name, value is list of UIDs 1358 */ 1359 public function findPageIdsForVersionStateChange($table, array $idList, $workspaceId, array &$pageIdList, array &$elementList) 1360 { 1361 if ($workspaceId == 0) { 1362 return; 1363 } 1364 1365 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1366 ->getQueryBuilderForTable($table); 1367 $queryBuilder->getRestrictions() 1368 ->removeAll() 1369 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); 1370 1371 $statement = $queryBuilder 1372 ->select('B.pid') 1373 ->from($table, 'A') 1374 ->from($table, 'B') 1375 ->where( 1376 $queryBuilder->expr()->gt( 1377 'A.t3ver_oid', 1378 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1379 ), 1380 $queryBuilder->expr()->eq( 1381 'A.t3ver_wsid', 1382 $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT) 1383 ), 1384 $queryBuilder->expr()->in( 1385 'A.uid', 1386 $queryBuilder->createNamedParameter($idList, Connection::PARAM_INT_ARRAY) 1387 ), 1388 $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid')) 1389 ) 1390 ->groupBy('B.pid') 1391 ->executeQuery(); 1392 1393 while ($row = $statement->fetchAssociative()) { 1394 $pageIdList[] = $row['pid']; 1395 // Find ws version 1396 // Note: cannot use BackendUtility::getRecordWSOL() 1397 // here because it does not accept workspace id! 1398 $rec = BackendUtility::getRecord('pages', $row[0]); 1399 BackendUtility::workspaceOL('pages', $rec, $workspaceId); 1400 if ($rec['_ORIG_uid']) { 1401 $elementList['pages'][$row[0]] = $rec['_ORIG_uid']; 1402 } 1403 } 1404 // The line below is necessary even with DISTINCT 1405 // because several elements can be passed by caller 1406 $pageIdList = array_unique($pageIdList); 1407 } 1408 1409 /** 1410 * Finds real page IDs for state change. 1411 * 1412 * @param array $idList List of page UIDs, possibly versioned 1413 */ 1414 public function findRealPageIds(array &$idList): void 1415 { 1416 foreach ($idList as $key => $id) { 1417 $rec = BackendUtility::getRecord('pages', $id, 't3ver_oid'); 1418 if ($rec['t3ver_oid'] > 0) { 1419 $idList[$key] = $rec['t3ver_oid']; 1420 } 1421 } 1422 } 1423 1424 /** 1425 * Moves a versioned record, which is not new or deleted. 1426 * 1427 * This is critical for a versioned record to be marked as MOVED (t3ver_state=4) 1428 * 1429 * @param string $table Table name to move 1430 * @param int $liveUid Record uid to move (online record) 1431 * @param int $destPid Position to move to: $destPid: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if 1432 * @param int $versionedRecordUid UID of offline version of online record 1433 * @param DataHandler $dataHandler DataHandler object 1434 * @see moveRecord() 1435 */ 1436 protected function moveRecord_moveVersionedRecord(string $table, int $liveUid, int $destPid, int $versionedRecordUid, DataHandler $dataHandler): void 1437 { 1438 // If a record gets moved after a record that already has a versioned record 1439 // then the versioned record needs to be placed after the existing one 1440 $originalRecordDestinationPid = $destPid; 1441 $movedTargetRecordInWorkspace = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, abs($destPid), 'uid'); 1442 if (is_array($movedTargetRecordInWorkspace) && $destPid < 0) { 1443 $destPid = -$movedTargetRecordInWorkspace['uid']; 1444 } 1445 $dataHandler->moveRecord_raw($table, $versionedRecordUid, $destPid); 1446 1447 $versionedRecord = BackendUtility::getRecord($table, $versionedRecordUid, 'uid,t3ver_state'); 1448 if (!VersionState::cast($versionedRecord['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) { 1449 // Update the state of this record to a move placeholder. This is allowed if the 1450 // record is a 'changed' (t3ver_state=0) record: Changing a record and moving it 1451 // around later, should switch it from 'changed' to 'moved'. Deleted placeholders 1452 // however are an 'end-state', they should not be switched to a move placeholder. 1453 // Scenario: For a live page that has a localization, the localization is first 1454 // marked as to-delete in workspace, creating a delete placeholder for that 1455 // localization. Later, the page is moved around, moving the localization along 1456 // with the default language record. The localization should then NOT be switched 1457 // from 'to-delete' to 'moved', this would loose the 'to-delete' information. 1458 GeneralUtility::makeInstance(ConnectionPool::class) 1459 ->getConnectionForTable($table) 1460 ->update( 1461 $table, 1462 [ 1463 't3ver_state' => (string)new VersionState(VersionState::MOVE_POINTER), 1464 ], 1465 [ 1466 'uid' => (int)$versionedRecordUid, 1467 ] 1468 ); 1469 } 1470 1471 // Check for the localizations of that element and move them as well 1472 $dataHandler->moveL10nOverlayRecords($table, $liveUid, $destPid, $originalRecordDestinationPid); 1473 } 1474 1475 /** 1476 * Gets an instance of the command map helper. 1477 * 1478 * @param DataHandler $dataHandler DataHandler object 1479 * @return CommandMap 1480 */ 1481 public function getCommandMap(DataHandler $dataHandler): CommandMap 1482 { 1483 return GeneralUtility::makeInstance( 1484 CommandMap::class, 1485 $this, 1486 $dataHandler, 1487 $dataHandler->cmdmap, 1488 $dataHandler->BE_USER->workspace 1489 ); 1490 } 1491 1492 protected function emitUpdateTopbarSignal(): void 1493 { 1494 BackendUtility::setUpdateSignal('updateTopbar'); 1495 } 1496 1497 /** 1498 * Returns all fieldnames from a table which have the unique evaluation type set. 1499 * 1500 * @param string $table Table name 1501 * @return array Array of fieldnames 1502 */ 1503 protected function getUniqueFields($table): array 1504 { 1505 $listArr = []; 1506 foreach ($GLOBALS['TCA'][$table]['columns'] ?? [] as $field => $configArr) { 1507 if ($configArr['config']['type'] === 'input') { 1508 $evalCodesArray = GeneralUtility::trimExplode(',', $configArr['config']['eval'] ?? '', true); 1509 if (in_array('uniqueInPid', $evalCodesArray) || in_array('unique', $evalCodesArray)) { 1510 $listArr[] = $field; 1511 } 1512 } 1513 } 1514 return $listArr; 1515 } 1516 1517 /** 1518 * Straight db based record deletion: sets deleted = 1 for soft-delete 1519 * enabled tables, or removes row from table. Used for move placeholder 1520 * records sometimes. 1521 */ 1522 protected function softOrHardDeleteSingleRecord(string $table, int $uid): void 1523 { 1524 $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null; 1525 if ($deleteField) { 1526 GeneralUtility::makeInstance(ConnectionPool::class) 1527 ->getConnectionForTable($table) 1528 ->update( 1529 $table, 1530 [$deleteField => 1], 1531 ['uid' => $uid], 1532 [\PDO::PARAM_INT] 1533 ); 1534 } else { 1535 GeneralUtility::makeInstance(ConnectionPool::class) 1536 ->getConnectionForTable($table) 1537 ->delete( 1538 $table, 1539 ['uid' => $uid] 1540 ); 1541 } 1542 } 1543 1544 /** 1545 * @return RelationHandler 1546 */ 1547 protected function createRelationHandlerInstance(): RelationHandler 1548 { 1549 return GeneralUtility::makeInstance(RelationHandler::class); 1550 } 1551 1552 /** 1553 * @return LanguageService 1554 */ 1555 protected function getLanguageService(): LanguageService 1556 { 1557 return $GLOBALS['LANG']; 1558 } 1559} 1560