‪TYPO3CMS  ‪main
DataHandler.php
Go to the documentation of this file.
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 
17 
18 use Doctrine\DBAL\Exception as DBALException;
19 use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
20 use Doctrine\DBAL\Types\IntegerType;
21 use Doctrine\DBAL\Types\JsonType;
22 use Psr\Container\ContainerInterface;
23 use Psr\EventDispatcher\EventDispatcherInterface;
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerAwareTrait;
26 use Symfony\Component\Uid\Uuid;
27 use TYPO3\CMS\Backend\Utility\BackendUtility;
40 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
54 use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
67 use ‪TYPO3\CMS\Core\SysLog\Action\Cache as SystemLogCacheAction;
68 use ‪TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
69 use ‪TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
70 use ‪TYPO3\CMS\Core\SysLog\Type as SystemLogType;
79 
93 class ‪DataHandler implements LoggerAwareInterface
94 {
95  use ‪LogDataTrait;
96  use LoggerAwareTrait;
97 
98  // *********************
99  // Public variables you can configure before using the class:
100  // *********************
105  public bool ‪$storeLogMessages = true;
106 
110  public bool ‪$enableLogging = true;
111 
116  public bool ‪$reverseOrder = false;
117 
119  public ‪$checkStoredRecords = true;
122 
126  public bool ‪$neverHideAtCopy = false;
127 
131  public bool ‪$isImporting = false;
132 
136  public bool ‪$dontProcessTransformations = false;
137 
143  protected bool ‪$useTransOrigPointerField = true;
144 
151  public bool ‪$bypassWorkspaceRestrictions = false;
152 
157  public bool ‪$bypassAccessCheckForRecords = false;
158 
165  public string ‪$copyWhichTables = '*';
166 
174  public ‪$copyTree = 0;
175 
184  public array ‪$defaultValues = [];
185 
193  public array ‪$suggestedInsertUids = [];
194 
201  public ?object ‪$callBackObj = null;
202 
207  protected ?‪CorrelationId ‪$correlationId = null;
208 
209  // *********************
210  // Internal variables (mapping arrays) which can be used (read-only) from outside
211  // *********************
218  public array ‪$autoVersionIdMap = [];
219 
225  public array ‪$substNEWwithIDs = [];
226 
232  public array ‪$substNEWwithIDs_table = [];
233 
239  public array ‪$newRelatedIDs = [];
240 
246  public array ‪$copyMappingArray_merged = [];
247 
251  protected array ‪$deletedRecords = [];
252 
258  public array ‪$errorLog = [];
259 
263  public array ‪$pagetreeRefreshFieldsFromPages = ['pid', 'sorting', 'deleted', 'hidden', 'title', 'doktype', 'is_siteroot', 'fe_group', 'nav_hide', 'nav_title', 'module', 'starttime', 'endtime', 'content_from_pid', 'extendToSubpages'];
264 
270  public bool ‪$pagetreeNeedsRefresh = false;
271 
272  // *********************
273  // Internal Variables, do not touch.
274  // *********************
275 
276  // Variables set in init() function:
277 
282 
288  public int ‪$userid;
289 
295  public bool ‪$admin;
296 
298 
302  protected array ‪$excludedTablesAndFields = [];
303 
308  protected array ‪$control = [];
309 
318  public array ‪$datamap = [];
319 
328  public array ‪$cmdmap = [];
329 
333  protected array ‪$mmHistoryRecords = [];
334 
338  protected array ‪$historyRecords = [];
339 
340  // Internal static:
341 
349  public int ‪$sortIntervals = 256;
350 
351  // Internal caching arrays
355  protected array ‪$recInsertAccessCache = [];
356 
360  protected array ‪$isRecordInWebMount_Cache = [];
361 
365  protected array ‪$isInWebMount_Cache = [];
366 
372  protected array ‪$pageCache = [];
373 
374  // Other arrays:
380  public array ‪$dbAnalysisStore = [];
381 
388  public array ‪$registerDBList = [];
389 
395  public array ‪$registerDBPids = [];
396 
408  public array ‪$copyMappingArray = [];
409 
415  public array ‪$remapStack = [];
416 
423  public array ‪$remapStackRecords = [];
424 
428  protected array ‪$remapStackActions = [];
429 
438 
439  // Various
440 
447  public ‪$checkValue_currentRecord = [];
448 
452  protected bool ‪$disableDeleteClause = false;
453 
454  protected ?array ‪$checkModifyAccessListHookObjects = null;
455 
460  protected ?self ‪$outerMostInstance = null;
461 
465  protected static array ‪$recordsToClearCacheFor = [];
466 
471  protected static array ‪$recordPidsForDeletedRecords = [];
472 
474  private readonly ‪FrontendInterface ‪$runtimeCache;
475  private readonly ‪ConnectionPool ‪$connectionPool;
476 
480  protected const ‪CACHE_IDENTIFIER_NESTED_ELEMENT_CALLS_PREFIX = 'core-datahandler-nestedElementCalls-';
481  protected const ‪CACHE_IDENTIFIER_ELEMENTS_TO_BE_DELETED = 'core-datahandler-elementsToBeDeleted';
482 
489  {
490  $this->cacheManager = GeneralUtility::makeInstance(CacheManager::class);
491  $this->runtimeCache = $this->cacheManager->getCache('runtime');
492  $this->connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
493  $this->pagePermissionAssembler = GeneralUtility::makeInstance(PagePermissionAssembler::class, ‪$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']);
494  if (‪$referenceIndexUpdater === null) {
495  // Create ReferenceIndexUpdater object. This should only happen on outermost instance,
496  // sub instances should receive the reference index updater from a parent.
497  ‪$referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
498  }
499  $this->referenceIndexUpdater = ‪$referenceIndexUpdater;
500  }
501 
505  public function ‪setControl(array ‪$control): void
506  {
507  $this->control = ‪$control;
508  }
509 
519  public function ‪start(array $dataMap, array $commandMap, ?BackendUserAuthentication $backendUser = null): void
520  {
521  // Initializing BE_USER
522  $this->BE_USER = $backendUser ?: ‪$GLOBALS['BE_USER'];
523  $this->userid = (int)($this->BE_USER->user['uid'] ?? 0);
524  $this->admin = $this->BE_USER->user['admin'] ?? false;
525 
526  // set correlation id for each new set of data or commands
527  $this->correlationId = ‪CorrelationId::forScope(
528  md5(‪StringUtility::getUniqueId(self::class))
529  );
530 
531  // Get default values from user TSconfig
532  $tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
533  if (is_array($tcaDefaultOverride)) {
534  $this->‪setDefaultsFromUserTS($tcaDefaultOverride);
535  }
536 
537  // generates the excludelist, based on TCA/exclude-flag and non_exclude_fields for the user:
538  if (!$this->admin) {
539  $this->excludedTablesAndFields = array_flip($this->‪getExcludeListArray());
540  }
541 
542  foreach ($dataMap as $tableName => $tableRecordArray) {
543  // @todo: Move this to a public setter and call it here. Then protect the property.
544  if (!is_string($tableName) || !is_array($tableRecordArray)) {
545  throw new \UnexpectedValueException('Data array must be shaped ["tableName" => [uid/"NEW.." => ["fieldName" => value]]]', 1709035799);
546  }
547  }
548  $this->datamap = $dataMap;
549 
550  foreach ($commandMap as $idCommandArray) {
551  // @todo: Move this to a public setter and call it here. Then protect the property.
552  if (!is_array($idCommandArray)) {
553  throw new \UnexpectedValueException('Command array must be shaped ["table" => [uid => ["command" => value]]]', 1708586415);
554  }
555  foreach ($idCommandArray as $id => $commandValueArray) {
556  if (!‪MathUtility::canBeInterpretedAsInteger($id) || !is_array($commandValueArray)) {
557  throw new \UnexpectedValueException('Single record commands must be shaped [uid => ["command" => value]]', 1708586979);
558  }
559  }
560  }
561  $this->cmdmap = $commandMap;
562  }
563 
571  public function ‪setMirror($mirror): void
572  {
573  if (!is_array($mirror)) {
574  return;
575  }
576  foreach ($mirror as $table => $uid_array) {
577  if (!isset($this->datamap[$table])) {
578  continue;
579  }
580  foreach ($uid_array as $id => $uidList) {
581  if (!isset($this->datamap[$table][$id])) {
582  continue;
583  }
584  $theIdsInArray = ‪GeneralUtility::trimExplode(',', $uidList, true);
585  foreach ($theIdsInArray as $copyToUid) {
586  $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id];
587  }
588  }
589  }
590  }
591 
598  public function ‪setDefaultsFromUserTS($userTS): void
599  {
600  if (!is_array($userTS)) {
601  return;
602  }
603  foreach ($userTS as $k => $v) {
604  $k = mb_substr($k, 0, -1);
605  if (!$k || !is_array($v) || !isset(‪$GLOBALS['TCA'][$k])) {
606  continue;
607  }
608  if (is_array($this->defaultValues[$k] ?? false)) {
609  $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v);
610  } else {
611  $this->defaultValues[$k] = $v;
612  }
613  }
614  }
615 
623  protected function ‪applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray): array
624  {
625  // First set TCAdefaults respecting the given PageID
626  $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null;
627  // Re-apply $this->defaultValues settings
628  $this->‪setDefaultsFromUserTS($tcaDefaults);
629  $cleanFieldArray = $this->‪newFieldArray($table);
630  if (isset($prepopulatedFieldArray['pid'])) {
631  $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid'];
632  }
633  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? null;
634  if ($sortColumn !== null && isset($prepopulatedFieldArray[$sortColumn])) {
635  $cleanFieldArray[$sortColumn] = $prepopulatedFieldArray[$sortColumn];
636  }
637  return $cleanFieldArray;
638  }
639 
640  /*********************************************
641  *
642  * HOOKS
643  *
644  *********************************************/
659  public function ‪hook_processDatamap_afterDatabaseOperations(&$hookObjectsArr, &$status, &$table, &$id, &$fieldArray): void
660  {
661  // Process hook directly:
662  if (!isset($this->remapStackRecords[$table][$id])) {
663  foreach ($hookObjectsArr as $hookObj) {
664  if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) {
665  $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);
666  }
667  }
668  } else {
669  $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [
670  'status' => $status,
671  'fieldArray' => $fieldArray,
672  'hookObjectsArr' => $hookObjectsArr,
673  ];
674  }
675  }
676 
684  protected function ‪getCheckModifyAccessListHookObjects(): array
685  {
686  if ($this->checkModifyAccessListHookObjects === null) {
687  $this->checkModifyAccessListHookObjects = [];
688  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) {
689  $hookObject = GeneralUtility::makeInstance($className);
690  if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) {
691  throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472);
692  }
693  $this->checkModifyAccessListHookObjects[] = $hookObject;
694  }
695  }
697  }
698 
699  /*********************************************
700  *
701  * PROCESSING DATA
702  *
703  *********************************************/
710  public function ‪process_datamap()
711  {
712  $this->‪controlActiveElements();
713 
714  // Keep versionized(!) relations here locally:
715  $registerDBList = [];
717  $this->datamap = $this->‪unsetElementsToBeDeleted($this->datamap);
718  // Editing frozen:
719  if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
720  $this->‪log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen');
721  return false;
722  }
723  // First prepare user defined objects (if any) for hooks which extend this function:
724  $hookObjectsArr = [];
725  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) {
726  $hookObject = GeneralUtility::makeInstance($className);
727  if (method_exists($hookObject, 'processDatamap_beforeStart')) {
728  $hookObject->processDatamap_beforeStart($this);
729  }
730  $hookObjectsArr[] = $hookObject;
731  }
732 
733  foreach ($this->datamap as $tableName => $tableDataMap) {
734  foreach ($tableDataMap as ‪$identifier => $fieldValues) {
736  $this->datamap[$tableName][‪$identifier] = $this->‪initializeSlugFieldsToEmptyString($tableName, $fieldValues);
737  }
738  }
739  }
740 
741  $this->datamap = DataMapProcessor::instance($this->datamap, $this->BE_USER, $this->referenceIndexUpdater)->process();
742  // Organize tables so that the pages-table is always processed first. This is required if you want to make sure that content pointing to a new page will be created.
743  $orderOfTables = [];
744  // Set pages first.
745  if (isset($this->datamap['pages'])) {
746  $orderOfTables[] = 'pages';
747  }
748  $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap)));
749  // Process the tables...
750  foreach ($orderOfTables as $table) {
751  // Check if
752  // - table is set in $GLOBALS['TCA'],
753  // - table is NOT readOnly
754  // - the table is set with content in the data-array (if not, there's nothing to process...)
755  // - permissions for tableaccess OK
756  $modifyAccessList = $this->‪checkModifyAccessList($table);
757  if (!$modifyAccessList) {
758  $this->‪log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', 1, ['table' => $table]);
759  }
760  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->‪tableReadOnly($table) || !is_array($this->datamap[$table]) || !$modifyAccessList) {
761  continue;
762  }
763 
764  if ($this->reverseOrder) {
765  $this->datamap[$table] = array_reverse($this->datamap[$table], true);
766  }
767  // For each record from the table, do:
768  // $id is the record uid, may be a string if new records...
769  // $incomingFieldArray is the array of fields
770  foreach ($this->datamap[$table] as $id => $incomingFieldArray) {
771  if (!is_array($incomingFieldArray)) {
772  continue;
773  }
774  $theRealPid = null;
775 
776  // Hook: processDatamap_preProcessFieldArray
777  foreach ($hookObjectsArr as $hookObj) {
778  if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) {
779  $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this);
780  // in case hook invalidated `$incomingFieldArray`, skip the record completely
781  if (!is_array($incomingFieldArray)) {
782  continue 2;
783  }
784  }
785  }
786  // ******************************
787  // Checking access to the record
788  // ******************************
789  $createNewVersion = false;
790  $old_pid_value = '';
791  // Is it a new record? (Then Id is a string)
793  // Get a fieldArray with tca default values
794  $fieldArray = $this->‪newFieldArray($table);
795  // A pid must be set for new records.
796  if (isset($incomingFieldArray['pid'])) {
797  $pid_value = $incomingFieldArray['pid'];
798  // Checking and finding numerical pid, it may be a string-reference to another value
799  $canProceed = true;
800  // If a NEW... id
801  if (str_contains($pid_value, 'NEW')) {
802  if ($pid_value[0] === '-') {
803  $negFlag = -1;
804  $pid_value = substr($pid_value, 1);
805  } else {
806  $negFlag = 1;
807  }
808  // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record.
809  if (isset($this->substNEWwithIDs[$pid_value])) {
810  if ($negFlag === 1) {
811  $old_pid_value = $this->substNEWwithIDs[$pid_value];
812  }
813  $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]);
814  } else {
815  $canProceed = false;
816  }
817  }
818  $pid_value = (int)$pid_value;
819  if ($canProceed) {
820  $fieldArray = $this->‪resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray);
821  }
822  }
823  $theRealPid = $fieldArray['pid'];
824  // Checks if records can be inserted on this $pid.
825  // If this is a page translation, the check needs to be done for the l10n_parent record
826  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
827  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
828  if ($table === 'pages'
829  && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0
830  && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0
831  ) {
832  $recordAccess = $this->‪checkRecordInsertAccess($table, $incomingFieldArray[$transOrigPointerField]);
833  } else {
834  $recordAccess = $this->‪checkRecordInsertAccess($table, $theRealPid);
835  }
836  if ($recordAccess) {
837  $incomingFieldArray = $this->‪addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray, $theRealPid);
838  $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
839  if (!$recordAccess) {
840  $this->‪log($table, 0, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
841  } elseif (!$this->bypassWorkspaceRestrictions && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
842  // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
843  // So, if no live records were allowed in the current workspace, we have to create a new version of this record
844  if (BackendUtility::isTableWorkspaceEnabled($table)) {
845  $createNewVersion = true;
846  } else {
847  $recordAccess = false;
848  $this->‪log(
849  $table,
850  0,
851  SystemLogDatabaseAction::VERSIONIZE,
852  0,
853  SystemLogErrorClassification::USER_ERROR,
854  'Attempt to insert version record "{table}:{uid}" to this workspace failed. "Live" edit permissions of records from tables without versioning required',
855  -1,
856  [
857  'table' => $table,
858  'uid' => $id,
859  ]
860  );
861  }
862  }
863  }
864  // Yes new record, change $record_status to 'insert'
865  $status = 'new';
866  } else {
867  // Nope... $id is a number
868  $id = (int)$id;
869  $fieldArray = [];
870 
871  $recordAccess = null;
872  if (is_array($hookObjectsArr)) {
873  foreach ($hookObjectsArr as $hookObj) {
874  if (method_exists($hookObj, 'checkRecordUpdateAccess')) {
875  $recordAccess = $hookObj->checkRecordUpdateAccess($table, $id, $incomingFieldArray, $recordAccess, $this);
876  }
877  }
878  }
879  if ($recordAccess !== null) {
880  $recordAccess = (bool)$recordAccess;
881  } else {
882  $recordAccess = $this->‪checkRecordUpdateAccess($table, $id);
883  }
884  if (!$recordAccess) {
885  if ($this->enableLogging) {
886  $propArr = $this->‪getRecordProperties($table, $id);
887  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record "{title}" ({table}:{uid}) without permission or non-existing page', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => $id], $propArr['event_pid']);
888  }
889  continue;
890  }
891 
892  // Next check of the record permissions (internals)
893  $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $id);
894  if (!$recordAccess) {
895  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
896  } else {
897  // Here we fetch the PID of the record that we point to...
898  $tempdata = $this->‪recordInfo($table, $id);
899  $theRealPid = $tempdata['pid'] ?? null;
900  // Use the new id of the versionized record we're trying to write to:
901  // (This record is a child record of a parent and has already been versionized.)
902  if (!empty($this->autoVersionIdMap[$table][$id])) {
903  // For the reason that creating a new version of this record, automatically
904  // created related child records (e.g. "IRRE"), update the accordant field:
905  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, ‪$registerDBList);
906  // Use the new id of the copied/versionized record:
907  $id = $this->autoVersionIdMap[$table][$id];
908  $recordAccess = true;
909  } elseif (!$this->bypassWorkspaceRestrictions && $tempdata && ($errorCode = $this->‪workspaceCannotEditRecord($table, $tempdata))) {
910  $recordAccess = false;
911  // Versioning is required and it must be offline version!
912  // Check if there already is a workspace version
913  $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
914  if ($workspaceVersion) {
915  $id = $workspaceVersion['uid'];
916  $recordAccess = true;
917  } elseif ($this->‪workspaceAllowAutoCreation($table, $id, $theRealPid)) {
918  // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
919  $this->pagetreeNeedsRefresh = true;
920 
921  $tce = GeneralUtility::makeInstance(self::class, $this->referenceIndexUpdater);
922  $tce->enableLogging = ‪$this->enableLogging;
923  // Setting up command for creating a new version of the record:
924  $cmd = [];
925  $cmd[$table][$id]['version'] = [
926  'action' => 'new',
927  // Default is to create a version of the individual records
928  'label' => 'Auto-created for WS #' . $this->BE_USER->workspace,
929  ];
930  $tce->start([], $cmd, $this->BE_USER);
931  $tce->process_cmdmap();
932  $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
933  // If copying was successful, share the new uids (also of related children):
934  if (!empty($tce->copyMappingArray[$table][$id])) {
935  foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
936  foreach ($origIdArray as $origId => $newId) {
937  $this->autoVersionIdMap[$origTable][$origId] = $newId;
938  }
939  }
940  // Update registerDBList, that holds the copied relations to child records:
941  ‪$registerDBList = array_merge(‪$registerDBList, $tce->registerDBList);
942  // For the reason that creating a new version of this record, automatically
943  // created related child records (e.g. "IRRE"), update the accordant field:
944  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, ‪$registerDBList);
945  // Use the new id of the copied/versionized record:
946  $id = $this->autoVersionIdMap[$table][$id];
947  $recordAccess = true;
948  } else {
949  $this->‪log(
950  $table,
951  $id,
952  SystemLogDatabaseAction::VERSIONIZE,
953  0,
954  SystemLogErrorClassification::USER_ERROR,
955  'Attempt to version record "{table}:{uid}" failed [{reason}]',
956  -1,
957  [
958  'reason' => $errorCode,
959  'table' => $table,
960  'uid' => $id,
961  ]
962  );
963  }
964  } else {
965  $this->‪log(
966  $table,
967  $id,
968  SystemLogDatabaseAction::VERSIONIZE,
969  0,
970  SystemLogErrorClassification::USER_ERROR,
971  'Attempt to version record "{table}:{uid}" failed [{reason}]. "Live" edit permissions of records from tables without versioning required',
972  -1,
973  [
974  'reason' => $errorCode,
975  'table' => $table,
976  'uid' => $id,
977  ]
978  );
979  }
980  }
981  }
982  // The default is 'update'
983  $status = 'update';
984  }
985  // If access was granted above, proceed to create or update record:
986  if (!$recordAccess) {
987  continue;
988  }
989 
990  // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
991  [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
992  if ($status === 'new') {
993  // Apply TCAdefaults from pageTS
994  $fieldArray = $this->‪applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
995  // Apply page permissions as well
996  if ($table === 'pages') {
997  $fieldArray = $this->pagePermissionAssembler->applyDefaults(
998  $fieldArray,
999  (int)$tscPID,
1000  (int)$this->userid,
1001  (int)$this->BE_USER->firstMainGroup
1002  );
1003  }
1004  // Ensure that the default values, that are stored in the $fieldArray (built from internal default values)
1005  // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and
1006  // all default values are also checked for validity
1007  // This allows to set TCAdefaults (for example) without having to use FormEngine to have the fields available first.
1008  $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray);
1009  }
1010  // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1011  $fieldArray = $this->‪fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1012  // Setting system fields
1013  if ($status === 'new') {
1014  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
1015  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
1016  }
1017  }
1018  // Set stage to "Editing" to make sure we restart the workflow
1019  if (BackendUtility::isTableWorkspaceEnabled($table)) {
1020  $fieldArray['t3ver_stage'] = 0;
1021  }
1022  if ($status !== 'new') {
1023  // Removing fields which are equal to the current value:
1024  $fieldArray = $this->‪compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1025  }
1026  if ((‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) && !empty($fieldArray)) {
1027  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
1028  }
1029  // Hook: processDatamap_postProcessFieldArray
1030  foreach ($hookObjectsArr as $hookObj) {
1031  if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1032  $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1033  }
1034  }
1035  // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1036  // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1037  if (is_array($fieldArray)) {
1038  if ($status === 'new') {
1039  if ($table === 'pages') {
1040  // for new pages always a refresh is needed
1041  $this->pagetreeNeedsRefresh = true;
1042  }
1043 
1044  // This creates a version of the record, instead of adding it to the live workspace
1045  if ($createNewVersion) {
1046  // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1047  $this->pagetreeNeedsRefresh = true;
1048  $fieldArray['pid'] = $theRealPid;
1049  $fieldArray['t3ver_oid'] = 0;
1050  // Setting state for version (so it can know it is currently a new version...)
1051  $fieldArray['t3ver_state'] = VersionState::NEW_PLACEHOLDER->value;
1052  $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace;
1053  $this->‪insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1054  // Hold auto-versionized ids of placeholders
1055  $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1056  } else {
1057  $this->‪insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1058  }
1059  } else {
1060  if ($table === 'pages') {
1061  // Only a certain number of fields needs to be checked for updates,
1062  // fields with unchanged values are already removed here.
1063  $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1064  if (!empty($fieldsToCheck)) {
1065  $this->pagetreeNeedsRefresh = true;
1066  }
1067  }
1068  $this->‪updateDB($table, $id, $fieldArray);
1069  }
1070  }
1071  // Hook: processDatamap_afterDatabaseOperations
1072  // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1073  // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1074  $this->‪hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1075  }
1076  }
1077  // Process the stack of relations to remap/correct
1078  $this->‪processRemapStack();
1079  $this->‪dbAnalysisStoreExec();
1080  // Hook: processDatamap_afterAllOperations
1081  // Note: When this hook gets called, all operations on the submitted data have been finished.
1082  foreach ($hookObjectsArr as $hookObj) {
1083  if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1084  $hookObj->processDatamap_afterAllOperations($this);
1085  }
1086  }
1087 
1088  if ($this->‪isOuterMostInstance()) {
1089  $this->referenceIndexUpdater->update();
1090  $this->‪processClearCacheQueue();
1091  $this->‪resetElementsToBeDeleted();
1092  }
1093  }
1094 
1101  protected function ‪initializeSlugFieldsToEmptyString(string $tableName, array $fieldValues): array
1102  {
1103  foreach ((‪$GLOBALS['TCA'][$tableName]['columns'] ?? []) as $columnName => $columnConfig) {
1104  if (($columnConfig['config']['type'] ?? '') === 'slug' && !isset($fieldValues[$columnName])) {
1105  $fieldValues[$columnName] = '';
1106  }
1107  }
1108  return $fieldValues;
1109  }
1110 
1122  protected function ‪resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1123  {
1124  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1125  // Points to a page on which to insert the element, possibly in the top of the page
1126  if ($pid >= 0) {
1127  // Ensure that the "pid" is not a translated page ID, but the default page ID
1128  $pid = $this->‪getDefaultLanguagePageId($pid);
1129  // The numerical pid is inserted in the data array
1130  $fieldArray['pid'] = $pid;
1131  // If this table is sorted we better find the top sorting number
1132  if ($sortColumn) {
1133  $fieldArray[$sortColumn] = $this->‪getSortNumber($table, 0, $pid);
1134  }
1135  } elseif ($sortColumn) {
1136  // Points to another record before itself
1137  // If this table is sorted we better find the top sorting number
1138  // Because $pid is < 0, getSortNumber() returns an array
1139  $sortingInfo = $this->‪getSortNumber($table, 0, $pid);
1140  $fieldArray['pid'] = $sortingInfo['pid'];
1141  $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1142  } else {
1143  // Here we fetch the PID of the record that we point to
1144  ‪$record = $this->‪recordInfo($table, abs($pid));
1145  // Ensure that the "pid" is not a translated page ID, but the default page ID
1146  $fieldArray['pid'] = $this->‪getDefaultLanguagePageId(‪$record['pid']);
1147  }
1148  return $fieldArray;
1149  }
1150 
1165  public function ‪fillInFieldArray($table, $id, array $fieldArray, array $incomingFieldArray, $realPid, $status, $tscPID)
1166  {
1167  // Initialize:
1168  $originalLanguageRecord = null;
1169  $originalLanguage_diffStorage = null;
1170  $diffStorageFlag = false;
1171  $isNewRecord = str_contains((string)$id, 'NEW');
1172  // Setting 'currentRecord' and 'checkValueRecord':
1173  if ($isNewRecord) {
1174  // Overlay default values with incoming values.
1175  $checkValueRecord = $fieldArray;
1176  ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
1177  $currentRecord = $checkValueRecord;
1178  } else {
1179  $id = (int)$id;
1180  // We must use the current values as basis for this!
1181  $currentRecord = ($checkValueRecord = $this->‪recordInfo($table, $id));
1182  }
1183 
1184  // Get original language record if available:
1185  if (is_array($currentRecord)
1186  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false)
1187  && !empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField'])
1188  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] ?? 0) > 0
1189  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? false)
1190  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] ?? 0) > 0
1191  ) {
1192  $originalLanguageRecord = $this->‪recordInfo($table, $currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]);
1193  BackendUtility::workspaceOL($table, $originalLanguageRecord);
1194  $originalLanguage_diffStorage = json_decode(
1195  (string)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] ?? ''),
1196  true
1197  );
1198  }
1199 
1200  $this->checkValue_currentRecord = $checkValueRecord;
1201  // In the following all incoming value-fields are tested:
1202  // - Are the user allowed to change the field?
1203  // - Is the field uid/pid (which are already set)
1204  // - perms-fields for pages-table, then do special things...
1205  // - If the field is nothing of the above and the field is configured in TCA, the fieldvalues are evaluated by ->checkValue
1206  // If everything is OK, the field is entered into $fieldArray[]
1207  foreach ($incomingFieldArray as $field => $fieldValue) {
1208  if (isset($this->excludedTablesAndFields[$table . '-' . $field])) {
1209  continue;
1210  }
1211 
1212  // The field must be editable.
1213  // Checking if a value for language can be changed:
1214  if ((‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false)
1215  && (string)‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] === (string)$field
1216  && !$this->BE_USER->checkLanguageAccess($fieldValue)
1217  ) {
1218  continue;
1219  }
1220 
1221  switch ($field) {
1222  case 'uid':
1223  case 'pid':
1224  // Nothing happens, already set
1225  break;
1226  case 'perms_userid':
1227  case 'perms_groupid':
1228  case 'perms_user':
1229  case 'perms_group':
1230  case 'perms_everybody':
1231  // Permissions can be edited by the owner or the administrator
1232  if ($table === 'pages' && ($this->admin || $status === 'new' || $this->‪pageInfo((int)$id, 'perms_userid') == $this->userid)) {
1233  $value = (int)$fieldValue;
1234  switch ($field) {
1235  case 'perms_userid':
1236  case 'perms_groupid':
1237  $fieldArray[$field] = $value;
1238  break;
1239  default:
1240  if ($value >= 0 && $value < (2 ** 5)) {
1241  $fieldArray[$field] = $value;
1242  }
1243  }
1244  }
1245  break;
1246  case 't3ver_oid':
1247  case 't3ver_wsid':
1248  case 't3ver_state':
1249  case 't3ver_stage':
1250  break;
1251  case 'l10n_state':
1252  $fieldArray[$field] = $fieldValue;
1253  break;
1254  default:
1255  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
1256  // Evaluating the value
1257  $res = $this->‪checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray);
1258  if (array_key_exists('value', $res)) {
1259  $fieldArray[$field] = $res['value'];
1260  }
1261  // Add the value of the original record to the diff-storage content:
1262  if (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false) {
1263  $originalLanguage_diffStorage[$field] = (string)($originalLanguageRecord[$field] ?? '');
1264  $diffStorageFlag = true;
1265  }
1266  } elseif (isset(‪$GLOBALS['TCA'][$table]['ctrl']['origUid']) && ‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] === $field) {
1267  // Allow value for original UID to pass by...
1268  $fieldArray[$field] = $fieldValue;
1269  }
1270  }
1271  }
1272 
1273  // Dealing with a page translation, setting "sorting", "pid", "perms_*" to the same values as the original record
1274  if ($table === 'pages' && is_array($originalLanguageRecord)) {
1275  $fieldArray['sorting'] = $originalLanguageRecord['sorting'];
1276  $fieldArray['perms_userid'] = $originalLanguageRecord['perms_userid'];
1277  $fieldArray['perms_groupid'] = $originalLanguageRecord['perms_groupid'];
1278  $fieldArray['perms_user'] = $originalLanguageRecord['perms_user'];
1279  $fieldArray['perms_group'] = $originalLanguageRecord['perms_group'];
1280  $fieldArray['perms_everybody'] = $originalLanguageRecord['perms_everybody'];
1281  }
1282 
1283  // Add diff-storage information
1284  if ($diffStorageFlag
1285  && (
1286  !array_key_exists(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
1287  || ($isNewRecord && $originalLanguageRecord !== null)
1288  )
1289  ) {
1290  // If the field is set it would probably be because of an undo-operation - in which case we should not
1291  // update the field of course. On the other hand, e.g. for record localization, we need to update the field.
1292  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = json_encode($originalLanguage_diffStorage);
1293  }
1294  return $fieldArray;
1295  }
1296 
1297  /*********************************************
1298  *
1299  * Evaluation of input values
1300  *
1301  ********************************************/
1318  public function ‪checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = []): array
1319  {
1320  $curValueRec = null;
1321  // Result array
1322  $res = [];
1323 
1324  // Processing special case of field pages.doktype
1325  if ($table === 'pages' && $field === 'doktype') {
1326  // If the user may not use this specific doktype, we issue a warning
1327  if (!($this->admin || ‪GeneralUtility::inList($this->BE_USER->groupData['pagetypes_select'], $value))) {
1328  if ($this->enableLogging) {
1329  $propArr = $this->‪getRecordProperties($table, $id);
1330  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot change the "doktype" of page "{title}" to the desired value', 1, ['title' => $propArr['header']], $propArr['event_pid']);
1331  }
1332  return $res;
1333  }
1334  if ($status === 'update') {
1335  // This checks 1) if we should check for disallowed tables and 2) if there are records from disallowed tables on the current page
1336  $onlyAllowedTables = GeneralUtility::makeInstance(PageDoktypeRegistry::class)->doesDoktypeOnlyAllowSpecifiedRecordTypes((int)$value);
1337  if ($onlyAllowedTables) {
1338  // use the real page id (default language)
1339  $recordId = $this->‪getDefaultLanguagePageId((int)$id);
1340  $theWrongTables = $this->‪doesPageHaveUnallowedTables($recordId, (int)$value);
1341  if ($theWrongTables !== []) {
1342  if ($this->enableLogging) {
1343  $propArr = $this->‪getRecordProperties($table, $id);
1344  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, '"doktype" of page "{title}" could not be changed because the page contains records from disallowed tables; {disallowedTables}', 2, ['title' => $propArr['header'], 'disallowedTables' => implode(', ', $theWrongTables)], $propArr['event_pid']);
1345  }
1346  return $res;
1347  }
1348  }
1349  }
1350  }
1351 
1352  $curValue = null;
1353  if ((int)$id !== 0) {
1354  // Get current value:
1355  $curValueRec = $this->‪recordInfo($table, (int)$id);
1356  // isset() won't work here, since values can be NULL
1357  if ($curValueRec !== null && array_key_exists($field, $curValueRec)) {
1358  $curValue = $curValueRec[$field];
1359  }
1360  }
1361 
1362  if ($table === 'be_users'
1363  && ($field === 'admin' || $field === 'password')
1364  && $status === 'update'
1365  ) {
1366  // Do not allow a non system maintainer admin to change admin flag and password of system maintainers
1367  $systemMaintainers = array_map(intval(...), ‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
1368  // False if current user is not in system maintainer list or if switch to user mode is active
1369  $isCurrentUserSystemMaintainer = $this->BE_USER->isSystemMaintainer();
1370  $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true);
1371  if ($field === 'admin') {
1372  $isFieldChanged = (int)$curValueRec[$field] !== (int)$value;
1373  } else {
1374  $isFieldChanged = $curValueRec[$field] !== $value;
1375  }
1376  if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList && $isFieldChanged) {
1377  $value = $curValueRec[$field];
1378  $this->‪log(
1379  $table,
1380  (int)$id,
1381  SystemLogDatabaseAction::UPDATE,
1382  0,
1383  SystemLogErrorClassification::SECURITY_NOTICE,
1384  'Only system maintainers can change the admin flag and password of other system maintainers. The value has not been updated'
1385  );
1386  }
1387  }
1388 
1389  // Getting config for the field
1390  $tcaFieldConf = $this->‪resolveFieldConfigurationAndRespectColumnsOverrides($table, $field);
1391 
1392  // Create $recFID only for those types that need it
1393  if ($tcaFieldConf['type'] === 'flex') {
1394  $recFID = $table . ':' . $id . ':' . $field;
1395  } else {
1396  $recFID = '';
1397  }
1398 
1399  // Perform processing:
1400  $res = $this->‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
1401  return $res;
1402  }
1403 
1413  protected function ‪resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field): array
1414  {
1415  $tcaFieldConf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
1416  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1417  $columnsOverridesConfigOfField = ‪$GLOBALS['TCA'][$table]['types'][$recordType]['columnsOverrides'][$field]['config'] ?? null;
1418  if ($columnsOverridesConfigOfField) {
1419  ArrayUtility::mergeRecursiveWithOverrule($tcaFieldConf, $columnsOverridesConfigOfField);
1420  }
1421  return $tcaFieldConf;
1422  }
1423 
1444  public function ‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, array $additionalData = null): array
1445  {
1446  // Convert to NULL value if defined in TCA
1447  if ($value === null && ($tcaFieldConf['nullable'] ?? false)) {
1448  return ['value' => null];
1449  }
1450 
1451  // This is either a normal field or a FlexForm field.
1452  // Used to enrich the (potential) error log with contextual information.
1453  $checkField = $recFID !== '' ? explode(':', $recFID)[2] : $field;
1454 
1455  $res = (array)match ((string)$tcaFieldConf['type']) {
1456  'category' => $this->‪checkValueForCategory($res, (string)$value, $tcaFieldConf, (string)$table, $id, (string)$status, (string)$field),
1457  'check' => $this->‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field),
1458  'color' => $this->‪checkValueForColor((string)$value, $tcaFieldConf),
1459  'datetime' => $this->‪checkValueForDatetime($value, $tcaFieldConf),
1460  'email' => $this->‪checkValueForEmail((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $checkField),
1461  'flex' => $field ? $this->‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field) : [],
1462  'inline' => $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [],
1463  'file' => $this->‪checkValueForFile($res, (string)$value, $tcaFieldConf, $table, $id, $field, $additionalData),
1464  'input' => $this->‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field),
1465  'language' => $this->‪checkValueForLanguage((int)$value, $table, $field),
1466  'link' => $this->‪checkValueForLink((string)$value, $tcaFieldConf, $table, $id, $checkField),
1467  'number' => $this->‪checkValueForNumber($value, $tcaFieldConf),
1468  'password' => $this->‪checkValueForPassword((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $additionalData['incomingFieldArray'] ?? []),
1469  'radio' => $this->‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field),
1470  'slug' => $this->‪checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []),
1471  'text' => $this->‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field),
1472  'group', 'folder', 'select' => $this->‪checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field),
1473  'json' => $this->‪checkValueForJson($value, $tcaFieldConf),
1474  'uuid' => $this->‪checkValueForUuid((string)$value, $tcaFieldConf),
1475  'passthrough', 'imageManipulation', 'user' => ['value' => $value],
1476  default => [],
1477  };
1478 
1479  return $this->‪checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field);
1480  }
1481 
1500  protected function ‪checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field): array
1501  {
1502  $relevantFieldNames = [
1503  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null,
1504  ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null,
1505  ];
1506 
1507  if (
1508  // in case field is empty
1509  empty($field)
1510  // in case the field is not relevant
1511  || !in_array($field, $relevantFieldNames)
1512  // in case the 'value' index has been unset already
1513  || !array_key_exists('value', $res)
1514  // in case it's not a NEW-identifier
1515  || !str_contains($value, 'NEW')
1516  ) {
1517  return $res;
1518  }
1519 
1520  $valueArray = [$value];
1521  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1522  $this->remapStack[] = [
1523  'args' => [$valueArray, $tcaFieldConf, $id, $table, $field],
1524  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
1525  'field' => $field,
1526  ];
1527  unset($res['value']);
1528 
1529  return $res;
1530  }
1531 
1542  protected function ‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field)
1543  {
1544  $richtextEnabled = (bool)($tcaFieldConf['enableRichtext'] ?? false);
1545 
1546  // Reset value to empty string, if less than "min" characters.
1547  $min = $tcaFieldConf['min'] ?? 0;
1548  if (!$richtextEnabled && $min > 0 && mb_strlen((string)$value) < $min) {
1549  $value = '';
1550  }
1551 
1552  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1553  $valueArray = [];
1554  } elseif (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
1555  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1556  $valueArray = $this->‪checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '');
1557  } else {
1558  $valueArray = ['value' => $value];
1559  }
1560 
1561  // Handle richtext transformations
1562  if ($this->dontProcessTransformations) {
1563  return $valueArray;
1564  }
1565  // Keep null as value
1566  if ($value === null) {
1567  return $valueArray;
1568  }
1569  if ($richtextEnabled) {
1570  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1571  $richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
1572  $richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
1573  $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class);
1574  $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []);
1575  }
1576 
1577  return $valueArray;
1578  }
1579 
1591  protected function ‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field): array
1592  {
1593  // Secures the string-length to be less than max.
1594  if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) {
1595  $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
1596  }
1597 
1598  // Reset value to empty string, if less than "min" characters.
1599  $min = $tcaFieldConf['min'] ?? 0;
1600  if ($min > 0 && mb_strlen((string)$value) < $min) {
1601  $value = '';
1602  }
1603 
1604  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
1605  $res = [];
1606  } elseif (empty($tcaFieldConf['eval'])) {
1607  $res = ['value' => $value];
1608  } else {
1609  // Process evaluation settings:
1610  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1611  $res = $this->‪checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table, $id);
1612  // Process UNIQUE settings:
1613  // Field is NOT set for flexForms - which also means that uniqueInPid and unique is NOT available for flexForm fields! Also getUnique should not be done for versioning
1614  if ($field && !empty($res['value'])) {
1615  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1616  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id, $realPid);
1617  }
1618  if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1619  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id);
1620  }
1621  }
1622  }
1623 
1624  return $res;
1625  }
1626 
1633  protected function ‪checkValueForNumber(mixed $value, array $tcaFieldConf): array
1634  {
1635  $format = $tcaFieldConf['format'] ?? 'integer';
1636  if ($format !== 'integer' && $format !== 'decimal') {
1637  // Early return if format is not valid
1638  return [];
1639  }
1640 
1641  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
1642  return [];
1643  }
1644 
1645  if ($format === 'decimal') {
1646  // @todo Make precision configurable
1647  $precision = 2;
1648  $value = preg_replace('/[^0-9,\\.-]/', '', $value);
1649  $negative = substr($value, 0, 1) === '-';
1650  $value = strtr($value, [',' => '.', '-' => '']);
1651  if (!str_contains($value, '.')) {
1652  $value .= '.0';
1653  }
1654  $valueArray = explode('.', $value);
1655  $dec = array_pop($valueArray);
1656  $value = (float)(implode('', $valueArray) . '.' . $dec);
1657  if ($negative) {
1658  $value = $value * -1;
1659  }
1660  $result['value'] = number_format($value, $precision, '.', '');
1661  } else {
1662  $result['value'] = (int)$value;
1663  }
1664 
1665  // Checking range of value:
1666  if (is_array($tcaFieldConf['range'] ?? false)) {
1667  if (isset($tcaFieldConf['range']['upper']) && ceil($result['value']) > (int)$tcaFieldConf['range']['upper']) {
1668  $result['value'] = (int)$tcaFieldConf['range']['upper'];
1669  }
1670  if (isset($tcaFieldConf['range']['lower']) && floor($result['value']) < (int)$tcaFieldConf['range']['lower']) {
1671  $result['value'] = (int)$tcaFieldConf['range']['lower'];
1672  }
1673  }
1674 
1675  return $result;
1676  }
1677 
1685  protected function ‪checkValueForColor(string $value, array $tcaFieldConf): array
1686  {
1687  // Always trim the value
1688  $value = trim($value);
1689  // Secures the string-length to be <= 7.
1690  $value = mb_substr($value, 0, 7, 'utf-8');
1691  // Early return if required validation fails
1692  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1693  return [];
1694  }
1695  return [
1696  'value' => $value,
1697  ];
1698  }
1699 
1711  protected function ‪checkValueForEmail(
1712  string $value,
1713  array $tcaFieldConf,
1714  string $table,
1715  int|string $id,
1716  int $realPid,
1717  string $field
1718  ): array {
1719  // Always trim the value
1720  $value = trim($value);
1721 
1722  // Early return if required validation fails
1723  // Note: The "required" check is evaluated but does not yet lead to an error, see
1724  // the comment in the DataHandler::validateValueForRequired() for more information.
1725  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1726  return [];
1727  }
1728 
1729  if ($value !== '' && !GeneralUtility::validEmail($value)) {
1730  // A non-empty value is given, which however is no valid email. Log this and unset the value afterwards.
1731  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{email}" is not a valid e-mail address for the field "{field}" of the table "{table}"', -1, ['email' => $value, 'field' => $field, 'table' => $table]);
1732  $value = '';
1733  }
1734 
1735  $res = [
1736  'value' => $value,
1737  ];
1738 
1739  // Early return if no evaluation is configured
1740  if (!isset($tcaFieldConf['eval'])) {
1741  return $res;
1742  }
1743  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1744 
1745  // Process UNIQUE settings:
1746  // Field is NOT set for flexForms - which also means that uniqueInPid and unique is NOT available for flexForm fields! Also getUnique should not be done for versioning
1747  if ($field && !empty($res['value'])) {
1748  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1749  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id, $realPid);
1750  }
1751  if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1752  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id);
1753  }
1754  }
1755 
1756  return $res;
1757  }
1758 
1770  protected function ‪checkValueForPassword(
1771  string $value,
1772  array $tcaFieldConf,
1773  string $table,
1774  int|string $id,
1775  int $realPid,
1776  array $incomingFieldArray = []
1777  ): array {
1778  // Always trim the value
1779  $value = trim($value);
1780 
1781  // Early return if required validation fails
1782  // Note: The "required" check is evaluated but does not yet lead to an error, see
1783  // the comment in the DataHandler::validateValueForRequired() for more information.
1784  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1785  return [];
1786  }
1787 
1788  // Early return, if password hashing is disabled and the table is not fe_users or be_users
1789  if (!($tcaFieldConf['hashed'] ?? true) && !in_array($table, ['fe_users', 'be_users'], true)) {
1790  return [
1791  'value' => $value,
1792  ];
1793  }
1794 
1795  // An incoming value is either the salted password if the user did not change existing password
1796  // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
1797  // The strategy is to see if a salt instance can be created from the incoming value. If so,
1798  // no new password was submitted and we keep the value. If no salting instance can be created,
1799  // incoming value must be a new plain text value that needs to be hashed.
1800  $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
1801  $mode = $table === 'fe_users' ? 'FE' : 'BE';
1802  $isNewUser = str_contains((string)$id, 'NEW');
1803  $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
1804 
1805  try {
1806  $hashFactory->get($value, $mode);
1807  } catch (InvalidPasswordHashException $e) {
1808  // We got no salted password instance, incoming value must be a new plaintext password
1809  // Validate new password against password policy for field
1810  $passwordPolicy = $tcaFieldConf['passwordPolicy'] ?? '';
1811  $passwordPolicyValidator = GeneralUtility::makeInstance(
1812  PasswordPolicyValidator::class,
1814  is_string($passwordPolicy) ? $passwordPolicy : ''
1815  );
1816 
1817  $contextData = new ContextData(
1818  loginMode: $mode,
1819  newUsername: $incomingFieldArray['username'] ?? '',
1820  newUserFirstName: $incomingFieldArray['first_name'] ?? '',
1821  newUserLastName: $incomingFieldArray['last_name'] ?? '',
1822  newUserFullName: $incomingFieldArray['realName'] ?? '',
1823  );
1824  $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch(
1825  new EnrichPasswordValidationContextDataEvent(
1826  $contextData,
1827  $incomingFieldArray,
1828  self::class
1829  )
1830  );
1831  $contextData = $event->getContextData();
1832 
1833  $isValidPassword = $passwordPolicyValidator->isValidPassword($value, $contextData);
1834  if (!$isValidPassword) {
1835  $message = $this->‪getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:dataHandler.passwordNotSaved');
1836  $this->‪log(
1837  $table,
1838  (int)$id,
1839  SystemLogDatabaseAction::UPDATE,
1840  0,
1841  SystemLogErrorClassification::WARNING,
1842  $message . implode('. ', $passwordPolicyValidator->getValidationErrors()),
1843  -1,
1844  [
1845  'table' => $table,
1846  'uid' => (string)$id,
1847  ],
1848  $realPid
1849  );
1850 
1851  // Password not valid for existing user. Stopping here, password won't be changed
1852  if (!$isNewUser) {
1853  return [];
1854  }
1855  // Password not valid for new user. To prevent empty passwords in the database, we set a random password.
1856  $value = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
1857  }
1858 
1859  // Get an instance of the current configured salted password strategy and hash the value
1860  $value = $newHashInstance->getHashedPassword($value);
1861  }
1862 
1863  return [
1864  'value' => $value,
1865  ];
1866  }
1867 
1881  protected function ‪checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
1882  {
1883  $workspaceId = $this->BE_USER->workspace;
1884  $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
1885  $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray);
1886  // Generate a value if there is none, otherwise ensure that all characters are cleaned up
1887  if ($value === '') {
1888  $value = $helper->generate($fullRecord, $realPid);
1889  } else {
1890  $value = $helper->sanitize($value);
1891  }
1892 
1893  // Return directly in case no evaluations are defined
1894  if (empty($tcaFieldConf['eval'])) {
1895  return ['value' => $value];
1896  }
1897 
1898  $state = ‪RecordStateFactory::forName($table)
1899  ->fromArray($fullRecord, $realPid, $id);
1900  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1901  if (in_array('unique', $evalCodesArray, true)) {
1902  $value = $helper->buildSlugForUniqueInTable($value, $state);
1903  }
1904  if (in_array('uniqueInSite', $evalCodesArray, true)) {
1905  $value = $helper->buildSlugForUniqueInSite($value, $state);
1906  }
1907  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1908  $value = $helper->buildSlugForUniqueInPid($value, $state);
1909  }
1910 
1911  return ['value' => $value];
1912  }
1913 
1924  protected function ‪checkValueForLanguage(int $value, string $table, string $field): array
1925  {
1926  // If given table is localizable and the given field is the defined
1927  // languageField, check if the selected language is allowed for the user.
1928  // Note: Usually this method should never be reached, in case the language value is
1929  // not valid, since recordEditAccessInternals checks for proper permission beforehand.
1930  if (BackendUtility::isTableLocalizable($table)
1931  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') === $field
1932  && !$this->BE_USER->checkLanguageAccess($value)
1933  ) {
1934  return [];
1935  }
1936  // @todo Should we also check if the language is allowed for the current site - if record has site context?
1937  return ['value' => $value];
1938  }
1939 
1950  protected function ‪checkValueForLink(string $value, array $tcaFieldConf, string $table, int|string $id, string $field): array
1951  {
1952  // Always trim the value
1953  $value = trim($value);
1954 
1955  // Early return if required validation fails
1956  // Note: The "required" check is evaluated but does not yet lead to an error, see
1957  // the comment in the DataHandler::validateValueForRequired() for more information.
1958  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1959  return [];
1960  }
1961 
1962  // Early return if an empty allow list is defined for the link types
1963  if (is_array($tcaFieldConf['allowedTypes'] ?? false) && $tcaFieldConf['allowedTypes'] === []) {
1964  return [];
1965  }
1966 
1967  if ($value !== '') {
1968  // Extract the actual link from the link definition for further evaluation
1969  $linkParameter = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode($value)['url'];
1970  if ($linkParameter === '') {
1971  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link definition for the field "{field}" of the table "{table}"', -1, ['link' => $value, 'field' => $field, 'table' => $table]);
1972  $value = '';
1973  } else {
1974  // Try to resolve the actual link type and compare with the allow list
1975  try {
1976  $linkData = GeneralUtility::makeInstance(LinkService::class)->resolve($linkParameter);
1977  $linkType = $linkData['type'] ?? '';
1978  $linkIdentifier = $linkData['identifier'] ?? '';
1979  if (is_array($tcaFieldConf['allowedTypes'] ?? false)
1980  && ($tcaFieldConf['allowedTypes'][0] ?? '') !== '*'
1981  && !in_array($linkType, $tcaFieldConf['allowedTypes'], true)
1982  && ($linkType !== 'record' || !in_array($linkIdentifier, $tcaFieldConf['allowedTypes'], true))
1983  ) {
1984  $message = $linkIdentifier !== ''
1985  ? 'Link type "record" with identifier "{type}" is not allowed for the field "{field}" of the table "{table}"'
1986  : 'Link type "{type}" is not allowed for the field "{field}" of the table "{table}"';
1987  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, $message, -1, ['type' => $linkIdentifier ?: $linkType, 'field' => $field, 'table' => $table]);
1988  $value = '';
1989  }
1990  } catch (UnknownLinkHandlerException $e) {
1991  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link for the field "{field}" of the table "{table}"', -1, ['link' => $value, 'field' => $field, 'table' => $table]);
1992  $value = '';
1993  }
1994  }
1995  }
1996 
1997  return ['value' => $value];
1998  }
1999 
2011  protected function ‪checkValueForCategory(
2012  array $result,
2013  string $value,
2014  array $tcaFieldConf,
2015  string $table,
2016  $id,
2017  string $status,
2018  string $field
2019  ): array {
2020  // Exploded comma-separated values and remove duplicates
2021  $valueArray = array_unique(‪GeneralUtility::trimExplode(',', $value, true));
2022  // If an exclusive key is found, discard all others:
2023  if ($tcaFieldConf['exclusiveKeys'] ?? false) {
2024  $exclusiveKeys = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
2025  foreach ($valueArray as $index => $key) {
2026  if (in_array($key, $exclusiveKeys, true)) {
2027  $valueArray = [$index => $key];
2028  break;
2029  }
2030  }
2031  }
2032  $unsetResult = false;
2033  if (str_contains($value, 'NEW')) {
2034  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2035  $this->remapStack[] = [
2036  'func' => 'checkValue_category_processDBdata',
2037  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field],
2038  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2039  'field' => $field,
2040  ];
2041  $unsetResult = true;
2042  } else {
2043  $valueArray = $this->‪checkValue_category_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2044  }
2045  if ($unsetResult) {
2046  unset($result['value']);
2047  } else {
2048  $newVal = implode(',', $this->‪checkValue_checkMax($tcaFieldConf, $valueArray));
2049  $result['value'] = $newVal !== '' ? $newVal : 0;
2050  }
2051  return $result;
2052  }
2053 
2060  protected function ‪checkValueForDatetime(mixed $value, array $tcaFieldConf): array
2061  {
2062  $format = $tcaFieldConf['format'] ?? 'datetime';
2063  if (!in_array($format, ['datetime', 'date', 'time', 'timesec'], true)) {
2064  // Early return if format is not valid
2065  return [];
2066  }
2067 
2068  // Handle native date/time fields
2069  $isNativeDateTimeField = false;
2070  $nativeDateTimeFieldFormat = '';
2071  $nativeDateTimeFieldResetValue = '';
2072  $nativeDateTimeType = $tcaFieldConf['dbType'] ?? '';
2073  if (in_array($nativeDateTimeType, ‪QueryHelper::getDateTimeTypes(), true)) {
2074  $isNativeDateTimeField = true;
2075  $dateTimeFormats = ‪QueryHelper::getDateTimeFormats();
2076  $nativeDateTimeFieldFormat = $dateTimeFormats[$nativeDateTimeType]['format'];
2077  $nativeDateTimeFieldEmptyValue = $dateTimeFormats[$nativeDateTimeType]['empty'];
2078  $nativeDateTimeFieldResetValue = $dateTimeFormats[$nativeDateTimeType]['reset'];
2079  if (empty($value)) {
2080  $value = null;
2081  } else {
2082  // Convert the date/time into a timestamp for the sake of the checks
2083  // We expect the ISO 8601 $value to contain a UTC timezone specifier.
2084  // We explicitly fallback to UTC if no timezone specifier is given (e.g. for copy operations).
2085  $dateTime = new \DateTime((string)$value, new \DateTimeZone('UTC'));
2086  // The timestamp (UTC) returned by getTimestamp() will be converted to
2087  // a local time string by gmdate() later.
2088  $value = $value === $nativeDateTimeFieldEmptyValue ? null : $dateTime->getTimestamp();
2089  }
2090  }
2091 
2092  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
2093  return [];
2094  }
2095 
2096  if ((string)$value !== '' && !‪MathUtility::canBeInterpretedAsInteger((string)$value)) {
2097  if (($format === 'time' || $format === 'timesec')) {
2098  $value = (new \DateTime((string)$value))->getTimestamp();
2099  } else {
2100  // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2101  // For instance "1999-11-11T11:11:11Z"
2102  // Since the user actually specifies the time in the server's local time, we need to mangle this
2103  // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2104  // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2105  // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2106  // TZ difference.
2107  try {
2108  // Make the date from JS a timestamp
2109  $value = (new \DateTime((string)$value))->getTimestamp();
2110  } catch (\Exception) {
2111  // set the default timezone value to achieve the value of 0 as a result
2112  $value = (int)date('Z', 0);
2113  }
2114 
2115  // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2116  $value -= (int)date('Z', $value);
2117  }
2118  }
2119 
2120  // Skip range validation, if the default value equals 0 and the input value is 0, "0" or an empty string.
2121  // This is needed for timestamp date fields with ['range']['lower'] set.
2122  $skipRangeValidation =
2123  isset($tcaFieldConf['default'], $value)
2124  && (int)$tcaFieldConf['default'] === 0
2125  && ($value === '' || $value === '0' || $value === 0);
2126 
2127  // Checking range of value:
2128  if (!$skipRangeValidation && is_array($tcaFieldConf['range'] ?? null)) {
2129  if (isset($tcaFieldConf['range']['upper']) && ceil($value) > (int)$tcaFieldConf['range']['upper']) {
2130  $value = (int)$tcaFieldConf['range']['upper'];
2131  }
2132  if (isset($tcaFieldConf['range']['lower']) && floor($value) < (int)$tcaFieldConf['range']['lower']) {
2133  $value = (int)$tcaFieldConf['range']['lower'];
2134  }
2135  }
2136 
2137  // Handle native date/time fields
2138  if ($isNativeDateTimeField) {
2139  if ($tcaFieldConf['nullable'] ?? false) {
2140  // Convert the timestamp back to a date/time if not null
2141  $value = $value !== null ? gmdate($nativeDateTimeFieldFormat, $value) : null;
2142  } else {
2143  // Convert the timestamp back to a date/time
2144  $value = $value !== null ? gmdate($nativeDateTimeFieldFormat, $value) : $nativeDateTimeFieldResetValue;
2145  }
2146  } else {
2147  // Ensure value is always an int if no native field is used
2148  $value = (int)$value;
2149  }
2150 
2151  $res['value'] = $value;
2152  return $res;
2153  }
2154 
2167  protected function ‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field)
2168  {
2169  $items = $tcaFieldConf['items'] ?? null;
2170  if (!empty($tcaFieldConf['itemsProcFunc'])) {
2171  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
2172  $items = $processingService->getProcessingItems(
2173  $table,
2174  $realPid,
2175  $field,
2176  $this->checkValue_currentRecord,
2177  $tcaFieldConf,
2178  $tcaFieldConf['items']
2179  );
2180  }
2181 
2182  $itemC = 0;
2183  if ($items !== null) {
2184  $itemC = count($items);
2185  }
2186  if (!$itemC) {
2187  $itemC = 1;
2188  }
2189  $maxV = (2 ** $itemC) - 1;
2190  if ($value < 0) {
2191  // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error.
2192  $value = 0;
2193  }
2194  if ($value > $maxV) {
2195  // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic,
2196  // changing list of items, then it may happen that a value is transformed and vanished checkboxes
2197  // are permanently removed from the value.
2198  // Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a
2199  // error message to the user - dynamic item sets via itemProcFunc on check would be a bad idea anyway.
2200  $value = (int)$value & $maxV;
2201  }
2202  if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) {
2203  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
2204  $otherRecordsWithSameValue = [];
2205  $maxCheckedRecords = 0;
2206  // @todo These checks do not consider the language of the current record (if available).
2207  if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) {
2208  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value, $realPid);
2209  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid'];
2210  }
2211  if (in_array('maximumRecordsChecked', $evalCodesArray, true)) {
2212  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value);
2213  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked'];
2214  }
2215 
2216  // there are more than enough records with value "1" in the DB
2217  // if so, set this value to "0" again
2218  if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) {
2219  $value = 0;
2220  $this->‪log(
2221  $table,
2222  $id,
2223  SystemLogDatabaseAction::CHECK,
2224  0,
2225  SystemLogErrorClassification::USER_ERROR,
2226  'Could not activate checkbox for field "{field}". A total of {max} record(s) can have this checkbox activated. Uncheck other records first in order to activate the checkbox of this record',
2227  -1,
2228  ['field' => $field, 'max' => $maxCheckedRecords]
2229  );
2230  }
2231  }
2232  $res['value'] = $value;
2233  return $res;
2234  }
2235 
2248  protected function ‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $pid, $field)
2249  {
2250  if (is_array($tcaFieldConf['items'])) {
2251  foreach ($tcaFieldConf['items'] as $set) {
2252  if ((string)$set['value'] === (string)$value) {
2253  $res['value'] = $value;
2254  break;
2255  }
2256  }
2257  }
2258 
2259  // if no value was found and an itemsProcFunc is defined, check that for the value
2260  if (!empty($tcaFieldConf['itemsProcFunc']) && empty($res['value'])) {
2261  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
2262  $processedItems = $processingService->getProcessingItems(
2263  $table,
2264  $pid,
2265  $field,
2266  $this->checkValue_currentRecord,
2267  $tcaFieldConf,
2268  $tcaFieldConf['items']
2269  );
2270 
2271  foreach ($processedItems as $set) {
2272  if ((string)$set['value'] === (string)$value) {
2273  $res['value'] = $value;
2274  break;
2275  }
2276  }
2277  }
2278 
2279  return $res;
2280  }
2281 
2289  protected function ‪checkValueForJson(array|string $value, array $tcaFieldConf): array
2290  {
2291  if (is_string($value)) {
2292  if ($value === '') {
2293  $value = [];
2294  } else {
2295  try {
2296  $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
2297  if ($value === null) {
2298  // Unset value as it could not be decoded
2299  return [];
2300  }
2301  } catch (\JsonException) {
2302  // Unset value as it is invalid
2303  return [];
2304  }
2305  }
2306  }
2307 
2308  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
2309  // Unset value as it is required
2310  return [];
2311  }
2312 
2313  return [
2314  'value' => $value,
2315  ];
2316  }
2317 
2330  protected function ‪checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field)
2331  {
2332  // Detecting if value sent is an array and if so, implode it around a comma:
2333  if (is_array($value)) {
2334  $value = implode(',', $value);
2335  } else {
2336  $value = (string)$value;
2337  }
2338 
2339  // When values are sent as group or select they come as comma-separated values which are exploded by this function:
2340  $valueArray = $this->‪checkValue_group_select_explodeSelectGroupValue($value);
2341  // If multiple is not set, remove duplicates:
2342  if (!($tcaFieldConf['multiple'] ?? false)) {
2343  $valueArray = array_unique($valueArray);
2344  }
2345  // If an exclusive key is found, discard all others:
2346  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) {
2347  $exclusiveKeys = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
2348  foreach ($valueArray as $index => $key) {
2349  if (in_array($key, $exclusiveKeys, true)) {
2350  $valueArray = [$index => $key];
2351  break;
2352  }
2353  }
2354  }
2355  // This could be a good spot for parsing the array through a validation-function which checks if the values are correct (except that database references are not in their final form - but that is the point, isn't it?)
2356  // NOTE!!! Must check max-items of files before the later check because that check would just leave out file names if there are too many!!
2357  $valueArray = $this->‪applyFiltersToValues($tcaFieldConf, $valueArray);
2358  // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed!
2359  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) {
2360  $preCount = count($valueArray);
2361  foreach ($valueArray as $index => $key) {
2362  if (!$this->BE_USER->checkAuthMode($table, $field, $key)) {
2363  unset($valueArray[$index]);
2364  }
2365  }
2366  // During the check it turns out that the value / all values were removed - we respond by simply returning an empty array so nothing is written to DB for this field.
2367  if ($preCount && empty($valueArray)) {
2368  return [];
2369  }
2370  }
2371  // For select types which has a foreign table attached:
2372  $unsetResult = false;
2373  if ($tcaFieldConf['type'] === 'group' || ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['foreign_table'] ?? false))) {
2374  // check, if there is a NEW... id in the value, that should be substituted later
2375  if (str_contains($value, 'NEW')) {
2376  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2377  $this->remapStack[] = [
2378  'func' => 'checkValue_group_select_processDBdata',
2379  'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field],
2380  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5],
2381  'field' => $field,
2382  ];
2383  $unsetResult = true;
2384  } else {
2385  $valueArray = $this->‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field);
2386  }
2387  }
2388  if (!$unsetResult) {
2389  $newVal = $this->‪checkValue_checkMax($tcaFieldConf, $valueArray);
2390  $res['value'] = $this->‪castReferenceValue(implode(',', $newVal), $tcaFieldConf, str_contains($value, 'NEW'));
2391  } else {
2392  unset($res['value']);
2393  }
2394  return $res;
2395  }
2396 
2406  protected function ‪checkValueForUuid(string $value, array $tcaFieldConf): array
2407  {
2408  if (Uuid::isValid($value)) {
2409  return ['value' => $value];
2410  }
2411 
2412  if ($tcaFieldConf['required'] ?? true) {
2413  return ['value' => (string)match ((int)($tcaFieldConf['version'] ?? 0)) {
2414  6 => Uuid::v6(),
2415  7 => Uuid::v7(),
2416  default => Uuid::v4()
2417  }];
2418  }
2419  // Unset invalid uuid - in case a field value is not required
2420  return [];
2421  }
2422 
2429  protected function ‪applyFiltersToValues(array $tcaFieldConfiguration, array $values)
2430  {
2431  if (!is_array($tcaFieldConfiguration['filter'] ?? null)) {
2432  return $values;
2433  }
2434  foreach ($tcaFieldConfiguration['filter'] as $filter) {
2435  if (empty($filter['userFunc'])) {
2436  continue;
2437  }
2438  $parameters = $filter['parameters'] ?? [];
2439  if (!is_array($parameters)) {
2440  $parameters = [];
2441  }
2442  $parameters['values'] = $values;
2443  $parameters['tcaFieldConfig'] = $tcaFieldConfiguration;
2444  $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this);
2445  if (!is_array($values)) {
2446  throw new \RuntimeException('Expected userFunc filter "' . $filter['userFunc'] . '" to return an array. Got ' . gettype($values) . '.', 1336051942);
2447  }
2448  }
2449  return $values;
2450  }
2451 
2468  protected function ‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field)
2469  {
2470  if (!is_array($value)) {
2471  $res['value'] = $value;
2472  return $res;
2473  }
2474 
2475  // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied.
2476  // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord
2477  // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new
2478  // records we do know the expected PID, so we send that with this special parameter. Only active when larger than zero.
2480  if ($status === 'new') {
2481  $row['pid'] = $realPid;
2482  }
2483 
2484  // Get data structure. The methods may throw various exceptions, with some of them being
2485  // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
2486  // and substitute with a dummy DS.
2487  try {
2488  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
2489  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
2490  ['config' => $tcaFieldConf],
2491  $table,
2492  $field,
2493  $row
2494  );
2495  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
2496  } catch (InvalidIdentifierException) {
2497  $dataStructureArray = ['sheets' => ['sDEF' => []]];
2498  }
2499 
2500  // Get current value array:
2501  $currentValueArray = (string)$curValue !== '' ? ‪GeneralUtility::xml2array($curValue) : [];
2502  if (!is_array($currentValueArray)) {
2503  $currentValueArray = [];
2504  }
2505  // Remove all old meta for languages...
2506  // Evaluation of input values:
2507  $value['data'] = $this->‪checkValue_flex_procInData($value['data'] ?? [], $currentValueArray['data'] ?? [], $dataStructureArray, [$table, $id, $curValue, $status, $realPid, $recFID, $tscPID]);
2508  // Create XML from input value:
2509  $xmlValue = $this->‪checkValue_flexArray2Xml($value);
2510 
2511  // Here we convert the currently submitted values BACK to an array, then merge the two and then BACK to XML again. This is needed to ensure the charsets are the same
2512  // (provided that the current value was already stored IN the charset that the new value is converted to).
2513  $xmlAsArray = ‪GeneralUtility::xml2array($xmlValue);
2514 
2515  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) {
2516  $hookObject = GeneralUtility::makeInstance($className);
2517  if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) {
2518  $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $xmlAsArray);
2519  }
2520  }
2521 
2522  ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $xmlAsArray);
2523  $xmlValue = $this->‪checkValue_flexArray2Xml($currentValueArray);
2524 
2525  $xmlAsArray = ‪GeneralUtility::xml2array($xmlValue);
2526  $xmlAsArray = $this->‪sortAndDeleteFlexSectionContainerElements($xmlAsArray, $dataStructureArray);
2527  $xmlValue = $this->‪checkValue_flexArray2Xml($xmlAsArray);
2528 
2529  $res['value'] = $xmlValue;
2530  return $res;
2531  }
2532 
2540  public function ‪checkValue_flexArray2Xml($array): string
2541  {
2542  $flexObj = GeneralUtility::makeInstance(FlexFormTools::class);
2543  return $flexObj->flexArray2Xml($array);
2544  }
2545 
2551  private function ‪sortAndDeleteFlexSectionContainerElements(array $valueArray, array $dataStructure): array
2552  {
2553  foreach (($dataStructure['sheets'] ?? []) as $dataStructureSheetName => $dataStructureSheetDefinition) {
2554  if (!isset($dataStructureSheetDefinition['ROOT']['el']) || !is_array($dataStructureSheetDefinition['ROOT']['el'])) {
2555  continue;
2556  }
2557  $dataStructureFields = $dataStructureSheetDefinition['ROOT']['el'];
2558  foreach ($dataStructureFields as $dataStructureFieldName => $dataStructureFieldDefinition) {
2559  if (isset($dataStructureFieldDefinition['type']) && $dataStructureFieldDefinition['type'] === 'array'
2560  && isset($dataStructureFieldDefinition['section']) && (string)$dataStructureFieldDefinition['section'] === '1'
2561  ) {
2562  // Found a possible section within flex form data structure definition
2563  if (!is_array($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] ?? false)) {
2564  // No containers in data
2565  continue;
2566  }
2567  $newElements = [];
2568  $containerCounter = 0;
2569  foreach ($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] as $sectionKey => $sectionValues) {
2570  // Remove to-delete containers
2571  $action = $sectionValues['_ACTION'] ?? '';
2572  if ($action === 'DELETE') {
2573  continue;
2574  }
2575  if (($sectionValues['_ACTION'] ?? '') === '') {
2576  $sectionValues['_ACTION'] = $containerCounter;
2577  }
2578  $newElements[$sectionKey] = $sectionValues;
2579  $containerCounter++;
2580  }
2581  // Resort by action key
2582  uasort($newElements, function ($a, $b) {
2583  return (int)$a['_ACTION'] - (int)$b['_ACTION'];
2584  });
2585  foreach ($newElements as &$element) {
2586  // Do not store action key
2587  unset($element['_ACTION']);
2588  }
2589  $valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] = $newElements;
2590  }
2591  }
2592  }
2593  return $valueArray;
2594  }
2595 
2608  public function ‪checkValue_inline($res, $value, $tcaFieldConf, $PP, $field, array $additionalData = null)
2609  {
2610  [$table, $id, , $status] = $PP;
2611  $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData);
2612  }
2613 
2629  public function ‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, array $additionalData = null)
2630  {
2631  if (!$tcaFieldConf['foreign_table']) {
2632  // Fatal error, inline fields should always have a foreign_table defined
2633  return false;
2634  }
2635  // When values are sent they come as comma-separated values which are exploded by this function:
2636  $valueArray = ‪GeneralUtility::trimExplode(',', $value);
2637  // Remove duplicates: (should not be needed)
2638  $valueArray = array_unique($valueArray);
2639  // Example for received data:
2640  // $value = 45,NEW4555fdf59d154,12,123
2641  // We need to decide whether we use the stack or can save the relation directly.
2642  if (!empty($value) && (str_contains($value, 'NEW') || !‪MathUtility::canBeInterpretedAsInteger($id))) {
2643  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2644  $this->remapStack[] = [
2645  'func' => 'checkValue_inline_processDBdata',
2646  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData],
2647  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2648  'additionalData' => $additionalData,
2649  'field' => $field,
2650  ];
2651  unset($res['value']);
2652  } elseif ($value || ‪MathUtility::canBeInterpretedAsInteger($id)) {
2653  $res['value'] = $this->‪checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2654  }
2655  return $res;
2656  }
2657 
2661  public function ‪checkValueForFile(
2662  array $res,
2663  string $value,
2664  array $tcaFieldConf,
2665  string $table,
2666  int|string $id,
2667  string $field,
2668  ?array $additionalData = null
2669  ): array {
2670  $valueArray = array_unique(‪GeneralUtility::trimExplode(',', $value));
2671  if ($value !== '' && (str_contains($value, 'NEW') || !‪MathUtility::canBeInterpretedAsInteger($id))) {
2672  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2673  $this->remapStack[] = [
2674  'func' => 'checkValue_file_processDBdata',
2675  'args' => [$valueArray, $tcaFieldConf, $id, $table],
2676  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
2677  'additionalData' => $additionalData,
2678  'field' => $field,
2679  ];
2680  unset($res['value']);
2681  } elseif ($value !== '' || ‪MathUtility::canBeInterpretedAsInteger($id)) {
2682  $res['value'] = $this->‪checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table);
2683  }
2684  return $res;
2685  }
2686 
2696  public function ‪checkValue_checkMax($tcaFieldConf, $valueArray): array
2697  {
2698  // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function
2699  // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check
2700  // before? Probably because we could not evaluate the validity of record uids etc... Hmm...
2701  // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first,
2702  // if the field is actually used regarding the CType.
2703  $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999;
2704  return array_slice($valueArray, 0, $maxitems);
2705  }
2706 
2707  /*********************************************
2708  *
2709  * Helper functions for evaluation functions.
2710  *
2711  ********************************************/
2724  public function ‪getUnique($table, $field, $value, $id, $newPid = 0)
2725  {
2726  if (!is_array(‪$GLOBALS['TCA'][$table]) || !is_array(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
2727  // Field is not configured in TCA
2728  return $value;
2729  }
2730 
2731  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude') {
2732  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
2733  $l10nParent = (int)$this->checkValue_currentRecord[$transOrigPointerField];
2734  if ($l10nParent > 0) {
2735  // Current record is a translation and l10n_mode "exclude" just copies the value from source language
2736  return $value;
2737  }
2738  }
2739 
2740  $newValue = $originalValue = $value;
2741  $queryBuilder = $this->‪getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid);
2742  // For as long as records with the test-value existing, try again (with incremented numbers appended)
2743  $statement = $queryBuilder->prepare();
2744  $result = $statement->executeQuery();
2745  if ($result->fetchOne()) {
2746  for ($counter = 0; $counter <= 100; $counter++) {
2747  $result->free();
2748  $newValue = $value . $counter;
2749  $statement->bindValue(1, $newValue, ‪Connection::PARAM_STR);
2750  $result = $statement->executeQuery();
2751  if (!$result->fetchOne()) {
2752  break;
2753  }
2754  }
2755  $result->free();
2756  }
2757 
2758  if ($originalValue !== $newValue) {
2759  $this->‪log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::WARNING, 'The value of the field "{field}" has been changed from "{originalValue}" to "{newValue}" as it is required to be unique', 1, ['field' => $field, 'originalValue' => $originalValue, 'newValue' => $newValue], $newPid);
2760  }
2761 
2762  return $newValue;
2763  }
2764 
2775  protected function ‪getUniqueCountStatement(
2776  string $value,
2777  string $table,
2778  string $field,
2779  int ‪$uid,
2780  int $pid
2781  ): QueryBuilder {
2782  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
2783  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2784  $queryBuilder
2785  ->count('uid')
2786  ->from($table)
2787  ->where(
2788  $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)),
2789  $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter(‪$uid, ‪Connection::PARAM_INT))
2790  );
2791  // ignore translations of current record if field is configured with l10n_mode = "exclude"
2792  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2793  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2794  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') !== '') {
2795  $queryBuilder
2796  ->andWhere(
2797  $queryBuilder->expr()->or(
2798  // records without l10n_parent must be taken into account (in any language)
2799  $queryBuilder->expr()->eq(
2800  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2801  $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT)
2802  ),
2803  // translations of other records must be taken into account
2804  $queryBuilder->expr()->neq(
2805  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2806  $queryBuilder->createPositionalParameter(‪$uid, ‪Connection::PARAM_INT)
2807  )
2808  )
2809  );
2810  }
2811  if ($pid !== 0) {
2812  $queryBuilder->andWhere(
2813  $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, ‪Connection::PARAM_INT))
2814  );
2815  } else {
2816  // pid>=0 for versioning
2817  $queryBuilder->andWhere(
2818  $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT))
2819  );
2820  }
2821  return $queryBuilder;
2822  }
2823 
2835  public function ‪getRecordsWithSameValue($tableName, ‪$uid, $fieldName, $value, $pageId = 0): array
2836  {
2837  $result = [];
2838  if (empty(‪$GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2839  return $result;
2840  }
2841 
2842  ‪$uid = (int)‪$uid;
2843  $pageId = (int)$pageId;
2844 
2845  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
2846  $queryBuilder->getRestrictions()->removeAll()
2847  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2848  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
2849 
2850  $queryBuilder->select('*')
2851  ->from($tableName)
2852  ->where(
2853  $queryBuilder->expr()->eq(
2854  $fieldName,
2855  $queryBuilder->createNamedParameter($value)
2856  ),
2857  $queryBuilder->expr()->neq(
2858  'uid',
2859  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
2860  )
2861  );
2862 
2863  if ($pageId) {
2864  $queryBuilder->andWhere(
2865  $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT))
2866  );
2867  }
2868 
2869  return $queryBuilder->executeQuery()->fetchAllAssociative();
2870  }
2871 
2879  public function ‪checkValue_text_Eval($value, $evalArray, $is_in)
2880  {
2881  $res = [];
2883  $set = true;
2884  foreach ($evalArray as $func) {
2885  switch ($func) {
2886  case 'trim':
2887  $value = trim((string)$value);
2888  break;
2889  default:
2890  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2891  if (class_exists($func)) {
2892  $evalObj = GeneralUtility::makeInstance($func);
2893  if (method_exists($evalObj, 'evaluateFieldValue')) {
2894  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2895  }
2896  }
2897  }
2898  }
2899  }
2900  if ($set) {
2901  $res['value'] = $value;
2902  }
2903  return $res;
2904  }
2905 
2917  public function ‪checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array
2918  {
2919  $res = [];
2920  $set = true;
2921  foreach ($evalArray as $func) {
2922  switch ($func) {
2923  case 'year':
2924  $value = (int)$value;
2925  break;
2926  case 'md5':
2927  if (strlen($value) !== 32) {
2928  $set = false;
2929  }
2930  break;
2931  case 'trim':
2932  $value = trim($value);
2933  break;
2934  case 'upper':
2935  $value = mb_strtoupper($value, 'utf-8');
2936  break;
2937  case 'lower':
2938  $value = mb_strtolower($value, 'utf-8');
2939  break;
2940  case 'is_in':
2941  $c = mb_strlen($value);
2942  if ($c) {
2943  $newVal = '';
2944  for ($a = 0; $a < $c; $a++) {
2945  $char = mb_substr($value, $a, 1);
2946  if (str_contains($is_in, $char)) {
2947  $newVal .= $char;
2948  }
2949  }
2950  $value = $newVal;
2951  }
2952  break;
2953  case 'nospace':
2954  $value = str_replace(' ', '', $value);
2955  break;
2956  case 'alpha':
2957  $value = preg_replace('/[^a-zA-Z]/', '', $value);
2958  break;
2959  case 'num':
2960  $value = preg_replace('/[^0-9]/', '', $value);
2961  break;
2962  case 'alphanum':
2963  $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
2964  break;
2965  case 'alphanum_x':
2966  $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
2967  break;
2968  case 'domainname':
2969  if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
2970  $value = (string)idn_to_ascii($value);
2971  }
2972  break;
2973  default:
2974  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2975  if (class_exists($func)) {
2976  $evalObj = GeneralUtility::makeInstance($func);
2977  if (method_exists($evalObj, 'evaluateFieldValue')) {
2978  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2979  }
2980  }
2981  }
2982  }
2983  }
2984  if ($set) {
2985  $res['value'] = $value;
2986  }
2987  return $res;
2988  }
2989 
3000  protected function ‪validateValueForRequired(array $tcaFieldConfig, mixed $value): bool
3001  {
3002  if (!isset($tcaFieldConfig['required']) || !$tcaFieldConfig['required']) {
3003  return true;
3004  }
3005  return !empty($value) || $value === '0';
3006  }
3007 
3020  public function ‪checkValue_category_processDBdata(
3021  array $valueArray,
3022  array $tcaFieldConf,
3023  $id,
3024  string $status,
3025  string $table,
3026  string $field
3027  ): array {
3028  $newRelations = implode(',', $valueArray);
3029  $relationHandler = $this->‪createRelationHandlerInstance();
3030  $relationHandler->start($newRelations, $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
3031  if ($tcaFieldConf['MM'] ?? false) {
3032  $relationHandler->convertItemArray();
3033  if ($status === 'update') {
3034  $relationHandleForOldRelations = $this->‪createRelationHandlerInstance();
3035  $relationHandleForOldRelations->start('', $tcaFieldConf['foreign_table'], $tcaFieldConf['MM'], $id, $table, $tcaFieldConf);
3036  $oldRelations = implode(',', $relationHandleForOldRelations->getValueArray());
3037  $relationHandler->writeMM($tcaFieldConf['MM'], $id);
3038  if ($oldRelations !== $newRelations) {
3039  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = $oldRelations;
3040  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = $newRelations;
3041  } else {
3042  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = '';
3043  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = '';
3044  }
3045  } else {
3046  $this->dbAnalysisStore[] = [$relationHandler, $tcaFieldConf['MM'], $id, '', $table];
3047  }
3048  $valueArray = $relationHandler->countItems();
3049  } else {
3050  $valueArray = $relationHandler->getValueArray();
3051  }
3052  return $valueArray;
3053  }
3054 
3068  public function ‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
3069  {
3070  $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table'];
3071  $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
3072  $newRelations = implode(',', $valueArray);
3073  $dbAnalysis = $this->‪createRelationHandlerInstance();
3074  $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
3075  $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
3076  if ($tcaFieldConf['MM'] ?? false) {
3077  // convert submitted items to use version ids instead of live ids
3078  // (only required for MM relations in a workspace context)
3079  $dbAnalysis->convertItemArray();
3080  if ($status === 'update') {
3081  $oldRelations_dbAnalysis = $this->‪createRelationHandlerInstance();
3082  $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
3083  // Db analysis with $id will initialize with the existing relations
3084  $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
3085  $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
3086  $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, $prep);
3087  if ($oldRelations != $newRelations) {
3088  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
3089  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
3090  } else {
3091  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
3092  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
3093  }
3094  } else {
3095  $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
3096  }
3097  $valueArray = $dbAnalysis->countItems();
3098  } else {
3099  $valueArray = $dbAnalysis->getValueArray($prep);
3100  }
3101  // Here we should see if 1) the records exist anymore, 2) which are new and check if the BE_USER has read-access to the new ones.
3102  return $valueArray;
3103  }
3104 
3112  public function ‪checkValue_group_select_explodeSelectGroupValue($value): array
3113  {
3114  $valueArray = ‪GeneralUtility::trimExplode(',', $value, true);
3115  foreach ($valueArray as &$newVal) {
3116  $temp = explode('|', $newVal, 2);
3117  $newVal = str_replace(['|', ','], '', rawurldecode($temp[0]));
3118  }
3119  unset($newVal);
3120  return $valueArray;
3121  }
3122 
3137  public function ‪checkValue_flex_procInData($dataPart, $dataPart_current, $dataStructure, $pParams, $callBackFunc = '', array $workspaceOptions = [])
3138  {
3139  if (is_array($dataPart)) {
3140  foreach ($dataPart as $sKey => $sheetDef) {
3141  if (isset($dataStructure['sheets'][$sKey]) && is_array($dataStructure['sheets'][$sKey]) && is_array($sheetDef)) {
3142  foreach ($sheetDef as $lKey => $lData) {
3144  $dataPart[$sKey][$lKey],
3145  $dataPart_current[$sKey][$lKey] ?? null,
3146  $dataStructure['sheets'][$sKey]['ROOT']['el'] ?? null,
3147  $pParams,
3148  $callBackFunc,
3149  $sKey . '/' . $lKey . '/',
3150  $workspaceOptions
3151  );
3152  }
3153  }
3154  }
3155  }
3156  return $dataPart;
3157  }
3158 
3172  public function ‪checkValue_flex_procInData_travDS(&$dataValues, $dataValues_current, $DSelements, $pParams, $callBackFunc, $structurePath, array $workspaceOptions = []): void
3173  {
3174  if (!is_array($DSelements)) {
3175  return;
3176  }
3177 
3178  // For each DS element:
3179  foreach ($DSelements as $key => $dsConf) {
3180  // Array/Section:
3181  if (isset($DSelements[$key]['type']) && $DSelements[$key]['type'] === 'array') {
3182  if (!is_array($dataValues[$key]['el'] ?? null)) {
3183  continue;
3184  }
3185 
3186  if ($DSelements[$key]['section']) {
3187  foreach ($dataValues[$key]['el'] as $ik => $el) {
3188  if (!is_array($el)) {
3189  continue;
3190  }
3191 
3192  if (!is_array($dataValues_current[$key]['el'] ?? false)) {
3193  $dataValues_current[$key]['el'] = [];
3194  }
3195  $theKey = key($el);
3196  if (!is_array($dataValues[$key]['el'][$ik][$theKey]['el'] ?? false)) {
3197  continue;
3198  }
3199 
3201  $dataValues[$key]['el'][$ik][$theKey]['el'],
3202  $dataValues_current[$key]['el'][$ik][$theKey]['el'] ?? [],
3203  $DSelements[$key]['el'][$theKey]['el'] ?? [],
3204  $pParams,
3205  $callBackFunc,
3206  $structurePath . $key . '/el/' . $ik . '/' . $theKey . '/el/',
3207  $workspaceOptions
3208  );
3209  }
3210  } else {
3211  if (!isset($dataValues[$key]['el'])) {
3212  $dataValues[$key]['el'] = [];
3213  }
3214  $this->‪checkValue_flex_procInData_travDS($dataValues[$key]['el'], $dataValues_current[$key]['el'], $DSelements[$key]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/', $workspaceOptions);
3215  }
3216  } else {
3217  $fieldConfiguration = $dsConf['config'] ?? null;
3218  // init with value from config for passthrough fields
3219  if (!empty($fieldConfiguration['type']) && $fieldConfiguration['type'] === 'passthrough') {
3220  if (!empty($dataValues_current[$key]['vDEF'])) {
3221  // If there is existing value, keep it
3222  $dataValues[$key]['vDEF'] = $dataValues_current[$key]['vDEF'];
3223  } elseif (
3224  !empty($fieldConfiguration['default'])
3225  && isset($pParams[1])
3227  ) {
3228  // If is new record and a default is specified for field, use it.
3229  $dataValues[$key]['vDEF'] = $fieldConfiguration['default'];
3230  }
3231  }
3232  if (!is_array($fieldConfiguration) || !isset($dataValues[$key]) || !is_array($dataValues[$key])) {
3233  continue;
3234  }
3235 
3236  foreach ($dataValues[$key] as $vKey => $data) {
3237  if ($callBackFunc) {
3238  if (is_object($this->callBackObj)) {
3239  $res = $this->callBackObj->{$callBackFunc}(
3240  $pParams,
3241  $fieldConfiguration,
3242  $dataValues[$key][$vKey] ?? null,
3243  $dataValues_current[$key][$vKey] ?? null,
3244  $structurePath . $key . '/' . $vKey . '/',
3245  $workspaceOptions
3246  );
3247  } else {
3248  $res = $this->{$callBackFunc}(
3249  $pParams,
3250  $fieldConfiguration,
3251  $dataValues[$key][$vKey] ?? null,
3252  $dataValues_current[$key][$vKey] ?? null,
3253  $structurePath . $key . '/' . $vKey . '/',
3254  $workspaceOptions
3255  );
3256  }
3257  } else {
3258  // Default
3259  [$CVtable, $CVid, $CVcurValue, $CVstatus, $CVrealPid, $CVrecFID, $CVtscPID] = $pParams;
3260 
3261  $additionalData = [
3262  'flexFormId' => $CVrecFID,
3263  'flexFormPath' => trim(rtrim($structurePath, '/') . '/' . $key . '/' . $vKey, '/'),
3264  ];
3265 
3266  $res = $this->‪checkValue_SW(
3267  [],
3268  $dataValues[$key][$vKey] ?? null,
3269  $fieldConfiguration,
3270  $CVtable,
3271  $CVid,
3272  $dataValues_current[$key][$vKey] ?? null,
3273  $CVstatus,
3274  $CVrealPid,
3275  $CVrecFID,
3276  '',
3277  $CVtscPID,
3278  $additionalData
3279  );
3280  }
3281  // Adding the value:
3282  if (isset($res['value'])) {
3283  $dataValues[$key][$vKey] = $res['value'];
3284  }
3285  }
3286  }
3287  }
3288  }
3289 
3301  protected function ‪checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field)
3302  {
3303  $foreignTable = $tcaFieldConf['foreign_table'];
3304  $valueArray = $this->‪applyFiltersToValues($tcaFieldConf, $valueArray);
3305  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3306  $dbAnalysis = $this->‪createRelationHandlerInstance();
3307  $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf);
3308  // IRRE with a pointer field (database normalization):
3309  if ($tcaFieldConf['foreign_field'] ?? false) {
3310  // update record in intermediate table (sorting & pointer uid to parent record)
3311  $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0);
3312  $newValue = $dbAnalysis->countItems(false);
3313  } elseif ($this->‪getRelationFieldType($tcaFieldConf) === 'mm') {
3314  // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here
3315  $valueArray = $this->‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field);
3316  $newValue = $valueArray[0];
3317  } else {
3318  $valueArray = $dbAnalysis->getValueArray();
3319  // Checking that the number of items is correct:
3320  $valueArray = $this->‪checkValue_checkMax($tcaFieldConf, $valueArray);
3321  $newValue = $this->‪castReferenceValue(implode(',', $valueArray), $tcaFieldConf, ($status === 'new'));
3322  }
3323  return $newValue;
3324  }
3325 
3329  protected function ‪checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table): mixed
3330  {
3331  $valueArray = GeneralUtility::makeInstance(FileExtensionFilter::class)->filter(
3332  $valueArray,
3333  (string)($tcaFieldConf['allowed'] ?? ''),
3334  (string)($tcaFieldConf['disallowed'] ?? ''),
3335  $this
3336  );
3337 
3338  $dbAnalysis = $this->‪createRelationHandlerInstance();
3339  $dbAnalysis->start(implode(',', $valueArray), $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
3340  $dbAnalysis->writeForeignField($tcaFieldConf, $id);
3341  return $dbAnalysis->countItems(false);
3342  }
3343 
3344  /*********************************************
3345  *
3346  * PROCESSING COMMANDS
3347  *
3348  ********************************************/
3355  public function ‪process_cmdmap()
3356  {
3357  // Editing frozen:
3358  if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
3359  $this->‪log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen');
3360  return false;
3361  }
3362  // Hook initialization:
3363  $hookObjectsArr = [];
3364  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
3365  $hookObj = GeneralUtility::makeInstance($className);
3366  if (method_exists($hookObj, 'processCmdmap_beforeStart')) {
3367  $hookObj->processCmdmap_beforeStart($this);
3368  }
3369  $hookObjectsArr[] = $hookObj;
3370  }
3371  $pasteDatamap = [];
3372  // Traverse command map:
3373  foreach ($this->cmdmap as $table => $idCommandArray) {
3374  // Check if the table may be modified!
3375  $modifyAccessList = $this->‪checkModifyAccessList($table);
3376  if (!$modifyAccessList) {
3377  $this->‪log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', 1, ['table' => $table]);
3378  }
3379  // Check basic permissions and circumstances:
3380  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->‪tableReadOnly($table) || !$modifyAccessList) {
3381  continue;
3382  }
3383 
3384  // Traverse the command map:
3385  foreach ($idCommandArray as $id => $incomingCmdArray) {
3386  if (!is_array($incomingCmdArray)) {
3387  continue;
3388  }
3389 
3390  if ($table === 'pages') {
3391  // for commands on pages do a pagetree-refresh
3392  $this->pagetreeNeedsRefresh = true;
3393  }
3394 
3395  foreach ($incomingCmdArray as $command => $value) {
3396  $pasteUpdate = false;
3397  if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') {
3398  // Extended paste command: $command is set to "move" or "copy"
3399  // $value['update'] holds field/value pairs which should be updated after copy/move operation
3400  // $value['target'] holds original $value (target of move/copy)
3401  $pasteUpdate = $value['update'];
3402  $value = $value['target'];
3403  }
3404  foreach ($hookObjectsArr as $hookObj) {
3405  if (method_exists($hookObj, 'processCmdmap_preProcess')) {
3406  $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate);
3407  }
3408  }
3409  // Init copyMapping array:
3410  // Must clear this array before call from here to those functions:
3411  // Contains mapping information between new and old id numbers.
3412  $this->copyMappingArray = [];
3413  // process the command
3414  $commandIsProcessed = false;
3415  foreach ($hookObjectsArr as $hookObj) {
3416  if (method_exists($hookObj, 'processCmdmap')) {
3418  $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate);
3419  }
3420  }
3421  // Only execute default commands if a hook hasn't been processed the command already
3422  if (!$commandIsProcessed) {
3423  $procId = $id;
3424  $backupUseTransOrigPointerField = ‪$this->useTransOrigPointerField;
3425  // Branch, based on command
3426  switch ($command) {
3427  case 'move':
3428  $this->‪moveRecord($table, (int)$id, $value);
3429  break;
3430  case 'copy':
3431  $target = $value['target'] ?? $value;
3432  $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false);
3433  if ($table === 'pages') {
3434  $this->‪copyPages((int)$id, $target);
3435  } else {
3436  $this->‪copyRecord($table, (int)$id, $target, true, [], '', 0, $ignoreLocalization);
3437  }
3438  $procId = $this->copyMappingArray[$table][$id] ?? null;
3439  break;
3440  case 'localize':
3441  $this->useTransOrigPointerField = true;
3442  $this->‪localize($table, (int)$id, $value);
3443  break;
3444  case 'copyToLanguage':
3445  $this->useTransOrigPointerField = false;
3446  $this->‪localize($table, (int)$id, $value);
3447  break;
3448  case 'inlineLocalizeSynchronize':
3449  $this->‪inlineLocalizeSynchronize($table, (int)$id, $value);
3450  break;
3451  case 'delete':
3452  $this->‪deleteAction($table, (int)$id);
3453  break;
3454  case 'undelete':
3455  $this->‪undeleteRecord((string)$table, (int)$id);
3456  break;
3457  }
3458  $this->useTransOrigPointerField = $backupUseTransOrigPointerField;
3459  if (is_array($pasteUpdate) && $procId > 0) {
3460  $pasteDatamap[$table][$procId] = $pasteUpdate;
3461  }
3462  }
3463  foreach ($hookObjectsArr as $hookObj) {
3464  if (method_exists($hookObj, 'processCmdmap_postProcess')) {
3465  $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap);
3466  }
3467  }
3468  // Merging the copy-array info together for remapping purposes.
3469  ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray);
3470  }
3471  }
3472  }
3473  $copyTCE = $this->‪getLocalTCE();
3474  $copyTCE->start($pasteDatamap, [], $this->BE_USER);
3475  $copyTCE->process_datamap();
3476  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3477  unset($copyTCE);
3478 
3479  // Finally, before exit, check if there are ID references to remap.
3480  // This might be the case if versioning or copying has taken place!
3481  $this->‪remapListedDBRecords();
3482  $this->‪processRemapStack();
3483  foreach ($hookObjectsArr as $hookObj) {
3484  if (method_exists($hookObj, 'processCmdmap_afterFinish')) {
3485  $hookObj->processCmdmap_afterFinish($this);
3486  }
3487  }
3488  if ($this->‪isOuterMostInstance()) {
3489  $this->referenceIndexUpdater->update();
3490  $this->‪processClearCacheQueue();
3491  $this->‪resetNestedElementCalls();
3492  }
3493  }
3494 
3495  /*********************************************
3496  *
3497  * Cmd: Copying
3498  *
3499  ********************************************/
3514  public function ‪copyRecord($table, ‪$uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '', $language = 0, $ignoreLocalization = false)
3515  {
3516  ‪$uid = ($origUid = (int)‪$uid);
3517  // Only copy if the table is defined in $GLOBALS['TCA'], a uid is given and the record wasn't copied before:
3518  if (empty(‪$GLOBALS['TCA'][$table]) || ‪$uid === 0) {
3519  return null;
3520  }
3521  if ($this->‪isRecordCopied($table, ‪$uid)) {
3522  return null;
3523  }
3524 
3525  // Fetch record with permission check
3527 
3528  // This checks if the record can be selected which is all that a copy action requires.
3529  if ($row === false) {
3530  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" which does not exist or you do not have permission to read', -1, ['table' => $table, 'uid' => ‪$uid]);
3531  return null;
3532  }
3533 
3534  // NOT using \TYPO3\CMS\Backend\Utility\BackendUtility::getTSCpid() because we need the real pid - not the ID of a page, if the input is a page...
3535  $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, ‪$uid, $destPid);
3536 
3537  // Check if table is allowed on destination page
3538  if (!$this->‪isTableAllowedForThisPage($tscPID, $table)) {
3539  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record "{table}:{uid}" on a page ({pid}) that can\'t store record type', -1, ['table' => $table, 'uid' => ‪$uid, 'pid' => $tscPID]);
3540  return null;
3541  }
3542 
3543  $fullLanguageCheckNeeded = $table !== 'pages';
3544  // Used to check language and general editing rights
3545  if (!$ignoreLocalization && ($language <= 0 || !$this->BE_USER->checkLanguageAccess($language)) && !$this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, false, $fullLanguageCheckNeeded)) {
3546  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" without having permissions to do so [{reason}]', -1, ['table' => $table, 'uid' => ‪$uid, 'reason' => $this->BE_USER->errorMsg]);
3547  return null;
3548  }
3549 
3550  $data = [];
3551  $nonFields = array_unique(‪GeneralUtility::trimExplode(',', 'uid,perms_userid,perms_groupid,perms_user,perms_group,perms_everybody,t3ver_oid,t3ver_wsid,t3ver_state,t3ver_stage,' . $excludeFields, true));
3552  BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace);
3553  $row = BackendUtility::purgeComputedPropertiesFromRecord($row);
3554 
3555  // Initializing:
3556  $theNewID = ‪StringUtility::getUniqueId('NEW');
3557  $enableField = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] ?? '';
3558  $headerField = ‪$GLOBALS['TCA'][$table]['ctrl']['label'];
3559  // Getting "copy-after" fields if applicable:
3560  $copyAfterFields = $destPid < 0 ? $this->‪fixCopyAfterDuplFields((string)$table, (int)abs($destPid)) : [];
3561  // Page TSconfig related:
3562  $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3563  $tE = $this->‪getTableEntries($table, $TSConfig);
3564  // Traverse ALL fields of the selected record:
3565  foreach ($row as $field => $value) {
3566  if (!in_array($field, $nonFields, true)) {
3567  // Get TCA configuration for the field:
3568  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3569  // Preparation/Processing of the value:
3570  // "pid" is hardcoded of course:
3571  // isset() won't work here, since values can be NULL in each of the arrays
3572  // except setDefaultOnCopyArray, since we exploded that from a string
3573  if ($field === 'pid') {
3574  $value = $destPid;
3575  } elseif (array_key_exists($field, $overrideValues)) {
3576  // Override value...
3577  $value = $overrideValues[$field];
3578  } elseif (array_key_exists($field, $copyAfterFields)) {
3579  // Copy-after value if available:
3580  $value = $copyAfterFields[$field];
3581  } else {
3582  // Hide at copy may override:
3583  if ($first && $field == $enableField
3584  && (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3585  && !$this->neverHideAtCopy
3586  && !($tE['disableHideAtCopy'] ?? false)
3587  ) {
3588  $value = 1;
3589  }
3590  // Prepend label on copy:
3591  if ($first && $field == $headerField
3592  && (‪$GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3593  && !($tE['disablePrependAtCopy'] ?? false)
3594  ) {
3595  $value = $this->‪getCopyHeader($table, $this->‪resolvePid($table, $destPid), $field, $this->‪clearPrefixFromValue($table, $value), 0);
3596  }
3597  // Processing based on the TCA config field type (files, references, flexforms...)
3598  $value = $this->‪copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $tscPID, $language);
3599  }
3600  // Add value to array.
3601  $data[$table][$theNewID][$field] = $value;
3602  }
3603  }
3604  // Overriding values:
3605  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3606  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3607  }
3608  // Setting original UID:
3609  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3610  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = ‪$uid;
3611  }
3612  // Do the copy by simply submitting the array through DataHandler:
3613  $copyTCE = $this->‪getLocalTCE();
3614  $copyTCE->start($data, [], $this->BE_USER);
3615  $copyTCE->process_datamap();
3616  // Getting the new UID:
3617  $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null;
3618  if ($theNewSQLID) {
3619  $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3620  // Keep automatically versionized record information:
3621  if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3622  $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3623  }
3624  }
3625  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3626  unset($copyTCE);
3627  if (!$ignoreLocalization && $language == 0) {
3628  //repointing the new translation records to the parent record we just created
3629  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
3630  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3631  }
3632  // This value is evaluated in DataMapItem->getType() so it is very important
3633  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3634  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3635  }
3636  $this->‪copyL10nOverlayRecords($table, ‪$uid, $destPid, $first, $overrideValues, $excludeFields);
3637  }
3638 
3639  return $theNewSQLID;
3640  }
3641 
3650  public function ‪copyPages(‪$uid, $destPid): void
3651  {
3652  // Initialize:
3653  ‪$uid = (int)‪$uid;
3654  $destPid = (int)$destPid;
3655 
3656  $copyTablesAlongWithPage = $this->‪getAllowedTablesToCopyWhenCopyingAPage();
3657  // Begin to copy pages if we're allowed to:
3658  if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3659  // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3660  // This method also copies the localizations of a page
3661  $theNewRootID = $this->‪copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true);
3662  // If we're going to copy recursively
3663  if ($theNewRootID && $this->copyTree) {
3664  // Get ALL subpages to copy (read-permissions are respected!):
3665  $CPtable = $this->‪int_pageTreeInfo([], $uid, (int)$this->copyTree, $theNewRootID);
3666  // Now copying the subpages:
3667  foreach ($CPtable as $thePageUid => $thePagePid) {
3668  $newPid = $this->copyMappingArray['pages'][$thePagePid] ?? null;
3669  if (isset($newPid)) {
3670  $this->‪copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3671  } else {
3672  $this->‪log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3673  break;
3674  }
3675  }
3676  }
3677  } else {
3678  $this->‪log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page {uid} without permission to this table', -1, ['uid' => ‪$uid]);
3679  }
3680  }
3681 
3689  protected function ‪getAllowedTablesToCopyWhenCopyingAPage(): array
3690  {
3691  // Finding list of tables to copy.
3692  // These are the tables, the user may modify
3693  $copyTablesArray = $this->admin ? $this->‪compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3694  // If not all tables are allowed then make a list of allowed tables.
3695  // That is the tables that figure in both allowed tables AND the copyTable-list
3696  if (!str_contains($this->copyWhichTables, '*')) {
3697  $definedTablesToCopy = ‪GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3698  // Pages are always allowed
3699  $definedTablesToCopy[] = 'pages';
3700  $definedTablesToCopy = array_flip($definedTablesToCopy);
3701  foreach ($copyTablesArray as $k => $table) {
3702  if (!$table || !isset($definedTablesToCopy[$table])) {
3703  unset($copyTablesArray[$k]);
3704  }
3705  }
3706  }
3707  $copyTablesArray = array_unique($copyTablesArray);
3708  return $copyTablesArray;
3709  }
3720  public function ‪copySpecificPage(‪$uid, $destPid, $copyTablesArray, $first = false)
3721  {
3722  // Copy the page itself:
3723  $theNewRootID = $this->‪copyRecord('pages', $uid, $destPid, $first);
3724  $currentWorkspaceId = (int)$this->BE_USER->workspace;
3725  // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3726  ‪if ($theNewRootID) {
3727  foreach ($copyTablesArray as $table) {
3728  // All records under the page is copied.
3729  if ($table && is_array(‪$GLOBALS['TCA'][$table] ?? false) && $table !== 'pages') {
3730  ‪$fields = ['uid'];
3731  $languageField = null;
3732  $transOrigPointerField = null;
3733  $translationSourceField = null;
3734  if (BackendUtility::isTableLocalizable($table)) {
3735  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
3736  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3737  ‪$fields[] = $languageField;
3738  ‪$fields[] = $transOrigPointerField;
3739  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3740  $translationSourceField = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3741  ‪$fields[] = $translationSourceField;
3742  }
3743  }
3744  $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3745  if ($isTableWorkspaceEnabled) {
3746  ‪$fields[] = 't3ver_oid';
3747  ‪$fields[] = 't3ver_state';
3748  ‪$fields[] = 't3ver_wsid';
3749  }
3750  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
3751  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3752  $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3753  $queryBuilder
3754  ->select(...‪$fields)
3755  ->from($table)
3756  ->where(
3757  $queryBuilder->expr()->eq(
3758  'pid',
3759  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
3760  )
3761  );
3762  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3763  $queryBuilder->orderBy(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3764  }
3765  $queryBuilder->addOrderBy('uid');
3766  try {
3767  $result = $queryBuilder->executeQuery();
3768  $rows = [];
3769  $movedLiveIds = [];
3770  $movedLiveRecords = [];
3771  while ($row = $result->fetchAssociative()) {
3772  if ($isTableWorkspaceEnabled && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) {
3773  $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3774  }
3775  $rows[(int)$row['uid']] = $row;
3776  }
3777  // Resolve placeholders of workspace versions
3778  if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3779  // If a record was moved within the page, the PlainDataResolver needs the moved record
3780  // but not the original live version, otherwise the moved record is not considered at all.
3781  // For this reason, we find the live ids, where there was also a moved record in the SQL
3782  // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3783  // see changeContentSortingAndCopyDraftPage test
3784  foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3785  if (isset($rows[$liveId])) {
3786  $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3787  unset($rows[$liveId]);
3788  }
3789  }
3790  $rows = array_reverse(
3792  $table,
3793  implode(',', ‪$fields),
3794  ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '',
3795  array_keys($rows)
3796  ),
3797  true
3798  );
3799  foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3800  $rows[$movePlaceHolderId] = $liveRecord;
3801  }
3802  }
3803  if (is_array($rows)) {
3804  $languageSourceMap = [];
3805  $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3806  $doRemap = false;
3807  foreach ($rows as $row) {
3808  // Skip localized records that will be processed in
3809  // copyL10nOverlayRecords() on copying the default language record
3810  $transOrigPointer = $row[$transOrigPointerField] ?? 0;
3811  if (!empty($languageField)
3812  && $row[$languageField] > 0
3813  && $transOrigPointer > 0
3814  && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))
3815  ) {
3816  continue;
3817  }
3818  // Copying each of the underlying records...
3819  $newUid = $this->‪copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3820  if ($translationSourceField) {
3821  $languageSourceMap[$row['uid']] = $newUid;
3822  if ($row[$languageField] > 0) {
3823  $doRemap = true;
3824  }
3825  }
3826  }
3827  if ($doRemap) {
3828  //remap is needed for records in non-default language records in the "free mode"
3829  $this->‪copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3830  }
3831  }
3832  } catch (DBALException $e) {
3833  $databaseErrorMessage = $e->getPrevious()->getMessage();
3834  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: {reason}', -1, ['reason' => $databaseErrorMessage]);
3835  }
3836  }
3837  }
3838  $this->‪processRemapStack();
3839  return $theNewRootID;
3840  }
3841  return null;
3842  }
3843 
3860  public function ‪copyRecord_raw($table, ‪$uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3861  {
3862  ‪$uid = (int)‪$uid;
3863  // Stop any actions if the record is marked to be deleted:
3864  // (this can occur if IRRE elements are versionized and child elements are removed)
3865  if ($this->‪isElementToBeDeleted($table, ‪$uid)) {
3866  return null;
3867  }
3868  // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3869  if (!‪$GLOBALS['TCA'][$table] || !‪$uid || $this->‪isRecordCopied($table, ‪$uid)) {
3870  return null;
3871  }
3872 
3873  // Fetch record with permission check
3875 
3876  // This checks if the record can be selected which is all that a copy action requires.
3877  if ($row === false) {
3878  $this->‪log(
3879  $table,
3880  ‪$uid,
3881  SystemLogDatabaseAction::INSERT,
3882  0,
3883  SystemLogErrorClassification::USER_ERROR,
3884  'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3885  );
3886  return null;
3887  }
3888 
3889  // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3890  $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3891 
3892  // Merge in override array.
3893  $row = array_merge($row, $overrideArray);
3894  // Traverse ALL fields of the selected record:
3895  foreach ($row as $field => $value) {
3897  if (!in_array($field, $nonFields, true)) {
3898  // Get TCA configuration for the field:
3899  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3900  if (is_array($conf)) {
3901  // Processing based on the TCA config field type (files, references, flexforms...)
3902  $value = $this->‪copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3903  }
3904  // Add value to array.
3905  $row[$field] = $value;
3906  }
3907  }
3908  $row['pid'] = $pid;
3909  // Setting original UID:
3910  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
3911  $row[‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = ‪$uid;
3912  }
3913  // Do the copy by internal function
3914  $theNewSQLID = $this->‪insertNewCopyVersion($table, $row, $pid);
3915 
3916  // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
3917  // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
3918  // that refers e.g. to a tt_content record is marked as deleted. The tt_content record then needs a reference index update.
3919  // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
3920  if (!empty($workspaceOptions)) {
3921  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
3922  }
3923 
3924  if ($theNewSQLID) {
3925  $this->‪dbAnalysisStoreExec();
3926  $this->dbAnalysisStore = [];
3927  return $this->copyMappingArray[$table][‪$uid] = $theNewSQLID;
3928  }
3929  return null;
3930  }
3931 
3942  public function ‪insertNewCopyVersion($table, $fieldArray, $realPid)
3943  {
3944  $id = ‪StringUtility::getUniqueId('NEW');
3945  // $fieldArray is set as current record.
3946  // The point is that when new records are created as copies with flex type fields there might be a field containing information about which DataStructure to use and without that information the flexforms cannot be correctly processed.... This should be OK since the $checkValueRecord is used by the flexform evaluation only anyways...
3947  $this->checkValue_currentRecord = $fieldArray;
3948  // Makes sure that transformations aren't processed on the copy.
3949  $backupDontProcessTransformations = ‪$this->dontProcessTransformations;
3950  $this->dontProcessTransformations = true;
3951  // Traverse record and input-process each value:
3952  foreach ($fieldArray as $field => $fieldValue) {
3953  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
3954  // Evaluating the value.
3955  $res = $this->‪checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
3956  if (isset($res['value'])) {
3957  $fieldArray[$field] = $res['value'];
3958  }
3959  }
3960  }
3961  // System fields being set:
3962  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
3963  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
3964  }
3965  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
3966  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
3967  }
3968  // Finally, insert record:
3969  $this->‪insertDB($table, $id, $fieldArray, BackendUtility::isTableWorkspaceEnabled($table));
3970  // Resets dontProcessTransformations to the previous state.
3971  $this->dontProcessTransformations = $backupDontProcessTransformations;
3972  // Return new id:
3973  return $this->substNEWwithIDs[$id] ?? null;
3974  }
3975 
3992  public function ‪copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
3993  {
3994  $relationFieldType = $this->‪getRelationFieldType($conf);
3995  // Get the localization mode for the current (parent) record (keep|select):
3996  // Register if there are references to take care of or MM is used on an inline field (no change to value):
3997  if ($this->‪isReferenceField($conf) || $relationFieldType === 'mm') {
3998  $value = $this->‪copyRecord_processManyToMany($table, ‪$uid, $field, $value, $conf, $language);
3999  } elseif ($relationFieldType !== false) {
4000  $value = $this->‪copyRecord_processRelation($table, ‪$uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
4001  }
4002  // For "flex" fieldtypes we need to traverse the structure for two reasons: If there are file references they have to be prepended with absolute paths and if there are database reference they MIGHT need to be remapped (still done in remapListedDBRecords())
4003  if (isset($conf['type']) && $conf['type'] === 'flex') {
4004  // Get current value array:
4005  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
4006  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
4007  ['config' => $conf],
4008  $table,
4009  $field,
4010  $row
4011  );
4012  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
4013  $currentValue = is_string($value) ? ‪GeneralUtility::xml2array($value) : null;
4014  // Traversing the XML structure, processing files:
4015  if (is_array($currentValue)) {
4016  $currentValue['data'] = $this->‪checkValue_flex_procInData($currentValue['data'] ?? [], [], $dataStructureArray, [$table, ‪$uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
4017  // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
4018  $value = $currentValue;
4019  }
4020  }
4021  return $value;
4022  }
4023 
4035  protected function ‪copyRecord_processManyToMany($table, ‪$uid, $field, $value, $conf, $language)
4036  {
4037  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
4038  $allowedTablesArray = ‪GeneralUtility::trimExplode(',', $allowedTables, true);
4039  $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
4040  $mmTable = !empty($conf['MM']) ? $conf['MM'] : '';
4041 
4042  $dbAnalysis = $this->‪createRelationHandlerInstance();
4043  $dbAnalysis->start($value, $allowedTables, $mmTable, ‪$uid, $table, $conf);
4044  $purgeItems = false;
4045 
4046  // Check if referenced records of select or group fields should also be localized in general.
4047  // A further check is done in the loop below for each table name.
4048  if ($language > 0 && $mmTable === '' && !empty($conf['localizeReferencesAtParentLocalization'])) {
4049  // Check whether allowed tables can be localized.
4050  $localizeTables = [];
4051  foreach ($allowedTablesArray as $allowedTable) {
4052  $localizeTables[$allowedTable] = BackendUtility::isTableLocalizable($allowedTable);
4053  }
4054 
4055  foreach ($dbAnalysis->itemArray as $index => $item) {
4056  // No action required, if referenced tables cannot be localized (current value will be used).
4057  if (empty($localizeTables[$item['table']])) {
4058  continue;
4059  }
4060 
4061  // Since select or group fields can reference many records, check whether there's already a localization.
4062  $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
4063  if ($recordLocalization) {
4064  $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
4065  } elseif ($this->‪isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . $language) === false) {
4066  $dbAnalysis->itemArray[$index]['id'] = $this->‪localize($item['table'], $item['id'], $language);
4067  }
4068  }
4069  $purgeItems = true;
4070  }
4071 
4072  if ($purgeItems || $mmTable !== '') {
4073  $dbAnalysis->purgeItemArray();
4074  $value = implode(',', $dbAnalysis->getValueArray($prependName));
4075  }
4076  // Setting the value in this array will notify the remapListedDBRecords() function that this field MAY need references to be corrected.
4077  if ($value) {
4078  $this->registerDBList[$table][‪$uid][$field] = $value;
4079  }
4080 
4081  return $value;
4082  }
4083 
4097  protected function ‪copyRecord_processRelation(
4098  $table,
4099  ‪$uid,
4100  $field,
4101  $value,
4102  $row,
4103  $conf,
4104  $realDestPid,
4105  $language,
4106  array $workspaceOptions
4107  ) {
4108  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
4109  $dbAnalysis = $this->‪createRelationHandlerInstance();
4110  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
4111  // Walk through the items, copy them and remember the new id:
4112  foreach ($dbAnalysis->itemArray as $k => $v) {
4113  $newId = null;
4114  // If language is set and differs from original record, this isn't a copy action but a localization of our parent/ancestor:
4115  if ($language > 0 && BackendUtility::isTableLocalizable($table) && $language != $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]) {
4116  // Children should be localized when the parent gets localized the first time, just do it:
4117  $newId = $this->‪localize($v['table'], $v['id'], $language);
4118  } else {
4119  if (!‪MathUtility::canBeInterpretedAsInteger($realDestPid)) {
4120  $newId = $this->‪copyRecord($v['table'], $v['id'], -(int)($v['id']));
4121  // If the destination page id is a NEW string, keep it on the same page
4122  } elseif ($this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($v['table'])) {
4123  // A filled $workspaceOptions indicated that this call
4124  // has it's origin in previous versionizeRecord() processing
4125  if (!empty($workspaceOptions)) {
4126  // Versions use live default id, thus the "new"
4127  // id is the original live default child record
4128  $newId = $v['id'];
4129  $this->‪versionizeRecord(
4130  $v['table'],
4131  $v['id'],
4132  $workspaceOptions['label'] ?? 'Auto-created for WS #' . $this->BE_USER->workspace,
4133  $workspaceOptions['delete'] ?? false
4134  );
4135  // Otherwise just use plain copyRecord() to create placeholders etc.
4136  } else {
4137  // If a record has been copied already during this request,
4138  // prevent superfluous duplication and use the existing copy
4139  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
4140  $newId = $this->copyMappingArray[$v['table']][$v['id']];
4141  } else {
4142  $newId = $this->‪copyRecord($v['table'], $v['id'], $realDestPid);
4143  }
4144  }
4145  } elseif ($this->BE_USER->workspace > 0 && !BackendUtility::isTableWorkspaceEnabled($v['table'])) {
4146  // We are in workspace context creating a new parent version and have a child table
4147  // that is not workspace aware. We don't do anything with this child.
4148  continue;
4149  } else {
4150  // If a record has been copied already during this request,
4151  // prevent superfluous duplication and use the existing copy
4152  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
4153  $newId = $this->copyMappingArray[$v['table']][$v['id']];
4154  } else {
4155  $newId = $this->‪copyRecord_raw($v['table'], $v['id'], $realDestPid, [], $workspaceOptions);
4156  }
4157  }
4158  }
4159  // If the current field is set on a page record, update the pid of related child records:
4160  if ($table === 'pages') {
4161  $this->registerDBPids[$v['table']][$v['id']] = ‪$uid;
4162  } elseif (isset($this->registerDBPids[$table][‪$uid])) {
4163  $this->registerDBPids[$v['table']][$v['id']] = $this->registerDBPids[$table][‪$uid];
4164  }
4165  $dbAnalysis->itemArray[$k]['id'] = $newId;
4166  }
4167  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4168  $value = implode(',', $dbAnalysis->getValueArray());
4169  $this->registerDBList[$table][‪$uid][$field] = $value;
4170 
4171  return $value;
4172  }
4173 
4188  public function ‪copyRecord_flexFormCallBack($pParams, $dsConf, $dataValue, $_1, $_2, $workspaceOptions): array
4189  {
4190  // Extract parameters:
4191  [$table, ‪$uid, $field, $realDestPid] = $pParams;
4192  // If references are set for this field, set flag so they can be corrected later (in ->remapListedDBRecords())
4193  if (($this->‪isReferenceField($dsConf) || $this->‪getRelationFieldType($dsConf) !== false) && (string)$dataValue !== '') {
4194  $dataValue = $this->‪copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $dataValue, [], $dsConf, $realDestPid, 0, $workspaceOptions);
4195  $this->registerDBList[$table][‪$uid][$field] = 'FlexForm_reference';
4196  }
4197  // Return
4198  return ['value' => $dataValue];
4199  }
4200 
4212  public function ‪copyL10nOverlayRecords($table, ‪$uid, $destPid, $first = false, $overrideValues = [], $excludeFields = ''): void
4213  {
4214  // There's no need to perform this for tables that are not localizable
4215  if (!BackendUtility::isTableLocalizable($table)) {
4216  return;
4217  }
4218 
4219  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
4220  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4221 
4222  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
4223  $queryBuilder->getRestrictions()->removeAll()
4224  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4225  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
4226 
4227  $queryBuilder->select('*')
4228  ->from($table)
4229  ->where(
4230  $queryBuilder->expr()->eq(
4231  $transOrigPointerField,
4232  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT, ':pointer')
4233  )
4234  );
4235 
4236  // Never copy the actual placeholders around, as the newly copied records are
4237  // always created as new record / new placeholder pairs
4238  if (BackendUtility::isTableWorkspaceEnabled($table)) {
4239  $queryBuilder->andWhere(
4240  $queryBuilder->expr()->neq(
4241  't3ver_state',
4242  VersionState::DELETE_PLACEHOLDER->value
4243  )
4244  );
4245  }
4246 
4247  // If $destPid is < 0, get the pid of the record with uid equal to abs($destPid)
4248  $tscPID = BackendUtility::getTSconfig_pidValue($table, ‪$uid, $destPid) ?? 0;
4249  // Get the localized records to be copied
4250  $l10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4251  if (is_array($l10nRecords)) {
4252  $localizedDestPids = [];
4253  // If $destPid < 0, then it is the uid of the original language record we are inserting after
4254  if ($destPid < 0) {
4255  // Get the localized records of the record we are inserting after
4256  $queryBuilder->setParameter('pointer', abs($destPid), ‪Connection::PARAM_INT);
4257  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4258  // Index the localized record uids by language
4259  if (is_array($destL10nRecords)) {
4260  foreach ($destL10nRecords as ‪$record) {
4261  $localizedDestPids[‪$record[$languageField]] = -‪$record['uid'];
4262  }
4263  }
4264  }
4265  $languageSourceMap = [
4266  ‪$uid => $overrideValues[$transOrigPointerField],
4267  ];
4268  // Copy the localized records after the corresponding localizations of the destination record
4269  foreach ($l10nRecords as ‪$record) {
4270  $localizedDestPid = (int)($localizedDestPids[‪$record[$languageField]] ?? 0);
4271  if ($localizedDestPid < 0) {
4272  $newUid = $this->‪copyRecord($table, ‪$record['uid'], $localizedDestPid, $first, $overrideValues, $excludeFields, ‪$record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4273  } else {
4274  $newUid = $this->‪copyRecord($table, ‪$record['uid'], $destPid < 0 ? $tscPID : $destPid, $first, $overrideValues, $excludeFields, ‪$record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4275  }
4276  $languageSourceMap[‪$record['uid']] = $newUid;
4277  }
4278  $this->‪copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap);
4279  }
4280  }
4281 
4289  protected function ‪copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap): void
4290  {
4291  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4292  return;
4293  }
4294  $translationSourceFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
4295  $translationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
4296 
4297  //We can avoid running these update queries by sorting the $l10nRecords by languageSource dependency (in copyL10nOverlayRecords)
4298  //and first copy records depending on default record (and map the field).
4299  foreach ($l10nRecords as ‪$record) {
4300  $oldSourceUid = ‪$record[$translationSourceFieldName];
4301  if ($oldSourceUid <= 0 && ‪$record[$translationParentFieldName] > 0) {
4302  //BC fix - in connected mode 'translationSource' field should not be 0
4303  $oldSourceUid = ‪$record[$translationParentFieldName];
4304  }
4305  if ($oldSourceUid > 0) {
4306  if (empty($languageSourceMap[$oldSourceUid])) {
4307  // we don't have mapping information available e.g when copyRecord returned null
4308  continue;
4309  }
4310  $newFieldValue = $languageSourceMap[$oldSourceUid];
4311  $updateFields = [
4312  $translationSourceFieldName => $newFieldValue,
4313  ];
4314  if (isset($languageSourceMap[‪$record['uid']])) {
4315  $this->connectionPool->getConnectionForTable($table)
4316  ->update($table, $updateFields, ['uid' => (int)$languageSourceMap[‪$record['uid']]]);
4317  if ($this->BE_USER->workspace > 0) {
4318  $this->connectionPool->getConnectionForTable($table)
4319  ->update($table, $updateFields, ['t3ver_oid' => (int)$languageSourceMap[‪$record['uid']], 't3ver_wsid' => $this->BE_USER->workspace]);
4320  }
4321  }
4322  }
4323  }
4324  }
4325 
4326  /*********************************************
4327  *
4328  * Cmd: Moving, Localizing
4329  *
4330  ********************************************/
4339  public function ‪moveRecord($table, ‪$uid, $destPid): void
4340  {
4341  if (!‪$GLOBALS['TCA'][$table]) {
4342  return;
4343  }
4344 
4345  // In case the record to be moved turns out to be an offline version,
4346  // we have to find the live version and work on that one.
4347  if ($lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, ‪$uid, 'uid')) {
4348  ‪$uid = $lookForLiveVersion['uid'];
4349  }
4350  // Initialize:
4351  $destPid = (int)$destPid;
4352  // Get this before we change the pid (for logging)
4353  $propArr = $this->‪getRecordProperties($table, ‪$uid);
4354  $moveRec = $this->‪getRecordProperties($table, ‪$uid, true);
4355  // This is the actual pid of the moving to destination
4356  $resolvedPid = $this->‪resolvePid($table, $destPid);
4357  // Finding out, if the record may be moved from where it is. If the record is a non-page, then it depends on edit-permissions.
4358  // If the record is a page, then there are two options: If the page is moved within itself,
4359  // (same pid) it's edit-perms of the pid. If moved to another place then its both delete-perms of the pid and new-page perms on the destination.
4360  if ($table !== 'pages' || $resolvedPid == $moveRec['pid']) {
4361  // Edit rights for the record...
4362  $mayMoveAccess = $this->‪checkRecordUpdateAccess($table, ‪$uid);
4363  } else {
4364  $mayMoveAccess = $this->‪doesRecordExist($table, ‪$uid, ‪Permission::PAGE_DELETE);
4365  }
4366  // Finding out, if the record may be moved TO another place. Here we check insert-rights (non-pages = edit, pages = new),
4367  // unless the pages are moved on the same pid, then edit-rights are checked
4368  if ($table !== 'pages' || $resolvedPid != $moveRec['pid']) {
4369  // Insert rights for the record...
4370  $mayInsertAccess = $this->‪checkRecordInsertAccess($table, $resolvedPid, SystemLogDatabaseAction::MOVE);
4371  } else {
4372  $mayInsertAccess = $this->‪checkRecordUpdateAccess($table, ‪$uid);
4373  }
4374  // Checking if there is anything else disallowing moving the record by checking if editing is allowed
4375  $fullLanguageCheckNeeded = $table !== 'pages';
4376  $mayEditAccess = $this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, false, $fullLanguageCheckNeeded);
4377  // If moving is allowed, begin the processing:
4378  if (!$mayEditAccess) {
4379  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to do so [{reason}]', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'reason' => $this->BE_USER->errorMsg], $propArr['event_pid']);
4380  return;
4381  }
4382 
4383  if (!$mayMoveAccess) {
4384  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to do so', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4385  return;
4386  }
4387 
4388  if (!$mayInsertAccess) {
4389  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to insert', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4390  return;
4391  }
4392 
4393  $recordWasMoved = false;
4394  // Move the record via a hook, used e.g. for versioning
4395  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4396  $hookObj = GeneralUtility::makeInstance($className);
4397  if (method_exists($hookObj, 'moveRecord')) {
4399  $hookObj->moveRecord($table, ‪$uid, $destPid, $propArr, $moveRec, $resolvedPid, $recordWasMoved, $this);
4400  }
4401  }
4402  // Move the record if a hook hasn't moved it yet
4403  if (!$recordWasMoved) {
4404  $this->‪moveRecord_raw($table, ‪$uid, $destPid);
4405  }
4406  }
4407 
4418  public function ‪moveRecord_raw($table, ‪$uid, $destPid): void
4419  {
4420  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
4421  $origDestPid = $destPid;
4422  // This is the actual pid of the moving to destination
4423  $resolvedPid = $this->‪resolvePid($table, $destPid);
4424  // Checking if the pid is negative, but no sorting row is defined. In that case, find the correct pid.
4425  // Basically this check make the error message 4-13 meaning less... But you can always remove this check if you
4426  // prefer the error instead of a no-good action (which is to move the record to its own page...)
4427  if (($destPid < 0 && !$sortColumn) || $destPid >= 0) {
4428  $destPid = $resolvedPid;
4429  }
4430  // Get this before we change the pid (for logging)
4431  $propArr = $this->‪getRecordProperties($table, ‪$uid);
4432  $moveRec = $this->‪getRecordProperties($table, ‪$uid, true);
4433  // Prepare user defined objects (if any) for hooks which extend this function:
4434  $hookObjectsArr = [];
4435  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4436  $hookObjectsArr[] = GeneralUtility::makeInstance($className);
4437  }
4438  // Timestamp field:
4439  $updateFields = [];
4440  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
4441  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
4442  }
4443 
4444  // Check if this is a translation of a page, if so then it just needs to be kept "sorting" in sync
4445  // Usually called from moveL10nOverlayRecords()
4446  if ($table === 'pages') {
4447  $defaultLanguagePageUid = $this->‪getDefaultLanguagePageId((int)$uid);
4448  // In workspaces, the default language page may have been moved to a different pid than the
4449  // default language page record of live workspace. In this case, localized pages need to be
4450  // moved to the pid of the workspace move record.
4451  $defaultLanguagePageWorkspaceOverlay = BackendUtility::getWorkspaceVersionOfRecord((int)$this->BE_USER->workspace, 'pages', $defaultLanguagePageUid, 'uid');
4452  if (is_array($defaultLanguagePageWorkspaceOverlay)) {
4453  $defaultLanguagePageUid = (int)$defaultLanguagePageWorkspaceOverlay['uid'];
4454  }
4455  if ($defaultLanguagePageUid !== (int)‪$uid) {
4456  // If the default language page has been moved, localized pages need to be moved to
4457  // that pid and sorting, too.
4458  $originalTranslationRecord = $this->‪recordInfo($table, $defaultLanguagePageUid);
4459  $updateFields[$sortColumn] = $originalTranslationRecord[$sortColumn];
4460  $destPid = $originalTranslationRecord['pid'];
4461  }
4462  }
4463 
4464  // Insert as first element on page (where uid = $destPid)
4465  if ($destPid >= 0) {
4466  if ($table !== 'pages' || $this->‪destNotInsideSelf($destPid, ‪$uid)) {
4467  // Clear cache before moving
4468  [$parentUid] = BackendUtility::getTSCpid($table, ‪$uid, '');
4469  $this->‪registerRecordIdForPageCacheClearing($table, ‪$uid, $parentUid);
4470  // Setting PID
4471  $updateFields['pid'] = $destPid;
4472  // Table is sorted by 'sortby'
4473  if ($sortColumn && !isset($updateFields[$sortColumn])) {
4474  $sortNumber = $this->‪getSortNumber($table, ‪$uid, $destPid);
4475  $updateFields[$sortColumn] = $sortNumber;
4476  }
4477  // Check for child records that have also to be moved
4478  $this->‪moveRecord_procFields($table, ‪$uid, $destPid);
4479  // Create query for update:
4480  $this->connectionPool->getConnectionForTable($table)
4481  ->update($table, $updateFields, ['uid' => (int)‪$uid]);
4482  // Check for the localizations of that element
4483  $this->‪moveL10nOverlayRecords($table, ‪$uid, $destPid, $destPid);
4484  // Call post-processing hooks:
4485  foreach ($hookObjectsArr as $hookObj) {
4486  if (method_exists($hookObj, 'moveRecord_firstElementPostProcess')) {
4487  $hookObj->moveRecord_firstElementPostProcess($table, ‪$uid, $destPid, $moveRec, $updateFields, $this);
4488  }
4489  }
4490 
4491  $this->‪getRecordHistoryStore()->moveRecord($table, ‪$uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4492  if ($this->enableLogging) {
4493  // Logging...
4494  $oldpagePropArr = $this->‪getRecordProperties('pages', $propArr['pid']);
4495  if ($destPid != $propArr['pid']) {
4496  // Logged to old page
4497  $newPropArr = $this->‪getRecordProperties($table, ‪$uid);
4498  $newpagePropArr = $this->‪getRecordProperties('pages', $destPid);
4499  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) to page "{pageTitle}" ({pid})', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $newpagePropArr['header'], 'pid' => $newPropArr['pid']], $propArr['pid']);
4500  // Logged to new page
4501  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) from page "{pageTitle}" ({pid}))', 3, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4502  } else {
4503  // Logged to new page
4504  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) on page "{pageTitle}" ({pid})', 4, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4505  }
4506  }
4507  // Clear cache after moving
4509  $this->‪fixUniqueInPid($table, ‪$uid);
4510  $this->‪fixUniqueInSite($table, (int)‪$uid);
4511  if ($table === 'pages') {
4512  $this->‪fixUniqueInSiteForSubpages((int)$uid);
4513  }
4514  } elseif ($this->enableLogging) {
4515  $destPropArr = $this->‪getRecordProperties('pages', $destPid);
4516  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page "{title}" ({uid}) to inside of its own rootline (at page "{pageTitle}" ({pid}))', 10, ['title' => $propArr['header'], 'uid' => ‪$uid, 'pageTitle' => $destPropArr['header'], 'pid' => $destPid], $propArr['pid']);
4517  }
4518  } elseif ($sortColumn) {
4519  // Put after another record
4520  // Table is being sorted
4521  // Save the position to which the original record is requested to be moved
4522  $originalRecordDestinationPid = $destPid;
4523  $sortInfo = $this->‪getSortNumber($table, ‪$uid, $destPid);
4524  // If not an array, there was an error (which is already logged)
4525  if (is_array($sortInfo)) {
4526  // Setting the destPid to the new pid of the record.
4527  $destPid = $sortInfo['pid'];
4528  if ($table !== 'pages' || $this->‪destNotInsideSelf($destPid, ‪$uid)) {
4529  // clear cache before moving
4531  // We now update the pid and sortnumber (if not set for page translations)
4532  $updateFields['pid'] = $destPid;
4533  if (!isset($updateFields[$sortColumn])) {
4534  $updateFields[$sortColumn] = $sortInfo['sortNumber'];
4535  }
4536  // Check for child records that have also to be moved
4537  $this->‪moveRecord_procFields($table, ‪$uid, $destPid);
4538  // Create query for update:
4539  $this->connectionPool->getConnectionForTable($table)
4540  ->update($table, $updateFields, ['uid' => (int)‪$uid]);
4541  // Check for the localizations of that element
4542  $this->‪moveL10nOverlayRecords($table, ‪$uid, $destPid, $originalRecordDestinationPid);
4543  // Call post-processing hooks:
4544  foreach ($hookObjectsArr as $hookObj) {
4545  if (method_exists($hookObj, 'moveRecord_afterAnotherElementPostProcess')) {
4546  $hookObj->moveRecord_afterAnotherElementPostProcess($table, ‪$uid, $destPid, $origDestPid, $moveRec, $updateFields, $this);
4547  }
4548  }
4549  $this->‪getRecordHistoryStore()->moveRecord($table, ‪$uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4550  if ($this->enableLogging) {
4551  // Logging...
4552  $oldpagePropArr = $this->‪getRecordProperties('pages', $propArr['pid']);
4553  if ($destPid != $propArr['pid']) {
4554  // Logged to old page
4555  $newPropArr = $this->‪getRecordProperties($table, ‪$uid);
4556  $newpagePropArr = $this->‪getRecordProperties('pages', $destPid);
4557  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) to page "{pageTitle}" ({pid})', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $newpagePropArr['header'], 'pid' => $newPropArr['pid']], $propArr['pid']);
4558  // Logged to old page
4559  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) from page "{pageTitle}" ({pid})', 3, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4560  } else {
4561  // Logged to old page
4562  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) on page "{pageTitle}" ({pid})', 4, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4563  }
4564  }
4565  // Clear cache after moving
4567  $this->‪fixUniqueInPid($table, ‪$uid);
4568  $this->‪fixUniqueInSite($table, (int)‪$uid);
4569  if ($table === 'pages') {
4570  $this->‪fixUniqueInSiteForSubpages((int)$uid);
4571  }
4572  } elseif ($this->enableLogging) {
4573  $destPropArr = $this->‪getRecordProperties('pages', $destPid);
4574  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page "{title}" ({uid}) to inside of its own rootline (at page "{pageTitle}" [{pid}])', 10, ['title' => $propArr['header'], 'uid' => ‪$uid, 'pageTitle' => $destPropArr['header'], 'pid' => $destPid], $propArr['pid']);
4575  }
4576  } else {
4577  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) to after another record, although the table has no sorting row', 13, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4578  }
4579  }
4580  }
4581 
4591  public function ‪moveRecord_procFields($table, ‪$uid, $destPid): void
4592  {
4593  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
4594  if (is_array($row) && (int)$destPid !== (int)$row['pid']) {
4595  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
4596  foreach ($row as $field => $value) {
4597  $this->‪moveRecord_procBasedOnFieldType($table, ‪$uid, $destPid, $value, $conf[$field]['config'] ?? []);
4598  }
4599  }
4600  }
4601 
4612  public function ‪moveRecord_procBasedOnFieldType($table, ‪$uid, $destPid, $value, $conf): void
4613  {
4614  if (($conf['behaviour']['disableMovingChildrenWithParent'] ?? false)
4615  || !in_array($this->‪getRelationFieldType($conf), ['list', 'field'], true)
4616  ) {
4617  return;
4618  }
4619 
4620  if ($table === 'pages') {
4621  // If the relations are related to a page record, make sure they reside at that page and not at its parent
4622  $destPid = ‪$uid;
4623  }
4624 
4625  $dbAnalysis = $this->‪createRelationHandlerInstance();
4626  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
4627 
4628  // Moving records to a positive destination will insert each
4629  // record at the beginning, thus the order is reversed here:
4630  foreach (array_reverse($dbAnalysis->itemArray) as $item) {
4631  $this->‪moveRecord($item['table'], $item['id'], $destPid);
4632  }
4633  }
4634 
4644  public function ‪moveL10nOverlayRecords($table, ‪$uid, $destPid, $originalRecordDestinationPid): void
4645  {
4646  // There's no need to perform this for non-localizable tables
4647  if (!BackendUtility::isTableLocalizable($table)) {
4648  return;
4649  }
4650 
4651  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
4652  $queryBuilder->getRestrictions()->removeAll()
4653  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4654  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace));
4655 
4656  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
4657  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4658  $l10nRecords = $queryBuilder->select('*')
4659  ->from($table)
4660  ->where(
4661  $queryBuilder->expr()->eq(
4662  $transOrigPointerField,
4663  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT, ':pointer')
4664  )
4665  )
4666  ->executeQuery()
4667  ->fetchAllAssociative();
4668 
4669  if (is_array($l10nRecords)) {
4670  $localizedDestPids = [];
4671  // If $$originalRecordDestinationPid < 0, then it is the uid of the original language record we are inserting after
4672  if ($originalRecordDestinationPid < 0) {
4673  // Get the localized records of the record we are inserting after
4674  $queryBuilder->setParameter('pointer', abs($originalRecordDestinationPid), ‪Connection::PARAM_INT);
4675  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4676  // Index the localized record uids by language
4677  if (is_array($destL10nRecords)) {
4678  foreach ($destL10nRecords as ‪$record) {
4679  $localizedDestPids[‪$record[$languageField]] = -‪$record['uid'];
4680  }
4681  }
4682  }
4683  // Move the localized records after the corresponding localizations of the destination record
4684  foreach ($l10nRecords as ‪$record) {
4685  $localizedDestPid = (int)($localizedDestPids[‪$record[$languageField]] ?? 0);
4686  if ($localizedDestPid < 0) {
4687  $this->‪moveRecord($table, ‪$record['uid'], $localizedDestPid);
4688  } else {
4689  $this->‪moveRecord($table, ‪$record['uid'], $destPid);
4690  }
4691  }
4692  }
4693  }
4694 
4704  public function ‪localize($table, ‪$uid, $language)
4705  {
4706  $newId = false;
4707  ‪$uid = (int)‪$uid;
4708  if (!‪$GLOBALS['TCA'][$table] || !‪$uid || $this->‪isNestedElementCallRegistered($table, ‪$uid, 'localize-' . (string)$language) !== false) {
4709  return false;
4710  }
4711 
4712  $this->‪registerNestedElementCall($table, ‪$uid, 'localize-' . (string)$language);
4713  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4714  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed; "languageField" and "transOrigPointerField" must be defined for the table {table}', -1, ['table' => $table]);
4715  return false;
4716  }
4717 
4718  if (!$this->‪doesRecordExist($table, ‪$uid, ‪Permission::PAGE_SHOW)) {
4719  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} without permission', -1, ['table' => $table, 'uid' => (int)‪$uid]);
4720  return false;
4721  }
4722 
4723  // Getting workspace overlay if possible - this will localize versions in workspace if any
4724  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
4725  if (!is_array($row)) {
4726  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} that did not exist', -1, ['table' => $table, 'uid' => (int)‪$uid]);
4727  return false;
4728  }
4729 
4730  [$pageId] = BackendUtility::getTSCpid($table, ‪$uid, '');
4731  // Try to fetch the site language from the pages' associated site
4732  $siteLanguage = $this->‪getSiteLanguageForPage((int)$pageId, (int)$language);
4733  if ($siteLanguage === null) {
4734  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Language ID "{languageId}" not found for page {pageId}', -1, ['languageId' => (int)$language, 'pageId' => (int)$pageId]);
4735  return false;
4736  }
4737 
4738  // Make sure that records which are translated from another language than the default language have a correct
4739  // localization source set themselves, before translating them to another language.
4740  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4741  && $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4742  $localizationParentRecord = BackendUtility::getRecord(
4743  $table,
4744  $row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4745  );
4746  if ((int)$localizationParentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4747  $this->‪log($table, $localizationParentRecord['uid'], SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed: Source record {table}:{originalRecordId} contained a reference to an original record that is not a default record (which is strange)', -1, ['table' => $table, 'originalRecordId' => $localizationParentRecord['uid']]);
4748  return false;
4749  }
4750  }
4751 
4752  // Default language records must never have a localization parent as they are the origin of any translation.
4753  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4754  && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4755  $this->‪log($table, $row['uid'], SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed: Source record {table}:{uid} contained a reference to an original default record but is a default record itself (which is strange)', -1, ['table' => $table, 'uid' => (int)$row['uid']]);
4756  return false;
4757  }
4758 
4759  $recordLocalizations = BackendUtility::getRecordLocalization($table, ‪$uid, $language, 'AND pid=' . (int)$row['pid']);
4760 
4761  if (!empty($recordLocalizations)) {
4762  $this->‪log(
4763  $table,
4764  ‪$uid,
4765  SystemLogDatabaseAction::LOCALIZE,
4766  0,
4767  SystemLogErrorClassification::USER_ERROR,
4768  'Localization failed: There already are localizations ({localizations}) for language {language} of the "{table}" record {uid}',
4769  -1,
4770  [
4771  'localizations' => implode(', ', array_column($recordLocalizations, 'uid')),
4772  'language' => $language,
4773  'table' => $table,
4774  'uid' => ‪$uid,
4775  ]
4776  );
4777  return false;
4778  }
4779 
4780  // Initialize:
4781  $overrideValues = [];
4782  // Set override values:
4783  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] = (int)$language;
4784  // If the translated record is a default language record, set it's uid as localization parent of the new record.
4785  // If translating from any other language, no override is needed; we just can copy the localization parent of
4786  // the original record (which is pointing to the correspondent default language record) to the new record.
4787  // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4788  // For pages, there is no "copy/free mode".
4789  if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4790  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = ‪$uid;
4791  } elseif (!$this->useTransOrigPointerField) {
4792  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4793  }
4794  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4795  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = ‪$uid;
4796  }
4797  // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4798  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['type'])) {
4799  // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4800  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[‪$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4801  }
4802  // Set exclude Fields:
4803  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4804  $translateToMsg = '';
4805  // Check if we are just prefixing:
4806  if (isset($fCfg['l10n_mode'], $fCfg['config']['type'])
4807  && $fCfg['l10n_mode'] === 'prefixLangTitle'
4808  && (
4809  $fCfg['config']['type'] === 'text'
4810  || $fCfg['config']['type'] === 'input'
4811  || $fCfg['config']['type'] === 'email'
4812  || $fCfg['config']['type'] === 'link'
4813  )
4814  && (string)$row[$fN] !== ''
4815  ) {
4816  $TSConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? [];
4817  $tableEntries = $this->‪getTableEntries($table, $TSConfig);
4818  if (!empty($TSConfig['translateToMessage']) && !($tableEntries['disablePrependAtCopy'] ?? false)) {
4819  $translateToMsg = $this->‪getLanguageService()->sL($TSConfig['translateToMessage']);
4820  $translateToMsg = @sprintf($translateToMsg, $siteLanguage->getTitle());
4821  }
4822 
4823  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4824  $hookObj = GeneralUtility::makeInstance($className);
4825  if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4826  // @todo Deprecate passing an array and pass the full SiteLanguage object instead
4827  $hookObj->processTranslateTo_copyAction(
4828  $row[$fN],
4829  ['uid' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle()],
4830  $this,
4831  $fN
4832  );
4833  }
4834  }
4835  if (!empty($translateToMsg)) {
4836  $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4837  } else {
4838  $overrideValues[$fN] = $row[$fN];
4839  }
4840  }
4841  if (($fCfg['config']['MM'] ?? false) && !empty($fCfg['config']['MM_oppositeUsage'])) {
4842  // We are localizing the 'local' side of an MM relation. (eg. localizing a category).
4843  // In this case, MM relations connected to the default lang record should not be copied,
4844  // so we set an override here to not trigger mm handling of 'items' field for this.
4845  $overrideValues[$fN] = 0;
4846  }
4847  }
4848 
4849  if ($table !== 'pages') {
4850  // Get the uid of record after which this localized record should be inserted
4851  $previousUid = $this->‪getPreviousLocalizedRecordUid($table, ‪$uid, $row['pid'], $language);
4852  // Execute the copy:
4853  $newId = $this->‪copyRecord($table, ‪$uid, -$previousUid, true, $overrideValues, '', $language);
4854  } else {
4855  // Create new page which needs to contain the same pid as the original page
4856  $overrideValues['pid'] = $row['pid'];
4857  // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4858  // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4859  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4860  $hiddenFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4861  $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? ‪$GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4862  // Override by TCA "hideAtCopy" or pageTS "disableHideAtCopy"
4863  // Only for visible pages to get the same behaviour as for copy
4864  if (!$overrideValues[$hiddenFieldName]) {
4865  $TSConfig = BackendUtility::getPagesTSconfig(‪$uid)['TCEMAIN.'] ?? [];
4866  $tableEntries = $this->‪getTableEntries($table, $TSConfig);
4867  if (
4868  (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
4869  && !$this->neverHideAtCopy
4870  && !($tableEntries['disableHideAtCopy'] ?? false)
4871  ) {
4872  $overrideValues[$hiddenFieldName] = 1;
4873  }
4874  }
4875  }
4876  $temporaryId = ‪StringUtility::getUniqueId('NEW');
4877  $copyTCE = $this->‪getLocalTCE();
4878  $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4879  $copyTCE->process_datamap();
4880  // Getting the new UID as if it had been copied:
4881  $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4882  if ($theNewSQLID) {
4883  $this->copyMappingArray[$table][‪$uid] = $theNewSQLID;
4884  $newId = $theNewSQLID;
4885  }
4886  }
4887 
4888  return $newId;
4889  }
4890 
4907  protected function ‪inlineLocalizeSynchronize($table, $id, array $command): void
4908  {
4909  $parentRecord = BackendUtility::getRecordWSOL($table, $id);
4910 
4911  // In case the parent record is the default language record, fetch the localization
4912  if (empty($parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
4913  // Fetch the live record
4914  // @todo: this needs to be revisited, as getRecordLocalization() does a WorkspaceRestriction
4915  // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
4916  $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
4917  if (empty($parentRecordLocalization)) {
4918  $this->‪log($table, $id, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::MESSAGE, 'Localization for parent record {table}:{uid} cannot be fetched', -1, ['table' => $table, 'uid' => (int)$id], $this->‪eventPid($table, $id, $parentRecord['pid']));
4919  return;
4920  }
4921  $parentRecord = $parentRecordLocalization[0];
4922  $id = $parentRecord['uid'];
4923  // Process overlay for current selected workspace
4924  BackendUtility::workspaceOL($table, $parentRecord);
4925  }
4926 
4927  $field = $command['field'] ?? '';
4928  $language = $command['language'] ?? 0;
4929  $action = $command['action'] ?? '';
4930  $ids = $command['ids'] ?? [];
4931 
4932  if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset(‪$GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
4933  return;
4934  }
4935 
4936  $config = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
4937  $foreignTable = $config['foreign_table'];
4938 
4939  $transOrigPointer = (int)$parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
4940  $childTransOrigPointerField = ‪$GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
4941 
4942  if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
4943  return;
4944  }
4945 
4946  $relationFieldType = $this->‪getRelationFieldType($config);
4947  if ($relationFieldType === false) {
4948  return;
4949  }
4950 
4951  $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
4952 
4953  $removeArray = [];
4954  $mmTable = $relationFieldType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
4955  // Fetch children from original language parent:
4956  $dbAnalysisOriginal = $this->‪createRelationHandlerInstance();
4957  $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
4958  $elementsOriginal = [];
4959  foreach ($dbAnalysisOriginal->itemArray as $item) {
4960  $elementsOriginal[$item['id']] = $item;
4961  }
4962  unset($dbAnalysisOriginal);
4963  // Fetch children from current localized parent:
4964  $dbAnalysisCurrent = $this->‪createRelationHandlerInstance();
4965  $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
4966  // Perform synchronization: Possibly removal of already localized records:
4967  if ($action === 'synchronize') {
4968  foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
4969  $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
4970  if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
4971  $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
4972  // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
4973  if (!isset($elementsOriginal[$childTransOrigPointer])) {
4974  unset($dbAnalysisCurrent->itemArray[$index]);
4975  $removeArray[$item['table']][$item['id']]['delete'] = 1;
4976  }
4977  }
4978  }
4979  }
4980  // Perform synchronization/localization: Possibly add unlocalized records for original language:
4981  if ($action === 'localize' || $action === 'synchronize') {
4982  foreach ($elementsOriginal as $originalId => $item) {
4983  if ($this->‪isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4984  continue;
4985  }
4986  $item['id'] = $this->‪localize($item['table'], $item['id'], $language);
4987 
4988  if (is_int($item['id'])) {
4989  $item['id'] = $this->‪overlayAutoVersionId($item['table'], $item['id']);
4990  }
4991  $dbAnalysisCurrent->itemArray[] = $item;
4992  }
4993  } elseif (!empty($ids)) {
4994  foreach ($ids as $childId) {
4995  if (!‪MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
4996  continue;
4997  }
4998  $item = $elementsOriginal[$childId];
4999  if ($this->‪isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
5000  continue;
5001  }
5002  $item['id'] = $this->‪localize($item['table'], $item['id'], $language);
5003  if (is_int($item['id'])) {
5004  $item['id'] = $this->‪overlayAutoVersionId($item['table'], $item['id']);
5005  }
5006  $dbAnalysisCurrent->itemArray[] = $item;
5007  }
5008  }
5009  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
5010  $value = implode(',', $dbAnalysisCurrent->getValueArray());
5011  $this->registerDBList[$table][$id][$field] = $value;
5012  // Remove child records (if synchronization requested it):
5013  if (is_array($removeArray) && !empty($removeArray)) {
5014  $tce = GeneralUtility::makeInstance(self::class, $this->referenceIndexUpdater);
5015  $tce->enableLogging = ‪$this->enableLogging;
5016  $tce->start([], $removeArray, $this->BE_USER);
5017  $tce->process_cmdmap();
5018  unset($tce);
5019  }
5020  $updateFields = [];
5021  // Handle, reorder and store relations:
5022  if ($relationFieldType === 'list') {
5023  $updateFields = [$field => $value];
5024  } elseif ($relationFieldType === 'field') {
5025  $dbAnalysisCurrent->writeForeignField($config, $id);
5026  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
5027  } elseif ($relationFieldType === 'mm') {
5028  $dbAnalysisCurrent->writeMM($config['MM'], $id);
5029  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
5030  }
5031  // Update field referencing to child records of localized parent record:
5032  if (!empty($updateFields)) {
5033  $this->‪updateDB($table, $id, $updateFields);
5034  }
5035  if (isset($parentRecord['_ORIG_uid']) && (int)$parentRecord['_ORIG_uid'] !== (int)$id) {
5036  // If there is a ws overlay of the record, then the relation has been attached to *this*
5037  // record, even though the uids point to live. We still need to update refindex of the overlay
5038  // to reflect this relation.
5039  $this->‪updateRefIndex($table, (int)$parentRecord['_ORIG_uid']);
5040  }
5041  }
5042 
5046  protected function ‪isRecordLocalized(string $table, int ‪$uid, int $language): bool
5047  {
5048  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
5049  $localizations = BackendUtility::getRecordLocalization($table, ‪$uid, $language, 'pid=' . (int)$row['pid']);
5050  return !empty($localizations);
5051  }
5052 
5053  /*********************************************
5054  *
5055  * Cmd: delete
5056  *
5057  ********************************************/
5065  public function ‪deleteAction($table, $id): void
5066  {
5067  $recordToDelete = BackendUtility::getRecord($table, $id);
5068 
5069  if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
5070  // When dealing with a workspace record, use discard.
5071  $this->‪discard($table, null, $recordToDelete);
5072  return;
5073  }
5074 
5075  // Record asked to be deleted was found:
5076  if (is_array($recordToDelete)) {
5077  $recordWasDeleted = false;
5078  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5079  $hookObj = GeneralUtility::makeInstance($className);
5080  if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
5082  $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
5083  }
5084  }
5085  // Delete the record if a hook hasn't deleted it yet
5086  if (!$recordWasDeleted) {
5087  $this->‪deleteEl($table, $id);
5088  }
5089  }
5090  }
5091 
5102  public function ‪deleteEl(string $table, int ‪$uid, bool $noRecordCheck = false, bool $forceHardDelete = false, bool $deleteRecordsOnPage = true): void
5103  {
5104  if ($table === 'pages') {
5105  $this->‪deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
5106  } else {
5109  $this->‪deleteRecord($table, ‪$uid, $noRecordCheck, $forceHardDelete);
5110  }
5111  }
5112 
5119  protected function ‪discardLocalizedWorkspaceVersionsOfRecord(string $table, int ‪$uid): void
5120  {
5121  if (!BackendUtility::isTableLocalizable($table)
5122  || !BackendUtility::isTableWorkspaceEnabled($table)
5123  || !$this->BE_USER->recordEditAccessInternals($table, ‪$uid)
5124  ) {
5125  return;
5126  }
5127  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
5128  $localizationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
5129  $liveRecord = BackendUtility::getRecord($table, ‪$uid);
5130  if ((int)($liveRecord[$languageField] ?? 0) !== 0 || (int)($liveRecord['t3ver_wsid'] ?? 0) !== 0) {
5131  // Don't do anything if we're not deleting a live record in default language
5132  return;
5133  }
5134  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
5135  $queryBuilder->getRestrictions()->removeAll();
5136  $queryBuilder = $queryBuilder->select('*')->from($table)
5137  ->where(
5138  // workspace elements
5139  $queryBuilder->expr()->gt('t3ver_wsid', $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
5140  // with sys_language_uid > 0
5141  $queryBuilder->expr()->gt($languageField, $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
5142  // in state 'new'
5143  $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, ‪Connection::PARAM_INT)),
5144  // with "l10n_parent" set to uid of live record
5145  $queryBuilder->expr()->eq($localizationParentFieldName, $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT))
5146  );
5147  $result = $queryBuilder->executeQuery();
5148  while ($row = $result->fetchAssociative()) {
5149  // BE user must be put into this workspace temporarily so stuff like refindex updating
5150  // is properly registered for this workspace when discarding records in there.
5151  $currentUserWorkspace = $this->BE_USER->workspace;
5152  $this->BE_USER->workspace = (int)$row['t3ver_wsid'];
5153  $this->‪discard($table, null, $row);
5154  // Switch user back to original workspace
5155  $this->BE_USER->workspace = $currentUserWorkspace;
5156  }
5157  }
5158 
5167  protected function ‪discardWorkspaceVersionsOfRecord($table, ‪$uid): void
5168  {
5169  $versions = BackendUtility::selectVersionsOfRecord($table, ‪$uid, '*', null);
5170  if ($versions === null) {
5171  // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
5172  return;
5173  }
5174  foreach ($versions as ‪$record) {
5175  if (‪$record['_CURRENT_VERSION'] ?? false) {
5176  // The live record is included in the result from selectVersionsOfRecord()
5177  // and marked as '_CURRENT_VERSION'. Skip this one.
5178  continue;
5179  }
5180  // BE user must be put into this workspace temporarily so stuff like refindex updating
5181  // is properly registered for this workspace when discarding records in there.
5182  $currentUserWorkspace = $this->BE_USER->workspace;
5183  $this->BE_USER->workspace = (int)‪$record['t3ver_wsid'];
5184  $this->‪discard($table, null, ‪$record);
5185  // Switch user back to original workspace
5186  $this->BE_USER->workspace = $currentUserWorkspace;
5187  }
5188  }
5189 
5202  public function ‪deleteRecord(string $table, int ‪$uid, bool $noRecordCheck = false, bool $forceHardDelete = false): void
5203  {
5204  $currentUserWorkspace = $this->BE_USER->workspace;
5205  if (!‪$GLOBALS['TCA'][$table] || !‪$uid) {
5206  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
5207  return;
5208  }
5209  // Skip processing already deleted records
5210  if (!$forceHardDelete && $this->‪hasDeletedRecord($table, ‪$uid)) {
5211  return;
5212  }
5213 
5214  // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
5215  $fullLanguageAccessCheck = true;
5216  if ($table === 'pages') {
5217  // If this is a page translation, the full language access check should not be done
5218  $defaultLanguagePageId = $this->‪getDefaultLanguagePageId($uid);
5219  if ($defaultLanguagePageId !== ‪$uid) {
5220  $fullLanguageAccessCheck = false;
5221  }
5222  }
5223  $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, $forceHardDelete, $fullLanguageAccessCheck);
5224  if (!$hasEditAccess) {
5225  $this->‪log($table, ‪$uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
5226  return;
5227  }
5228  if ($table === 'pages') {
5229  $perms = ‪Permission::PAGE_DELETE;
5230  } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5231  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5232  $perms = ‪Permission::PAGE_EDIT;
5233  } else {
5234  $perms = ‪Permission::CONTENT_EDIT;
5235  }
5236  if (!$noRecordCheck && !$this->‪doesRecordExist($table, ‪$uid, $perms)) {
5237  return;
5238  }
5239 
5240  $recordToDelete = [];
5241  $recordWorkspaceId = 0;
5242  if (BackendUtility::isTableWorkspaceEnabled($table)) {
5243  $recordToDelete = BackendUtility::getRecord($table, ‪$uid);
5244  $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0);
5245  }
5246 
5247  // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
5248  [$parentUid] = BackendUtility::getTSCpid($table, ‪$uid, '');
5249  $this->‪registerRecordIdForPageCacheClearing($table, ‪$uid, $parentUid);
5250  $deleteField = ‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? false;
5251  $databaseErrorMessage = '';
5252  if ($recordWorkspaceId > 0) {
5253  // If this is a workspace record, use discard
5254  $this->BE_USER->workspace = $recordWorkspaceId;
5255  $this->‪discard($table, null, $recordToDelete);
5256  // Switch user back to original workspace
5257  $this->BE_USER->workspace = $currentUserWorkspace;
5258  } elseif ($deleteField && !$forceHardDelete) {
5259  $updateFields = [
5260  $deleteField => 1,
5261  ];
5262  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
5263  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
5264  }
5265  // before deleting this record, check for child records or references
5266  $this->‪deleteRecord_procFields($table, ‪$uid);
5267  try {
5268  // Delete all l10n records as well
5269  $this->deletedRecords[$table][] = ‪$uid;
5270  $this->‪deleteL10nOverlayRecords($table, ‪$uid);
5271  $this->connectionPool->getConnectionForTable($table)
5272  ->update($table, $updateFields, ['uid' => ‪$uid]);
5273  } catch (DBALException $e) {
5274  $databaseErrorMessage = $e->getPrevious()->getMessage();
5275  }
5276  } else {
5277  // Delete the hard way...:
5278  try {
5279  $this->‪hardDeleteSingleRecord($table, ‪$uid);
5280  $this->deletedRecords[$table][] = ‪$uid;
5281  $this->‪deleteL10nOverlayRecords($table, ‪$uid);
5282  } catch (DBALException $e) {
5283  $databaseErrorMessage = $e->getPrevious()->getMessage();
5284  }
5285  }
5286  if ($this->enableLogging) {
5287  $state = SystemLogDatabaseAction::DELETE;
5288  if ($databaseErrorMessage === '') {
5289  if ($forceHardDelete) {
5290  $message = 'Record "{title}" ({table}:{uid}) was deleted unrecoverable from page "{pageTitle}" ({pid})';
5291  } else {
5292  $message = 'Record "{title}" ({table}:{uid}) was deleted from page "{pageTitle}" ({pid})';
5293  }
5294  $propArr = $this->‪getRecordProperties($table, ‪$uid);
5295  $pagePropArr = $this->‪getRecordProperties('pages', $propArr['pid']);
5296 
5297  $this->‪log($table, ‪$uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
5298  'title' => $propArr['header'],
5299  'table' => $table,
5300  'uid' => ‪$uid,
5301  'pageTitle' => $pagePropArr['header'],
5302  'pid' => $propArr['pid'],
5303  ], $propArr['event_pid']);
5304  } else {
5305  $this->‪log($table, ‪$uid, $state, 0, SystemLogErrorClassification::SYSTEM_ERROR, $databaseErrorMessage);
5306  }
5307  }
5308 
5309  // Add history entry
5310  $this->‪getRecordHistoryStore()->deleteRecord($table, ‪$uid, $this->correlationId);
5311 
5312  // Update reference index with table/uid on left side (recuid)
5313  $this->‪updateRefIndex($table, ‪$uid);
5314  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
5315  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, ‪$uid, $currentUserWorkspace);
5316  }
5317 
5327  public function ‪deletePages(int ‪$uid, bool $force = false, bool $forceHardDelete = false, bool $deleteRecordsOnPage = true): void
5328  {
5329  if (‪$uid === 0) {
5330  $this->‪log('pages', $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled', -1, [], 0);
5331  return;
5332  }
5333  // Getting list of pages to delete:
5334  if ($force) {
5335  // Returns the branch WITHOUT permission checks, so it cannot return null
5336  $res = $this->‪doesBranchExist($uid, ‪Permission::NOTHING);
5337  if (is_array($res)) {
5338  $res[] = ‪$uid;
5339  }
5340  } else {
5341  $res = $this->‪canDeletePage($uid);
5342  }
5343  // Perform deletion if no error occurred
5344  if (is_array($res)) {
5345  foreach ($res as $deleteId) {
5346  $this->‪deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
5347  }
5348  } else {
5349  $this->‪log(
5350  'pages',
5351  $uid,
5352  SystemLogDatabaseAction::DELETE,
5353  0,
5354  SystemLogErrorClassification::SYSTEM_ERROR,
5355  $res,
5356  );
5357  }
5358  }
5359 
5369  protected function ‪deleteSpecificPage(int ‪$uid, bool $forceHardDelete, bool $deleteRecordsOnPage): void
5370  {
5371  if (!‪$uid) {
5372  // Early void return on invalid uid
5373  return;
5374  }
5375 
5376  // Delete either a default language page or a translated page
5377  $pageIdInDefaultLanguage = $this->‪getDefaultLanguagePageId($uid);
5378  $isPageTranslation = false;
5379  $pageLanguageId = 0;
5380  if ($pageIdInDefaultLanguage !== ‪$uid) {
5381  // For translated pages, translated records in other tables (eg. tt_content) for the
5382  // to-delete translated page have their pid field set to the uid of the default language record,
5383  // NOT the uid of the translated page record.
5384  // If a translated page is deleted, only translations of records in other tables of this language
5385  // should be deleted. The code checks if the to-delete page is a translated page and
5386  // adapts the query for other tables to use the uid of the default language page as pid together
5387  // with the language id of the translated page.
5388  $isPageTranslation = true;
5389  $pageLanguageId = $this->‪pageInfo($uid, ‪$GLOBALS['TCA']['pages']['ctrl']['languageField']);
5390  }
5391 
5392  if ($deleteRecordsOnPage) {
5393  $tableNames = $this->‪compileAdminTables();
5394  foreach ($tableNames as $table) {
5395  if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
5396  // Skip pages table. And skip table if not translatable, but a translated page is deleted
5397  continue;
5398  }
5399 
5400  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
5401  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5402  $queryBuilder
5403  ->select('uid')
5404  ->from($table)
5405  // order by uid is needed here to process possible live records first - overlays always
5406  // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
5407  // leading to hard to debug issues. This is especially relevant for the
5408  // discardWorkspaceVersionsOfRecord() call below.
5409  ->addOrderBy('uid');
5410 
5411  if ($isPageTranslation) {
5412  // Only delete records in the specified language
5413  $queryBuilder->where(
5414  $queryBuilder->expr()->eq(
5415  'pid',
5416  $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, ‪Connection::PARAM_INT)
5417  ),
5418  $queryBuilder->expr()->eq(
5419  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
5420  $queryBuilder->createNamedParameter($pageLanguageId, ‪Connection::PARAM_INT)
5421  )
5422  );
5423  } else {
5424  // Delete all records on this page
5425  $queryBuilder->where(
5426  $queryBuilder->expr()->eq(
5427  'pid',
5428  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
5429  )
5430  );
5431  }
5432 
5433  $currentUserWorkspace = $this->BE_USER->workspace;
5434  if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5435  // If we are in a workspace, make sure only records of this workspace are deleted.
5436  $queryBuilder->andWhere(
5437  $queryBuilder->expr()->eq(
5438  't3ver_wsid',
5439  $queryBuilder->createNamedParameter($currentUserWorkspace, ‪Connection::PARAM_INT)
5440  )
5441  );
5442  }
5443 
5444  $statement = $queryBuilder->executeQuery();
5445 
5446  while ($row = $statement->fetchAssociative()) {
5447  // Delete any further workspace overlays of the record in question, then delete the record.
5448  $this->‪discardWorkspaceVersionsOfRecord($table, $row['uid']);
5449  $this->‪deleteRecord($table, (int)$row['uid'], true, $forceHardDelete);
5450  }
5451  }
5452  }
5453 
5454  // Delete any further workspace overlays of the record in question, then delete the record.
5455  $this->‪discardWorkspaceVersionsOfRecord('pages', $uid);
5456  $this->‪deleteRecord('pages', $uid, true, $forceHardDelete);
5457  }
5458 
5466  public function ‪canDeletePage(‪$uid)
5467  {
5468  ‪$uid = (int)‪$uid;
5469  $isTranslatedPage = null;
5470 
5471  // If we may at all delete this page
5472  // If this is a page translation, do the check against the perms_* of the default page
5473  // Because it is currently only deleting the translation
5474  $defaultLanguagePageId = $this->‪getDefaultLanguagePageId($uid);
5475  if ($defaultLanguagePageId !== ‪$uid) {
5476  if ($this->‪doesRecordExist('pages', (int)$defaultLanguagePageId, ‪Permission::PAGE_DELETE)) {
5477  $isTranslatedPage = true;
5478  } else {
5479  return 'Attempt to delete page without permissions';
5480  }
5481  } elseif (!$this->‪doesRecordExist('pages', $uid, ‪Permission::PAGE_DELETE)) {
5482  return 'Attempt to delete page without permissions';
5483  }
5484 
5485  $pagesInBranch = $this->‪doesBranchExist($uid, ‪Permission::PAGE_DELETE);
5486  if ($pagesInBranch === null) {
5487  return 'Attempt to delete pages in branch without permissions';
5488  }
5489 
5490  $pagesInBranch[] = ‪$uid;
5491 
5492  if ($disallowedTables = $this->‪checkForRecordsFromDisallowedTables($pagesInBranch)) {
5493  return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
5494  }
5495 
5496  foreach ($pagesInBranch as $pageInBranch) {
5497  if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, !$isTranslatedPage)) {
5498  return 'Attempt to delete page which has prohibited localizations';
5499  }
5500  }
5501  return $pagesInBranch;
5502  }
5503 
5512  public function ‪cannotDeleteRecord($table, $id)
5513  {
5514  if ($table === 'pages') {
5515  $res = $this->‪canDeletePage($id);
5516  return is_array($res) ? false : $res;
5517  }
5518  if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5519  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5520  $perms = ‪Permission::PAGE_EDIT;
5521  } else {
5522  $perms = ‪Permission::CONTENT_EDIT;
5523  }
5524  return $this->‪doesRecordExist($table, $id, $perms) ? false : 'No permission to delete record';
5525  }
5526 
5536  public function ‪deleteRecord_procFields($table, ‪$uid): void
5537  {
5538  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
5539  $row = BackendUtility::getRecord($table, ‪$uid, '*', '', false);
5540  if (empty($row)) {
5541  return;
5542  }
5543  foreach ($row as $field => $value) {
5544  $this->‪deleteRecord_procBasedOnFieldType($table, ‪$uid, $value, $conf[$field]['config'] ?? []);
5545  }
5546  }
5547 
5559  public function ‪deleteRecord_procBasedOnFieldType($table, ‪$uid, $value, $conf): void
5560  {
5561  if (!isset($conf['type'])) {
5562  return;
5563  }
5564 
5565  if ($conf['type'] === 'inline' || $conf['type'] === 'file') {
5566  if (in_array($this->‪getRelationFieldType($conf), ['list', 'field'], true)) {
5567  $dbAnalysis = $this->‪createRelationHandlerInstance();
5568  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
5569  $dbAnalysis->undeleteRecord = true;
5570 
5571  // non type save comparison is intended!
5572  if (!isset($conf['behaviour']['enableCascadingDelete'])
5573  || $conf['behaviour']['enableCascadingDelete'] != false
5574  ) {
5575  // Walk through the items and remove them
5576  foreach ($dbAnalysis->itemArray as $v) {
5577  $this->‪deleteAction($v['table'], $v['id']);
5578  }
5579  }
5580  }
5581  } elseif ($this->‪isReferenceField($conf)) {
5582  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5583  $dbAnalysis = $this->‪createRelationHandlerInstance();
5584  $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', ‪$uid, $table, $conf);
5585  foreach ($dbAnalysis->itemArray as $v) {
5586  $this->‪updateRefIndex($v['table'], $v['id']);
5587  }
5588  }
5589  }
5590 
5598  public function ‪deleteL10nOverlayRecords($table, ‪$uid): void
5599  {
5600  // Check whether table can be localized
5601  if (!BackendUtility::isTableLocalizable($table)) {
5602  return;
5603  }
5604 
5605  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
5606  $queryBuilder->getRestrictions()->removeAll()
5607  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5608  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5609 
5610  $queryBuilder->select('*')
5611  ->from($table)
5612  ->where(
5613  $queryBuilder->expr()->eq(
5614  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5615  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
5616  )
5617  );
5618 
5619  $result = $queryBuilder->executeQuery();
5620  while (‪$record = $result->fetchAssociative()) {
5621  // Ignore workspace delete placeholders. Those records have been marked for
5622  // deletion before - deleting them again in a workspace would revert that state.
5623  if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5624  BackendUtility::workspaceOL($table, ‪$record, $this->BE_USER->workspace);
5625  if (VersionState::tryFrom(‪$record['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) {
5626  continue;
5627  }
5628  }
5629  $this->‪deleteAction($table, (int)(‪$record['t3ver_oid'] ?? 0) > 0 ? (int)‪$record['t3ver_oid'] : (int)‪$record['uid']);
5630  }
5631  }
5632 
5633  /*********************************************
5634  *
5635  * Cmd: undelete / restore
5636  *
5637  ********************************************/
5638 
5649  protected function ‪undeleteRecord(string $table, int ‪$uid): void
5650  {
5651  ‪$record = BackendUtility::getRecord($table, ‪$uid, '*', '', false);
5652  $deleteField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5653  $timestampField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5654 
5655  if (‪$record === null
5656  || $deleteField === ''
5657  || !isset(‪$record[$deleteField])
5658  || (bool)‪$record[$deleteField] === false
5659  || ($timestampField !== '' && !isset(‪$record[$timestampField]))
5660  || (int)$this->BE_USER->workspace > 0
5661  || (BackendUtility::isTableWorkspaceEnabled($table) && (int)(‪$record['t3ver_wsid'] ?? 0) > 0)
5662  ) {
5663  // Return early and silently, if:
5664  // * Record not found
5665  // * Table is not soft-delete aware
5666  // * Record does not have deleted field - db analyzer not up-to-date?
5667  // * Record is not deleted - may eventually happen via recursion with self referencing records?
5668  // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5669  // * User is in a workspace - does not make sense
5670  // * Record is in a workspace - workspace records are not soft-delete aware
5671  return;
5672  }
5673 
5674  $recordPid = (int)(‪$record['pid'] ?? 0);
5675  if ($recordPid > 0) {
5676  // Record is not on root level. Parent page record must exist and must not be deleted itself.
5677  $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5678  if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5679  $this->‪log(
5680  $table,
5681  ‪$uid,
5682  SystemLogDatabaseAction::DELETE,
5683  0,
5684  SystemLogErrorClassification::USER_ERROR,
5685  'Record "{table}:{uid}" can\'t be restored: The page "{pid}" containing it does not exist or is soft-deleted',
5686  0,
5687  [
5688  'table' => $table,
5689  'uid' => ‪$uid,
5690  'pid' => $recordPid,
5691  ],
5692  $recordPid
5693  );
5694  return;
5695  }
5696  }
5697 
5698  // @todo: When restoring a not-default language record, it should be verified the default language
5699  // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5700 
5701  if (!$this->BE_USER->recordEditAccessInternals($table, ‪$record, false, true)) {
5702  // User misses access permissions to record
5703  $this->‪log(
5704  $table,
5705  ‪$uid,
5706  SystemLogDatabaseAction::DELETE,
5707  0,
5708  SystemLogErrorClassification::USER_ERROR,
5709  'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions',
5710  0,
5711  [
5712  'table' => $table,
5713  'uid' => ‪$uid,
5714  ],
5715  $recordPid
5716  );
5717  return;
5718  }
5719 
5720  // Restore referenced child records
5722 
5723  // Restore record
5724  $updateFields[$deleteField] = 0;
5725  if ($timestampField !== '') {
5726  $updateFields[$timestampField] = ‪$GLOBALS['EXEC_TIME'];
5727  }
5728  $this->connectionPool->getConnectionForTable($table)
5729  ->update(
5730  $table,
5731  $updateFields,
5732  ['uid' => ‪$uid]
5733  );
5734 
5735  if ($this->enableLogging) {
5736  $this->‪log(
5737  $table,
5738  ‪$uid,
5739  SystemLogDatabaseAction::INSERT,
5740  0,
5741  SystemLogErrorClassification::MESSAGE,
5742  'Record "{table}:{uid}" was restored on page {pid}',
5743  0,
5744  [
5745  'table' => $table,
5746  'uid' => ‪$uid,
5747  'pid' => $recordPid,
5748  ],
5749  $recordPid
5750  );
5751  }
5752 
5753  // Register cache clearing of page, or parent page if a page is restored.
5754  $this->‪registerRecordIdForPageCacheClearing($table, ‪$uid, $recordPid);
5755  // Add history entry
5756  $this->‪getRecordHistoryStore()->undeleteRecord($table, ‪$uid, $this->correlationId);
5757  // Update reference index with table/uid on left side (recuid)
5758  $this->‪updateRefIndex($table, ‪$uid);
5759  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5760  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, ‪$uid, 0);
5761  }
5762 
5771  protected function ‪undeleteRecordRelations(string $table, int ‪$uid, array ‪$record): void
5772  {
5773  foreach (‪$record as $fieldName => $value) {
5774  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5775  $fieldType = (string)($fieldConfig['type'] ?? '');
5776  if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5777  continue;
5778  }
5779  $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5780  if ($fieldType === 'inline' || $fieldType === 'file') {
5781  // @todo: Inline MM not handled here, and what about group / select?
5782  if (!in_array($this->‪getRelationFieldType($fieldConfig), ['list', 'field'], true)) {
5783  continue;
5784  }
5785  $relationHandler = $this->‪createRelationHandlerInstance();
5786  $relationHandler->start($value, $foreignTable, '', ‪$uid, $table, $fieldConfig);
5787  $relationHandler->undeleteRecord = true;
5788  foreach ($relationHandler->itemArray as $reference) {
5789  $this->‪undeleteRecord($reference['table'], (int)$reference['id']);
5790  }
5791  } elseif ($this->‪isReferenceField($fieldConfig)) {
5792  $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5793  $relationHandler = $this->‪createRelationHandlerInstance();
5794  $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', ‪$uid, $table, $fieldConfig);
5795  foreach ($relationHandler->itemArray as $reference) {
5796  // @todo: Unsure if this is ok / enough. Needs coverage.
5797  $this->‪updateRefIndex($reference['table'], $reference['id']);
5798  }
5799  }
5800  }
5801  }
5802 
5803  /*********************************************
5804  *
5805  * Cmd: Workspace discard & flush
5806  *
5807  ********************************************/
5808 
5822  public function ‪discard(string $table, ?int ‪$uid, array ‪$record = null): void
5823  {
5824  if (‪$uid === null && ‪$record === null) {
5825  throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5826  }
5827 
5828  // Fetch record we are dealing with if not given
5829  if (‪$record === null) {
5830  ‪$record = BackendUtility::getRecord($table, (int)‪$uid);
5831  }
5832  if (!is_array(‪$record)) {
5833  return;
5834  }
5835  ‪$uid = (int)‪$record['uid'];
5836 
5837  // Call hook and return if hook took care of the element
5838  $recordWasDiscarded = false;
5839  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5840  $hookObj = GeneralUtility::makeInstance($className);
5841  if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5843  $hookObj->processCmdmap_discardAction($table, ‪$uid, ‪$record, $recordWasDiscarded);
5844  }
5845  }
5846 
5847  $userWorkspace = (int)$this->BE_USER->workspace;
5848  ‪if ($recordWasDiscarded
5849  || $userWorkspace === 0
5850  || !BackendUtility::isTableWorkspaceEnabled($table)
5851  || $this->‪hasDeletedRecord($table, ‪$uid)
5852  ) {
5853  return;
5854  }
5855 
5856  // Gather versioned record
5857  if ((int)‪$record['t3ver_wsid'] === 0) {
5858  ‪$record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, ‪$uid);
5859  }
5860  if (!is_array(‪$record)) {
5861  return;
5862  }
5863  $versionRecord = ‪$record;
5864 
5865  // User access checks
5866  if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5867  $this->‪log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: Different workspace', -1, ['table' => $table, 'uid' => (int)$versionRecord['uid']]);
5868  return;
5869  }
5870  if ($errorCode = $this->‪workspaceCannotEditOfflineVersion($table, $versionRecord)) {
5871  $this->‪log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: {reason}', -1, ['table' => $table, 'uid' => (int)$versionRecord['uid'], 'reason' => $errorCode]);
5872  return;
5873  }
5874  if (!$this->‪checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5875  $this->‪log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: User has no edit access', -1, ['table' => $table, 'uid' => (int)$versionRecord['uid']]);
5876  return;
5877  }
5878  $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5879  if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5880  $this->‪log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: User has no delete access', -1, ['table' => $table, 'uid' => (int)$versionRecord['uid']]);
5881  return;
5882  }
5883 
5884  // Perform discard operations
5885  $versionState = VersionState::tryFrom($versionRecord['t3ver_state'] ?? 0);
5886  if ($table === 'pages' && $versionState === VersionState::NEW_PLACEHOLDER) {
5887  // When discarding a new page, there can be new sub pages and new records.
5888  // Those need to be discarded, otherwise they'd end up as records without parent page.
5889  $this->‪discardSubPagesAndRecordsOnPage($versionRecord);
5890  }
5891 
5892  $this->‪discardLocalizationOverlayRecords($table, $versionRecord);
5893  $this->‪discardRecordRelations($table, $versionRecord);
5894  $this->‪discardCsvReferencesToRecord($table, $versionRecord);
5895  $this->‪hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5896  $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5897  $this->‪registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5898  $this->‪getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5899  $this->‪log(
5900  $table,
5901  (int)$versionRecord['uid'],
5902  SystemLogDatabaseAction::DELETE,
5903  0,
5904  SystemLogErrorClassification::MESSAGE,
5905  'Record {table}:{uid} was deleted unrecoverable from page {pid}',
5906  0,
5907  ['table' => $table, 'uid' => $versionRecord['uid'], 'pid' => $versionRecord['pid']],
5908  (int)$versionRecord['pid']
5909  );
5910  }
5911 
5918  protected function ‪discardSubPagesAndRecordsOnPage(array $page): void
5919  {
5920  $isLocalizedPage = false;
5921  $sysLanguageId = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['languageField']];
5922  $versionState = VersionState::tryFrom($page['t3ver_state'] ?? 0);
5923  if ($sysLanguageId > 0) {
5924  // New or moved localized page.
5925  // Discard records on this page localization, but no sub pages.
5926  // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
5927  // @todo: Discard other page translations that inherit from this?! (l10n_source field)
5928  $isLocalizedPage = true;
5929  $pid = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
5930  } elseif ($versionState === VersionState::NEW_PLACEHOLDER) {
5931  // New default language page.
5932  // Discard any sub pages and all other records of this page, including any page localizations.
5933  // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
5934  // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
5935  $pid = (int)$page['uid'];
5936  } else {
5937  // Moved default language page.
5938  // Discard any sub pages and all other records of this page, including any page localizations.
5939  $pid = (int)$page['t3ver_oid'];
5940  }
5941  $tables = $this->‪compileAdminTables();
5942  foreach ($tables as $table) {
5943  if (($isLocalizedPage && $table === 'pages')
5944  || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
5945  || !BackendUtility::isTableWorkspaceEnabled($table)
5946  ) {
5947  continue;
5948  }
5949  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
5950  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5951  $queryBuilder->select('*')
5952  ->from($table)
5953  ->where(
5954  $queryBuilder->expr()->eq(
5955  'pid',
5956  $queryBuilder->createNamedParameter($pid, ‪Connection::PARAM_INT)
5957  ),
5958  $queryBuilder->expr()->eq(
5959  't3ver_wsid',
5960  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
5961  )
5962  );
5963  if ($isLocalizedPage) {
5964  // Add sys_language_uid = x restriction if discarding a localized page
5965  $queryBuilder->andWhere(
5966  $queryBuilder->expr()->eq(
5967  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
5968  $queryBuilder->createNamedParameter($sysLanguageId, ‪Connection::PARAM_INT)
5969  )
5970  );
5971  }
5972  $statement = $queryBuilder->executeQuery();
5973  while ($row = $statement->fetchAssociative()) {
5974  $this->‪discard($table, null, $row);
5975  }
5976  }
5977  }
5978 
5985  protected function ‪discardRecordRelations(string $table, array ‪$record): void
5986  {
5987  foreach (‪$record as $field => $value) {
5988  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
5989  if (!isset($fieldConfig['type'])) {
5990  continue;
5991  }
5992  if ($fieldConfig['type'] === 'inline' || $fieldConfig['type'] === 'file') {
5993  $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5994  if ($foreignTable === ''
5995  || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
5996  && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
5997  ) {
5998  continue;
5999  }
6000  if (in_array($this->‪getRelationFieldType($fieldConfig), ['list', 'field'], true)) {
6001  $dbAnalysis = $this->‪createRelationHandlerInstance();
6002  $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)‪$record['uid'], $table, $fieldConfig);
6003  $dbAnalysis->undeleteRecord = true;
6004  foreach ($dbAnalysis->itemArray as $relationRecord) {
6005  $this->‪discard($relationRecord['table'], (int)$relationRecord['id']);
6006  }
6007  }
6008  } elseif ($this->‪isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
6009  $this->‪discardMmRelations($table, $fieldConfig, ‪$record);
6010  }
6011  // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
6012  }
6013  }
6014 
6034  protected function ‪discardCsvReferencesToRecord(string $table, array ‪$record): void
6035  {
6036  // @see test workspaces Group Discard createContentAndCreateElementRelationAndDiscardElement
6037  // Records referencing the to-discard record.
6038  $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
6039  $statement = $queryBuilder->select('tablename', 'recuid', 'field')
6040  ->from('sys_refindex')
6041  ->where(
6042  $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter(‪$record['t3ver_wsid'], ‪Connection::PARAM_INT)),
6043  $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
6044  $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter(‪$record['uid'], ‪Connection::PARAM_INT))
6045  )
6046  ->executeQuery();
6047  while ($row = $statement->fetchAssociative()) {
6048  // For each record referencing the to-discard record, see if it is a CSV group field definition.
6049  // If so, update that record to drop both the possible "uid" and "table_name_uid" variants from the list.
6050  $fieldTca = ‪$GLOBALS['TCA'][$row['tablename']]['columns'][$row['field']]['config'] ?? [];
6051  $groupAllowed = ‪GeneralUtility::trimExplode(',', $fieldTca['allowed'] ?? '', true);
6052  // @todo: "select" may be affected too, but it has no coverage to show this, yet?
6053  if (($fieldTca['type'] ?? '') === 'group'
6054  && empty($fieldTca['MM'])
6055  && (in_array('*', $groupAllowed, true) || in_array($table, $groupAllowed, true))
6056  ) {
6057  // Note it would be possible to a) update multiple records with only one DB call, and b) combine the
6058  // select and update to a single update query by doing the CSV manipulation as string function in sql.
6059  // That's harder to get right though and probably not *that* beneficial performance-wise since we're
6060  // most likely dealing with a very small number of records here anyways. Still, an optimization should
6061  // be considered after we drop TCA 'prepend_tname' handling and always rely only on "table_name_uid"
6062  // variant for CSV storage.
6063 
6064  // Get that record
6065  $recordReferencingDiscardedRecord = BackendUtility::getRecord($row['tablename'], $row['recuid'], $row['field']);
6066  if (!$recordReferencingDiscardedRecord) {
6067  continue;
6068  }
6069  // Drop "uid" and "table_name_uid" from list
6070  $listOfRelatedRecords = ‪GeneralUtility::trimExplode(',', $recordReferencingDiscardedRecord[$row['field']], true);
6071  $listOfRelatedRecordsWithoutDiscardedRecord = array_diff($listOfRelatedRecords, [‪$record['uid'], $table . '_' . ‪$record['uid']]);
6072  if ($listOfRelatedRecords !== $listOfRelatedRecordsWithoutDiscardedRecord) {
6073  // Update record if list changed
6074  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($row['tablename']);
6075  $queryBuilder->update($row['tablename'])
6076  ->set($row['field'], implode(',', $listOfRelatedRecordsWithoutDiscardedRecord))
6077  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($row['recuid'], ‪Connection::PARAM_INT)))
6078  ->executeStatement();
6079  }
6080  }
6081  }
6082  }
6083 
6092  protected function ‪discardMmRelations(string $table, array $fieldConfig, array ‪$record): void
6093  {
6094  $recordUid = (int)‪$record['uid'];
6095  $mmTableName = $fieldConfig['MM'];
6096  // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
6097  $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
6098  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName);
6099  $queryBuilder->delete($mmTableName)->where(
6100  // uid_local = given uid OR uid_foreign = given uid
6101  $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, ‪Connection::PARAM_INT))
6102  );
6103  if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
6104  $queryBuilder->andWhere(
6105  ‪QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, ‪QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $fieldConfig['MM_table_where'])))
6106  );
6107  }
6108  $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
6109  foreach ($mmMatchFields as $fieldName => $fieldValue) {
6110  $queryBuilder->andWhere(
6111  $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue))
6112  );
6113  }
6114  $queryBuilder->executeStatement();
6115 
6116  // refindex treatment for mm relation handling: If the to discard record is foreign side of an mm relation,
6117  // there may be other refindex rows that become obsolete when that record is discarded. See Modify
6118  // addCategoryRelation sys_category-29->tt_content-298. We thus register an update for references
6119  // to this item (right side - ref_table, ref_uid) in reference index updater to catch these.
6120  if ($relationUidFieldName === 'uid_foreign') {
6121  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $recordUid, (int)‪$record['t3ver_wsid']);
6122  }
6123  }
6124 
6131  protected function ‪discardLocalizationOverlayRecords(string $table, array ‪$record): void
6132  {
6133  if (!BackendUtility::isTableLocalizable($table)) {
6134  return;
6135  }
6136  ‪$uid = (int)‪$record['uid'];
6137  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
6138  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6139  $statement = $queryBuilder->select('*')
6140  ->from($table)
6141  ->where(
6142  $queryBuilder->expr()->eq(
6143  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
6144  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
6145  ),
6146  $queryBuilder->expr()->eq(
6147  't3ver_wsid',
6148  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
6149  )
6150  )
6151  ->executeQuery();
6152  while (‪$record = $statement->fetchAssociative()) {
6153  $this->‪discard($table, null, ‪$record);
6154  }
6155  }
6156 
6157  /*********************************************
6158  *
6159  * Cmd: Versioning
6160  *
6161  ********************************************/
6174  public function ‪versionizeRecord($table, $id, $label, $delete = false)
6175  {
6176  $id = (int)$id;
6177  // Stop any actions if the record is marked to be deleted:
6178  // (this can occur if IRRE elements are versionized and child elements are removed)
6179  if ($this->‪isElementToBeDeleted($table, $id)) {
6180  return null;
6181  }
6182  if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
6183  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Versioning is not supported for this table {table}:{uid}', -1, ['table' => $table, 'uid' => (int)$id]);
6184  return null;
6185  }
6186 
6187  // Fetch record with permission check
6188  $row = $this->‪recordInfoWithPermissionCheck($table, $id, ‪Permission::PAGE_SHOW);
6189 
6190  // This checks if the record can be selected which is all that a copy action requires.
6191  if ($row === false) {
6192  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'The record does not exist or you don\'t have correct permissions to make a new version (copy) of this record "{table}:{uid}"', -1, ['table' => $table, 'uid' => (int)$id]);
6193  return null;
6194  }
6195 
6196  // Record must be online record, otherwise we would create a version of a version
6197  if (($row['t3ver_oid'] ?? 0) > 0) {
6198  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Record "{table}:{uid}" you wanted to versionize was already a version in archive (record has an online ID)', -1, ['table' => $table, 'uid' => (int)$id]);
6199  return null;
6200  }
6201 
6202  if ($delete && $errorCode = $this->‪cannotDeleteRecord($table, $id)) {
6203  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Record {table}:{uid} cannot be deleted: {reason}', -1, ['table' => $table, 'uid' => (int)$id, 'reason' => $errorCode]);
6204  return null;
6205  }
6206 
6207  // Set up the values to override when making a raw-copy:
6208  $overrideArray = [
6209  't3ver_oid' => $id,
6210  't3ver_wsid' => $this->BE_USER->workspace,
6211  't3ver_state' => $delete ? VersionState::DELETE_PLACEHOLDER->value : VersionState::DEFAULT_STATE->value,
6212  't3ver_stage' => 0,
6213  ];
6214  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
6215  $overrideArray[‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
6216  }
6217  // Checking if the record already has a version in the current workspace of the backend user
6218  $versionRecord = ['uid' => null];
6219  if ($this->BE_USER->workspace !== 0) {
6220  // Look for version already in workspace:
6221  $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
6222  }
6223  // Create new version of the record and return the new uid
6224  if (empty($versionRecord['uid'])) {
6225  // Create raw-copy and return result:
6226  // The information of the label to be used for the workspace record
6227  // as well as the information whether the record shall be removed
6228  // must be forwarded (creating delete placeholders on a workspace are
6229  // done by copying the record and override several fields).
6230  $workspaceOptions = [
6231  'delete' => $delete,
6232  'label' => $label,
6233  ];
6234  return $this->‪copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
6235  }
6236  // Reuse the existing record and return its uid
6237  // (prior to TYPO3 CMS 6.2, an error was thrown here, which
6238  // did not make much sense since the information is available)
6239  return $versionRecord['uid'];
6240  }
6241 
6255  public function ‪versionPublishManyToManyRelations(string $table, array $liveRecord, array $workspaceRecord, int $fromWorkspace): void
6256  {
6257  if (!is_array(‪$GLOBALS['TCA'][$table]['columns'])) {
6258  return;
6259  }
6260  $toDeleteRegistry = [];
6261  $toUpdateRegistry = [];
6262  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $dbFieldName => $dbFieldConfig) {
6263  if (empty($dbFieldConfig['config']['type'])) {
6264  continue;
6265  }
6266  if (!empty($dbFieldConfig['config']['MM']) && $this->‪isReferenceField($dbFieldConfig['config'])) {
6267  $toDeleteRegistry[] = $dbFieldConfig['config'];
6268  $toUpdateRegistry[] = $dbFieldConfig['config'];
6269  }
6270  if ($dbFieldConfig['config']['type'] === 'flex') {
6271  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6272  // Find possible mm tables attached to live record flex from data structures, mark as to delete
6273  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $liveRecord);
6274  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6275  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6276  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6277  if (is_array($flexFieldDefinition) && $this->‪flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6278  $toDeleteRegistry[] = $flexFieldDefinition['config'];
6279  }
6280  }
6281  }
6282  // Find possible mm tables attached to workspace record flex from data structures, mark as to update uid
6283  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $workspaceRecord);
6284  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6285  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6286  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6287  if (is_array($flexFieldDefinition) && $this->‪flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6288  $toUpdateRegistry[] = $flexFieldDefinition['config'];
6289  }
6290  }
6291  }
6292  }
6293  }
6294 
6295  // Delete mm table relations of live record
6296  foreach ($toDeleteRegistry as $config) {
6297  $uidFieldName = $this->‪mmRelationIsLocalSide($config) ? 'uid_local' : 'uid_foreign';
6298  $mmTableName = $config['MM'];
6299  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName);
6300  $queryBuilder->delete($mmTableName);
6301  $queryBuilder->where($queryBuilder->expr()->eq(
6302  $uidFieldName,
6303  $queryBuilder->createNamedParameter((int)$liveRecord['uid'], ‪Connection::PARAM_INT)
6304  ));
6305  if ($this->‪mmQueryShouldUseTablenamesColumn($config)) {
6306  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6307  'tablenames',
6308  $queryBuilder->createNamedParameter($table)
6309  ));
6310  }
6311  $queryBuilder->executeStatement();
6312  }
6313 
6314  // Update mm table relations of workspace record to uid of live record
6315  foreach ($toUpdateRegistry as $config) {
6316  $mmRelationIsLocalSide = $this->‪mmRelationIsLocalSide($config);
6317  $uidFieldName = $mmRelationIsLocalSide ? 'uid_local' : 'uid_foreign';
6318  $mmTableName = $config['MM'];
6319  $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName);
6320  $queryBuilder->update($mmTableName);
6321  $queryBuilder->set($uidFieldName, (int)$liveRecord['uid'], true, ‪Connection::PARAM_INT);
6322  $queryBuilder->where($queryBuilder->expr()->eq(
6323  $uidFieldName,
6324  $queryBuilder->createNamedParameter((int)$workspaceRecord['uid'], ‪Connection::PARAM_INT)
6325  ));
6326  if ($this->‪mmQueryShouldUseTablenamesColumn($config)) {
6327  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6328  'tablenames',
6329  $queryBuilder->createNamedParameter($table)
6330  ));
6331  }
6332  $queryBuilder->executeStatement();
6333 
6334  if (!$mmRelationIsLocalSide) {
6335  // refindex treatment for mm relation handling: If the to publish record is foreign side of an mm relation, we need
6336  // to instruct refindex updater to update all local side references for the live record the current workspace record
6337  // has on foreign side. See ManyToMany Publish addCategoryRelation, this will create the sys_category-31->tt_content-297 entry.
6338  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$workspaceRecord['uid'], $fromWorkspace, 0);
6339  // Similar, when in mm foreign side and relations are deleted in live during publish, other relations pointing to the
6340  // same local side record may need updates due to different sorting, and the former refindex entry of the live record
6341  // needs updates. See ManyToMany Publish deleteCategoryRelation scenario.
6342  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$liveRecord['uid'], 0);
6343  }
6344  }
6345  }
6346 
6351  private function ‪flexFieldDefinitionIsMmRelation(array $flexFieldDefinition): bool
6352  {
6353  return ($flexFieldDefinition['type'] ?? '') !== 'array' // is a field, not a section
6354  && is_array($flexFieldDefinition['config'] ?? false) // config array exists
6355  && $this->‪isReferenceField($flexFieldDefinition['config']) // select, group, category
6356  && !empty($flexFieldDefinition['config']['MM']); // MM exists
6357  }
6358 
6365  private function ‪mmQueryShouldUseTablenamesColumn(array $config): bool
6366  {
6367  if ($this->‪mmRelationIsLocalSide($config)) {
6368  return false;
6369  }
6370  if ($config['type'] === 'group' && !empty($config['prepend_tname'])) {
6371  // prepend_tname in MM on foreign side forces 'tablenames' column
6372