‪TYPO3CMS  11.5
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\PostgreSQL94Platform as PostgreSqlPlatform;
20 use Doctrine\DBAL\Platforms\SQLServer2012Platform as SQLServerPlatform;
21 use Doctrine\DBAL\Types\IntegerType;
22 use Psr\Log\LoggerAwareInterface;
23 use Psr\Log\LoggerAwareTrait;
24 use TYPO3\CMS\Backend\Utility\BackendUtility;
40 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
60 use ‪TYPO3\CMS\Core\SysLog\Action\Cache as SystemLogCacheAction;
61 use ‪TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
62 use ‪TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
63 use ‪TYPO3\CMS\Core\SysLog\Type as SystemLogType;
71 
85 class ‪DataHandler implements LoggerAwareInterface
86 {
87  use ‪LogDataTrait;
88  use LoggerAwareTrait;
89 
90  // *********************
91  // Public variables you can configure before using the class:
92  // *********************
99  public ‪$storeLogMessages = true;
100 
106  public ‪$enableLogging = true;
107 
114  public ‪$reverseOrder = false;
115 
123  public ‪$checkSimilar = true;
124 
131  public ‪$checkStoredRecords = true;
132 
139 
145  public ‪$neverHideAtCopy = false;
146 
152  public ‪$isImporting = false;
153 
159  public ‪$dontProcessTransformations = false;
160 
168  protected ‪$useTransOrigPointerField = true;
169 
177  public ‪$bypassWorkspaceRestrictions = false;
178 
185  public ‪$bypassAccessCheckForRecords = false;
186 
194  public ‪$copyWhichTables = '*';
195 
203  public ‪$copyTree = 0;
204 
214  public ‪$defaultValues = [];
215 
224  public ‪$overrideValues = [];
225 
234  public ‪$data_disableFields = [];
235 
245  public ‪$suggestedInsertUids = [];
246 
254  public ‪$callBackObj;
255 
262  protected ‪$correlationId;
263 
264  // *********************
265  // Internal variables (mapping arrays) which can be used (read-only) from outside
266  // *********************
273  public $autoVersionIdMap = [];
274 
280  public $substNEWwithIDs = [];
281 
288  public $substNEWwithIDs_table = [];
289 
296  public $newRelatedIDs = [];
297 
304  public $copyMappingArray_merged = [];
305 
311  protected $deletedRecords = [];
312 
319  public $errorLog = [];
320 
326  public $pagetreeRefreshFieldsFromPages = ['pid', 'sorting', 'deleted', 'hidden', 'title', 'doktype', 'is_siteroot', 'fe_group', 'nav_hide', 'nav_title', 'module', 'starttime', 'endtime', 'content_from_pid', 'extendToSubpages'];
327 
334  public $pagetreeNeedsRefresh = false;
335 
336  // *********************
337  // Internal Variables, do not touch.
338  // *********************
339 
340  // Variables set in init() function:
341 
347  public $BE_USER;
348 
355  public $userid;
356 
363  public $username;
364 
371  public $admin;
372 
376  protected $pagePermissionAssembler;
377 
383  protected $excludedTablesAndFields = [];
384 
391  protected $control = [];
392 
398  public $datamap = [];
399 
405  public $cmdmap = [];
406 
412  protected $mmHistoryRecords = [];
413 
419  protected $historyRecords = [];
420 
421  // Internal static:
422 
431  public $sortIntervals = 256;
432 
433  // Internal caching arrays
439  protected $recInsertAccessCache = [];
440 
446  protected $isRecordInWebMount_Cache = [];
447 
453  protected $isInWebMount_Cache = [];
454 
460  protected $pageCache = [];
461 
462  // Other arrays:
469  public $dbAnalysisStore = [];
470 
477  public $registerDBList = [];
478 
485  public $registerDBPids = [];
486 
498  public $copyMappingArray = [];
499 
506  public $remapStack = [];
507 
515  public $remapStackRecords = [];
516 
522  protected $remapStackChildIds = [];
523 
529  protected $remapStackActions = [];
530 
540  protected $referenceIndexUpdater;
541 
542  // Various
543 
550  public $checkValue_currentRecord = [];
551 
557  protected $disableDeleteClause = false;
558 
562  protected $checkModifyAccessListHookObjects;
563 
570  protected $outerMostInstance;
571 
577  protected static $recordsToClearCacheFor = [];
578 
585  protected static $recordPidsForDeletedRecords = [];
586 
592  protected $runtimeCache;
593 
599  protected $cachePrefixNestedElementCalls = 'core-datahandler-nestedElementCalls-';
600 
606  public function __construct(?‪ReferenceIndexUpdater $referenceIndexUpdater = null)
607  {
608  $this->checkStoredRecords = (bool)‪$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecords'];
609  $this->checkStoredRecords_loose = (bool)‪$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecordsLoose'];
610  $this->runtimeCache = $this->‪getRuntimeCache();
611  $this->pagePermissionAssembler = GeneralUtility::makeInstance(PagePermissionAssembler::class, ‪$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']);
612  if ($referenceIndexUpdater === null) {
613  // Create ReferenceIndexUpdater object. This should only happen on outer most instance,
614  // sub instances should receive the reference index updater from a parent.
615  $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
616  }
617  $this->‪referenceIndexUpdater = $referenceIndexUpdater;
618  }
619 
624  public function ‪setControl(array $control)
625  {
626  $this->control = $control;
627  }
628 
638  public function ‪start($data, $cmd, $altUserObject = null)
639  {
640  // Initializing BE_USER
641  $this->BE_USER = is_object($altUserObject) ? $altUserObject : ‪$GLOBALS['BE_USER'];
642  $this->userid = (int)($this->BE_USER->user['uid'] ?? 0);
643  $this->username = $this->BE_USER->user['username'] ?? '';
644  $this->admin = $this->BE_USER->user['admin'] ?? false;
645 
646  // set correlation id for each new set of data or commands
647  $this->correlationId = ‪CorrelationId::forScope(
648  md5(‪StringUtility::getUniqueId(self::class))
649  );
650 
651  // Get default values from user TSconfig
652  $tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
653  if (is_array($tcaDefaultOverride)) {
654  $this->‪setDefaultsFromUserTS($tcaDefaultOverride);
655  }
656 
657  // generates the excludelist, based on TCA/exclude-flag and non_exclude_fields for the user:
658  if (!$this->admin) {
659  $this->excludedTablesAndFields = array_flip($this->‪getExcludeListArray());
660  }
661  // Setting the data and cmd arrays
662  if (is_array($data)) {
663  reset($data);
664  $this->datamap = $data;
665  }
666  if (is_array($cmd)) {
667  reset($cmd);
668  $this->cmdmap = $cmd;
669  }
670  }
671 
679  public function ‪setMirror($mirror)
680  {
681  if (!is_array($mirror)) {
682  return;
683  }
684 
685  foreach ($mirror as $table => $uid_array) {
686  if (!isset($this->datamap[$table])) {
687  continue;
688  }
689 
690  foreach ($uid_array as $id => $uidList) {
691  if (!isset($this->datamap[$table][$id])) {
692  continue;
693  }
694 
695  $theIdsInArray = ‪GeneralUtility::trimExplode(',', $uidList, true);
696  foreach ($theIdsInArray as $copyToUid) {
697  $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id];
698  }
699  }
700  }
701  }
702 
709  public function ‪setDefaultsFromUserTS($userTS)
710  {
711  if (!is_array($userTS)) {
712  return;
713  }
714 
715  foreach ($userTS as $k => $v) {
716  $k = mb_substr($k, 0, -1);
717  if (!$k || !is_array($v) || !isset(‪$GLOBALS['TCA'][$k])) {
718  continue;
719  }
720 
721  if (is_array($this->defaultValues[$k] ?? false)) {
722  $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v);
723  } else {
724  $this->defaultValues[$k] = $v;
725  }
726  }
727  }
728 
741  protected function ‪applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray): array
742  {
743  // First set TCAdefaults respecting the given PageID
744  $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null;
745  // Re-apply $this->defaultValues settings
746  $this->‪setDefaultsFromUserTS($tcaDefaults);
747  $cleanFieldArray = $this->‪newFieldArray($table);
748  if (isset($prepopulatedFieldArray['pid'])) {
749  $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid'];
750  }
751  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? null;
752  if ($sortColumn !== null && isset($prepopulatedFieldArray[$sortColumn])) {
753  $cleanFieldArray[$sortColumn] = $prepopulatedFieldArray[$sortColumn];
754  }
755  return $cleanFieldArray;
756  }
757 
758  /*********************************************
759  *
760  * HOOKS
761  *
762  *********************************************/
777  public function ‪hook_processDatamap_afterDatabaseOperations(&$hookObjectsArr, &$status, &$table, &$id, &$fieldArray)
778  {
779  // Process hook directly:
780  if (!isset($this->remapStackRecords[$table][$id])) {
781  foreach ($hookObjectsArr as $hookObj) {
782  if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) {
783  $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);
784  }
785  }
786  } else {
787  $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [
788  'status' => $status,
789  'fieldArray' => $fieldArray,
790  'hookObjectsArr' => $hookObjectsArr,
791  ];
792  }
793  }
794 
802  protected function ‪getCheckModifyAccessListHookObjects()
803  {
804  if (!isset($this->checkModifyAccessListHookObjects)) {
805  $this->checkModifyAccessListHookObjects = [];
806  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) {
807  $hookObject = GeneralUtility::makeInstance($className);
808  if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) {
809  throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472);
810  }
811  $this->checkModifyAccessListHookObjects[] = $hookObject;
812  }
813  }
814  return $this->checkModifyAccessListHookObjects;
815  }
816 
817  /*********************************************
818  *
819  * PROCESSING DATA
820  *
821  *********************************************/
828  public function ‪process_datamap()
829  {
830  $this->‪controlActiveElements();
831 
832  // Keep versionized(!) relations here locally:
833  $registerDBList = [];
835  $this->datamap = $this->‪unsetElementsToBeDeleted($this->datamap);
836  // Editing frozen:
837  if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
838  $this->‪log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen!');
839  return false;
840  }
841  // First prepare user defined objects (if any) for hooks which extend this function:
842  $hookObjectsArr = [];
843  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) {
844  $hookObject = GeneralUtility::makeInstance($className);
845  if (method_exists($hookObject, 'processDatamap_beforeStart')) {
846  $hookObject->processDatamap_beforeStart($this);
847  }
848  $hookObjectsArr[] = $hookObject;
849  }
850  // Pre-process data-map and synchronize localization states
851  $this->datamap = GeneralUtility::makeInstance(SlugEnricher::class)->enrichDataMap($this->datamap);
852  $this->datamap = DataMapProcessor::instance($this->datamap, $this->BE_USER, $this->‪referenceIndexUpdater)->process();
853  // 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.
854  $orderOfTables = [];
855  // Set pages first.
856  if (isset($this->datamap['pages'])) {
857  $orderOfTables[] = 'pages';
858  }
859  $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap)));
860  // Process the tables...
861  foreach ($orderOfTables as $table) {
862  // Check if
863  // - table is set in $GLOBALS['TCA'],
864  // - table is NOT readOnly
865  // - the table is set with content in the data-array (if not, there's nothing to process...)
866  // - permissions for tableaccess OK
867  $modifyAccessList = $this->‪checkModifyAccessList($table);
868  if (!$modifyAccessList) {
869  $this->‪log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
870  }
871  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->‪tableReadOnly($table) || !is_array($this->datamap[$table]) || !$modifyAccessList) {
872  continue;
873  }
874 
875  if ($this->reverseOrder) {
876  $this->datamap[$table] = array_reverse($this->datamap[$table], true);
877  }
878  // For each record from the table, do:
879  // $id is the record uid, may be a string if new records...
880  // $incomingFieldArray is the array of fields
881  foreach ($this->datamap[$table] as $id => $incomingFieldArray) {
882  if (!is_array($incomingFieldArray)) {
883  continue;
884  }
885  $theRealPid = null;
886 
887  // Hook: processDatamap_preProcessFieldArray
888  foreach ($hookObjectsArr as $hookObj) {
889  if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) {
890  $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this);
891  // in case hook invalidated `$incomingFieldArray`, skip the record completely
892  if (!is_array($incomingFieldArray)) {
893  continue 2;
894  }
895  }
896  }
897  // ******************************
898  // Checking access to the record
899  // ******************************
900  $createNewVersion = false;
901  $old_pid_value = '';
902  // Is it a new record? (Then Id is a string)
904  // Get a fieldArray with tca default values
905  $fieldArray = $this->‪newFieldArray($table);
906  // A pid must be set for new records.
907  if (isset($incomingFieldArray['pid'])) {
908  $pid_value = $incomingFieldArray['pid'];
909  // Checking and finding numerical pid, it may be a string-reference to another value
910  $canProceed = true;
911  // If a NEW... id
912  if (str_contains($pid_value, 'NEW')) {
913  if ($pid_value[0] === '-') {
914  $negFlag = -1;
915  $pid_value = substr($pid_value, 1);
916  } else {
917  $negFlag = 1;
918  }
919  // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record.
920  if (isset($this->substNEWwithIDs[$pid_value])) {
921  if ($negFlag === 1) {
922  $old_pid_value = $this->substNEWwithIDs[$pid_value];
923  }
924  $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]);
925  } else {
926  $canProceed = false;
927  }
928  }
929  $pid_value = (int)$pid_value;
930  if ($canProceed) {
931  $fieldArray = $this->‪resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray);
932  }
933  }
934  $theRealPid = $fieldArray['pid'];
935  // Checks if records can be inserted on this $pid.
936  // If this is a page translation, the check needs to be done for the l10n_parent record
937  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
938  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
939  if ($table === 'pages'
940  && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0
941  && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0
942  ) {
943  $recordAccess = $this->‪checkRecordInsertAccess($table, $incomingFieldArray[$transOrigPointerField]);
944  } else {
945  $recordAccess = $this->‪checkRecordInsertAccess($table, $theRealPid);
946  }
947  if ($recordAccess) {
948  $this->‪addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray, $theRealPid);
949  $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
950  if (!$recordAccess) {
951  $this->‪log($table, 0, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed. [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
952  } elseif (!$this->bypassWorkspaceRestrictions && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
953  // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
954  // So, if no live records were allowed in the current workspace, we have to create a new version of this record
955  if (BackendUtility::isTableWorkspaceEnabled($table)) {
956  $createNewVersion = true;
957  } else {
958  $recordAccess = false;
959  $this->‪log($table, 0, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Record could not be created in this workspace');
960  }
961  }
962  }
963  // Yes new record, change $record_status to 'insert'
964  $status = 'new';
965  } else {
966  // Nope... $id is a number
967  $id = (int)$id;
968  $fieldArray = [];
969  $recordAccess = $this->‪checkRecordUpdateAccess($table, $id, $incomingFieldArray, $hookObjectsArr);
970  if (!$recordAccess) {
971  if ($this->enableLogging) {
972  $propArr = $this->‪getRecordProperties($table, $id);
973  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record \'%s\' (%s) without permission. Or non-existing page.', 2, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']);
974  }
975  continue;
976  }
977  // Next check of the record permissions (internals)
978  $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $id);
979  if (!$recordAccess) {
980  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed. [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
981  } else {
982  // Here we fetch the PID of the record that we point to...
983  $tempdata = $this->‪recordInfo($table, $id, 'pid' . (BackendUtility::isTableWorkspaceEnabled($table) ? ',t3ver_oid,t3ver_wsid,t3ver_stage' : ''));
984  $theRealPid = $tempdata['pid'] ?? null;
985  // Use the new id of the versionized record we're trying to write to:
986  // (This record is a child record of a parent and has already been versionized.)
987  if (!empty($this->autoVersionIdMap[$table][$id])) {
988  // For the reason that creating a new version of this record, automatically
989  // created related child records (e.g. "IRRE"), update the accordant field:
990  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
991  // Use the new id of the copied/versionized record:
992  $id = $this->autoVersionIdMap[$table][$id];
993  $recordAccess = true;
994  } elseif (!$this->bypassWorkspaceRestrictions && $tempdata && ($errorCode = $this->‪workspaceCannotEditRecord($table, $tempdata))) {
995  $recordAccess = false;
996  // Versioning is required and it must be offline version!
997  // Check if there already is a workspace version
998  $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
999  if ($workspaceVersion) {
1000  $id = $workspaceVersion['uid'];
1001  $recordAccess = true;
1002  } elseif ($this->‪workspaceAllowAutoCreation($table, $id, $theRealPid)) {
1003  // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1004  $this->pagetreeNeedsRefresh = true;
1005 
1006  $tce = GeneralUtility::makeInstance(self::class, $this->‪referenceIndexUpdater);
1007  $tce->enableLogging = ‪$this->enableLogging;
1008  // Setting up command for creating a new version of the record:
1009  $cmd = [];
1010  $cmd[$table][$id]['version'] = [
1011  'action' => 'new',
1012  // Default is to create a version of the individual records
1013  'label' => 'Auto-created for WS #' . $this->BE_USER->workspace,
1014  ];
1015  $tce->start([], $cmd, $this->BE_USER);
1016  $tce->process_cmdmap();
1017  $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
1018  // If copying was successful, share the new uids (also of related children):
1019  if (!empty($tce->copyMappingArray[$table][$id])) {
1020  foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
1021  foreach ($origIdArray as $origId => $newId) {
1022  $this->autoVersionIdMap[$origTable][$origId] = $newId;
1023  }
1024  }
1025  // Update registerDBList, that holds the copied relations to child records:
1026  $registerDBList = array_merge($registerDBList, $tce->registerDBList);
1027  // For the reason that creating a new version of this record, automatically
1028  // created related child records (e.g. "IRRE"), update the accordant field:
1029  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
1030  // Use the new id of the copied/versionized record:
1031  $id = $this->autoVersionIdMap[$table][$id];
1032  $recordAccess = true;
1033  } else {
1034  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Could not be edited in offline workspace in the branch where found (failure state: \'{reason}\'). Auto-creation of version failed!', -1, ['reason' => $errorCode]);
1035  }
1036  } else {
1037  $this->‪log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Could not be edited in offline workspace in the branch where found (failure state: \'{reason}\'). Auto-creation of version not allowed in workspace!', -1, ['reason' => $errorCode]);
1038  }
1039  }
1040  }
1041  // The default is 'update'
1042  $status = 'update';
1043  }
1044  // If access was granted above, proceed to create or update record:
1045  if (!$recordAccess) {
1046  continue;
1047  }
1048 
1049  // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
1050  [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
1051  if ($status === 'new') {
1052  // Apply TCAdefaults from pageTS
1053  $fieldArray = $this->‪applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
1054  // Apply page permissions as well
1055  if ($table === 'pages') {
1056  $fieldArray = $this->pagePermissionAssembler->applyDefaults(
1057  $fieldArray,
1058  (int)$tscPID,
1059  (int)$this->userid,
1060  (int)$this->BE_USER->firstMainGroup
1061  );
1062  }
1063  // Ensure that the default values, that are stored in the $fieldArray (built from internal default values)
1064  // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and
1065  // all default values are also checked for validity
1066  // This allows to set TCAdefaults (for example) without having to use FormEngine to have the fields available first.
1067  $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray);
1068  }
1069  // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1070  $fieldArray = $this->‪fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1071  // NOTICE! All manipulation beyond this point bypasses both "excludeFields" AND possible "MM" relations to field!
1072  // Forcing some values unto field array:
1073  // NOTICE: This overriding is potentially dangerous; permissions per field is not checked!!!
1074  $fieldArray = $this->‪overrideFieldArray($table, $fieldArray);
1075  // Setting system fields
1076  if ($status === 'new') {
1077  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
1078  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
1079  }
1080  if (‪$GLOBALS['TCA'][$table]['ctrl']['cruser_id'] ?? false) {
1081  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
1082  }
1083  }
1084  // Set stage to "Editing" to make sure we restart the workflow
1085  if (BackendUtility::isTableWorkspaceEnabled($table)) {
1086  $fieldArray['t3ver_stage'] = 0;
1087  }
1088  if ($status !== 'new' && $this->checkSimilar) {
1089  // Removing fields which are equal to the current value:
1090  $fieldArray = $this->‪compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1091  }
1092  if ((‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) && !empty($fieldArray)) {
1093  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
1094  }
1095  // Hook: processDatamap_postProcessFieldArray
1096  foreach ($hookObjectsArr as $hookObj) {
1097  if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1098  $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1099  }
1100  }
1101  // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1102  // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1103  if (is_array($fieldArray)) {
1104  if ($status === 'new') {
1105  if ($table === 'pages') {
1106  // for new pages always a refresh is needed
1107  $this->pagetreeNeedsRefresh = true;
1108  }
1109 
1110  // This creates a version of the record, instead of adding it to the live workspace
1111  if ($createNewVersion) {
1112  // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1113  $this->pagetreeNeedsRefresh = true;
1114  $fieldArray['pid'] = $theRealPid;
1115  $fieldArray['t3ver_oid'] = 0;
1116  // Setting state for version (so it can know it is currently a new version...)
1117  $fieldArray['t3ver_state'] = (string)new ‪VersionState(‪VersionState::NEW_PLACEHOLDER);
1118  $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace;
1119  $this->‪insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1120  // Hold auto-versionized ids of placeholders
1121  $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1122  } else {
1123  $this->‪insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1124  }
1125  } else {
1126  if ($table === 'pages') {
1127  // only a certain number of fields needs to be checked for updates
1128  // if $this->checkSimilar is TRUE, fields with unchanged values are already removed here
1129  $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1130  if (!empty($fieldsToCheck)) {
1131  $this->pagetreeNeedsRefresh = true;
1132  }
1133  }
1134  $this->‪updateDB($table, $id, $fieldArray);
1135  }
1136  }
1137  // Hook: processDatamap_afterDatabaseOperations
1138  // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1139  // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1140  $this->‪hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1141  }
1142  }
1143  // Process the stack of relations to remap/correct
1144  $this->‪processRemapStack();
1145  $this->‪dbAnalysisStoreExec();
1146  // Hook: processDatamap_afterAllOperations
1147  // Note: When this hook gets called, all operations on the submitted data have been finished.
1148  foreach ($hookObjectsArr as $hookObj) {
1149  if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1150  $hookObj->processDatamap_afterAllOperations($this);
1151  }
1152  }
1153 
1154  if ($this->‪isOuterMostInstance()) {
1155  $this->‪referenceIndexUpdater->update();
1156  $this->‪processClearCacheQueue();
1157  $this->‪resetElementsToBeDeleted();
1158  }
1159  }
1160 
1167  protected function ‪normalizeTimeFormat(string $table, string $value, string $dbType): string
1168  {
1169  $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1170  $platform = $connection->getDatabasePlatform();
1171  if ($platform instanceof SQLServerPlatform) {
1172  $defaultLength = ‪QueryHelper::getDateTimeFormats()[$dbType]['empty'];
1173  $value = substr(
1174  $value,
1175  0,
1176  strlen($defaultLength)
1177  );
1178  }
1179  return $value;
1180  }
1181 
1193  protected function ‪resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1194  {
1195  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1196  // Points to a page on which to insert the element, possibly in the top of the page
1197  if ($pid >= 0) {
1198  // Ensure that the "pid" is not a translated page ID, but the default page ID
1199  $pid = $this->‪getDefaultLanguagePageId($pid);
1200  // The numerical pid is inserted in the data array
1201  $fieldArray['pid'] = $pid;
1202  // If this table is sorted we better find the top sorting number
1203  if ($sortColumn) {
1204  $fieldArray[$sortColumn] = $this->‪getSortNumber($table, 0, $pid);
1205  }
1206  } elseif ($sortColumn) {
1207  // Points to another record before itself
1208  // If this table is sorted we better find the top sorting number
1209  // Because $pid is < 0, getSortNumber() returns an array
1210  $sortingInfo = $this->‪getSortNumber($table, 0, $pid);
1211  $fieldArray['pid'] = $sortingInfo['pid'];
1212  $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1213  } else {
1214  // Here we fetch the PID of the record that we point to
1215  $record = $this->‪recordInfo($table, abs($pid), 'pid');
1216  // Ensure that the "pid" is not a translated page ID, but the default page ID
1217  $fieldArray['pid'] = $this->‪getDefaultLanguagePageId($record['pid']);
1218  }
1219  return $fieldArray;
1220  }
1221 
1236  public function ‪fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $realPid, $status, $tscPID)
1237  {
1238  // Initialize:
1239  $originalLanguageRecord = null;
1240  $originalLanguage_diffStorage = null;
1241  $diffStorageFlag = false;
1242  $isNewRecord = str_contains((string)$id, 'NEW');
1243  // Setting 'currentRecord' and 'checkValueRecord':
1244  if ($isNewRecord) {
1245  // Must have the 'current' array - not the values after processing below...
1246  $checkValueRecord = $fieldArray;
1247  // IF $incomingFieldArray is an array, overlay it.
1248  // 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...
1249  if (is_array($incomingFieldArray) && is_array($checkValueRecord)) {
1250  ‪ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
1251  }
1252  $currentRecord = $checkValueRecord;
1253  } else {
1254  $id = (int)$id;
1255  // We must use the current values as basis for this!
1256  $currentRecord = ($checkValueRecord = $this->‪recordInfo($table, $id, '*'));
1257  }
1258 
1259  // Get original language record if available:
1260  if (is_array($currentRecord)
1261  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false)
1262  && !empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField'])
1263  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] ?? 0) > 0
1264  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? false)
1265  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] ?? 0) > 0
1266  ) {
1267  $originalLanguageRecord = $this->‪recordInfo($table, $currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']], '*');
1268  BackendUtility::workspaceOL($table, $originalLanguageRecord);
1269  $originalLanguage_diffStorage = json_decode(
1270  (string)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] ?? ''),
1271  true
1272  );
1273  }
1274 
1275  $this->checkValue_currentRecord = $checkValueRecord;
1276  // In the following all incoming value-fields are tested:
1277  // - Are the user allowed to change the field?
1278  // - Is the field uid/pid (which are already set)
1279  // - perms-fields for pages-table, then do special things...
1280  // - If the field is nothing of the above and the field is configured in TCA, the fieldvalues are evaluated by ->checkValue
1281  // If everything is OK, the field is entered into $fieldArray[]
1282  foreach ($incomingFieldArray as $field => $fieldValue) {
1283  if (isset($this->excludedTablesAndFields[$table . '-' . $field]) || (bool)($this->data_disableFields[$table][$id][$field] ?? false)) {
1284  continue;
1285  }
1286 
1287  // The field must be editable.
1288  // Checking if a value for language can be changed:
1289  if ((‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false)
1290  && (string)‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] === (string)$field
1291  && !$this->BE_USER->checkLanguageAccess($fieldValue)
1292  ) {
1293  continue;
1294  }
1295 
1296  switch ($field) {
1297  case 'uid':
1298  case 'pid':
1299  // Nothing happens, already set
1300  break;
1301  case 'perms_userid':
1302  case 'perms_groupid':
1303  case 'perms_user':
1304  case 'perms_group':
1305  case 'perms_everybody':
1306  // Permissions can be edited by the owner or the administrator
1307  if ($table === 'pages' && ($this->admin || $status === 'new' || $this->‪pageInfo((int)$id, 'perms_userid') == $this->userid)) {
1308  $value = (int)$fieldValue;
1309  switch ($field) {
1310  case 'perms_userid':
1311  case 'perms_groupid':
1312  $fieldArray[$field] = $value;
1313  break;
1314  default:
1315  if ($value >= 0 && $value < (2 ** 5)) {
1316  $fieldArray[$field] = $value;
1317  }
1318  }
1319  }
1320  break;
1321  case 't3ver_oid':
1322  case 't3ver_wsid':
1323  case 't3ver_state':
1324  case 't3ver_stage':
1325  break;
1326  case 'l10n_state':
1327  $fieldArray[$field] = $fieldValue;
1328  break;
1329  default:
1330  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
1331  // Evaluating the value
1332  $res = $this->‪checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray);
1333  if (array_key_exists('value', $res)) {
1334  $fieldArray[$field] = $res['value'];
1335  }
1336  // Add the value of the original record to the diff-storage content:
1337  if (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false) {
1338  $originalLanguage_diffStorage[$field] = (string)($originalLanguageRecord[$field] ?? '');
1339  $diffStorageFlag = true;
1340  }
1341  } elseif (isset(‪$GLOBALS['TCA'][$table]['ctrl']['origUid']) && ‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] === $field) {
1342  // Allow value for original UID to pass by...
1343  $fieldArray[$field] = $fieldValue;
1344  }
1345  }
1346  }
1347 
1348  // Dealing with a page translation, setting "sorting", "pid", "perms_*" to the same values as the original record
1349  if ($table === 'pages' && is_array($originalLanguageRecord)) {
1350  $fieldArray['sorting'] = $originalLanguageRecord['sorting'];
1351  $fieldArray['perms_userid'] = $originalLanguageRecord['perms_userid'];
1352  $fieldArray['perms_groupid'] = $originalLanguageRecord['perms_groupid'];
1353  $fieldArray['perms_user'] = $originalLanguageRecord['perms_user'];
1354  $fieldArray['perms_group'] = $originalLanguageRecord['perms_group'];
1355  $fieldArray['perms_everybody'] = $originalLanguageRecord['perms_everybody'];
1356  }
1357 
1358  // Add diff-storage information
1359  if ($diffStorageFlag
1360  && (
1361  !array_key_exists(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
1362  || ($isNewRecord && $originalLanguageRecord !== null)
1363  )
1364  ) {
1365  // If the field is set it would probably be because of an undo-operation - in which case we should not
1366  // update the field of course. On the other hand, e.g. for record localization, we need to update the field.
1367  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = json_encode($originalLanguage_diffStorage);
1368  }
1369  return $fieldArray;
1370  }
1371 
1372  /*********************************************
1373  *
1374  * Evaluation of input values
1375  *
1376  ********************************************/
1393  public function ‪checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = [])
1394  {
1395  $curValueRec = null;
1396  // Result array
1397  $res = [];
1398 
1399  // Processing special case of field pages.doktype
1400  if ($table === 'pages' && $field === 'doktype') {
1401  // If the user may not use this specific doktype, we issue a warning
1402  if (!($this->admin || GeneralUtility::inList($this->BE_USER->groupData['pagetypes_select'], $value))) {
1403  if ($this->enableLogging) {
1404  $propArr = $this->‪getRecordProperties($table, $id);
1405  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot change the \'doktype\' of page \'%s\' to the desired value.', 1, [$propArr['header']], $propArr['event_pid']);
1406  }
1407  return $res;
1408  }
1409  if ($status === 'update') {
1410  // This checks 1) if we should check for disallowed tables and 2) if there are records from disallowed tables on the current page
1411  $onlyAllowedTables = ‪$GLOBALS['PAGES_TYPES'][$value]['onlyAllowedTables'] ?? ‪$GLOBALS['PAGES_TYPES']['default']['onlyAllowedTables'];
1412  if ($onlyAllowedTables) {
1413  // use the real page id (default language)
1414  $recordId = $this->‪getDefaultLanguagePageId((int)$id);
1415  $theWrongTables = $this->‪doesPageHaveUnallowedTables($recordId, (int)$value);
1416  if ($theWrongTables) {
1417  if ($this->enableLogging) {
1418  $propArr = $this->‪getRecordProperties($table, $id);
1419  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, '\'doktype\' of page \'%s\' could not be changed because the page contains records from disallowed tables; %s', 2, [$propArr['header'], $theWrongTables], $propArr['event_pid']);
1420  }
1421  return $res;
1422  }
1423  }
1424  }
1425  }
1426 
1427  $curValue = null;
1428  if ((int)$id !== 0) {
1429  // Get current value:
1430  $curValueRec = $this->‪recordInfo($table, (int)$id, $field);
1431  // isset() won't work here, since values can be NULL
1432  if ($curValueRec !== null && array_key_exists($field, $curValueRec)) {
1433  $curValue = $curValueRec[$field];
1434  }
1435  }
1436 
1437  if ($table === 'be_users'
1438  && ($field === 'admin' || $field === 'password')
1439  && $status === 'update'
1440  ) {
1441  // Do not allow a non system maintainer admin to change admin flag and password of system maintainers
1442  $systemMaintainers = array_map('intval', ‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
1443  // False if current user is not in system maintainer list or if switch to user mode is active
1444  $isCurrentUserSystemMaintainer = $this->BE_USER->isSystemMaintainer();
1445  $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true);
1446  if ($field === 'admin') {
1447  $isFieldChanged = (int)$curValueRec[$field] !== (int)$value;
1448  } else {
1449  $isFieldChanged = $curValueRec[$field] !== $value;
1450  }
1451  if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList && $isFieldChanged) {
1452  $value = $curValueRec[$field];
1453  $this->‪log(
1454  $table,
1455  (int)$id,
1456  SystemLogDatabaseAction::UPDATE,
1457  0,
1458  SystemLogErrorClassification::SECURITY_NOTICE,
1459  'Only system maintainers can change the admin flag and password of other system maintainers. The value has not been updated.'
1460  );
1461  }
1462  }
1463 
1464  // Getting config for the field
1465  $tcaFieldConf = $this->‪resolveFieldConfigurationAndRespectColumnsOverrides($table, $field);
1466 
1467  // Create $recFID only for those types that need it
1468  if ($tcaFieldConf['type'] === 'flex') {
1469  $recFID = $table . ':' . $id . ':' . $field;
1470  } else {
1471  $recFID = '';
1472  }
1473 
1474  // Perform processing:
1475  $res = $this->‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
1476  return $res;
1477  }
1478 
1489  protected function ‪resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field): array
1490  {
1491  $tcaFieldConf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
1492  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1493  $columnsOverridesConfigOfField = ‪$GLOBALS['TCA'][$table]['types'][$recordType]['columnsOverrides'][$field]['config'] ?? null;
1494  if ($columnsOverridesConfigOfField) {
1495  ‪ArrayUtility::mergeRecursiveWithOverrule($tcaFieldConf, $columnsOverridesConfigOfField);
1496  }
1497  return $tcaFieldConf;
1498  }
1499 
1520  public function ‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, ?array $additionalData = null)
1521  {
1522  // Convert to NULL value if defined in TCA
1523  if ($value === null && !empty($tcaFieldConf['eval']) && GeneralUtility::inList($tcaFieldConf['eval'], 'null')) {
1524  $res = ['value' => null];
1525  return $res;
1526  }
1527 
1528  switch ($tcaFieldConf['type']) {
1529  case 'text':
1530  $res = $this->‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field);
1531  break;
1532  case 'passthrough':
1533  case 'imageManipulation':
1534  case 'user':
1535  $res['value'] = $value;
1536  break;
1537  case 'input':
1538  $res = $this->‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field);
1539  break;
1540  case 'slug':
1541  $res = $this->‪checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []);
1542  break;
1543  case 'language':
1544  $res = $this->‪checkValueForLanguage((int)$value, $table, $field);
1545  break;
1546  case 'category':
1547  $res = $this->‪checkValueForCategory($res, (string)$value, $tcaFieldConf, (string)$table, $id, (string)$status, (string)$field);
1548  break;
1549  case 'check':
1550  $res = $this->‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1551  break;
1552  case 'radio':
1553  $res = $this->‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1554  break;
1555  case 'group':
1556  case 'select':
1557  $res = $this->‪checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field);
1558  break;
1559  case 'inline':
1560  $res = $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [];
1561  break;
1562  case 'flex':
1563  // FlexForms are only allowed for real fields.
1564  if ($field) {
1565  $res = $this->‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field);
1566  }
1567  break;
1568  default:
1569  // Do nothing
1570  }
1571  $res = $this->‪checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field);
1572  return $res;
1573  }
1593  protected function ‪checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field)
1594  {
1595  $relevantFieldNames = [
1596  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null,
1597  ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null,
1598  ];
1599 
1600  if (
1601  // in case field is empty
1602  empty($field)
1603  // in case the field is not relevant
1604  || !in_array($field, $relevantFieldNames)
1605  // in case the 'value' index has been unset already
1606  || !array_key_exists('value', $res)
1607  // in case it's not a NEW-identifier
1608  || !str_contains($value, 'NEW')
1609  ) {
1610  return $res;
1611  }
1612 
1613  $valueArray = [$value];
1614  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1615  $this->‪addNewValuesToRemapStackChildIds($valueArray);
1616  $this->remapStack[] = [
1617  'args' => [$valueArray, $tcaFieldConf, $id, $table, $field],
1618  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
1619  'field' => $field,
1620  ];
1621  unset($res['value']);
1622 
1623  return $res;
1624  }
1625 
1636  protected function ‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field)
1637  {
1638  if (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
1639  $cacheId = $this->‪getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1640  $evalCodesArray = $this->runtimeCache->get($cacheId);
1641  if (!is_array($evalCodesArray)) {
1642  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1643  $this->runtimeCache->set($cacheId, $evalCodesArray);
1644  }
1645  $valueArray = $this->‪checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '');
1646  } else {
1647  $valueArray = ['value' => $value];
1648  }
1649 
1650  // Handle richtext transformations
1651  if ($this->dontProcessTransformations) {
1652  return $valueArray;
1653  }
1654  // Keep null as value
1655  if ($value === null) {
1656  return $valueArray;
1657  }
1658  if (isset($tcaFieldConf['enableRichtext']) && (bool)$tcaFieldConf['enableRichtext'] === true) {
1659  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1660  $richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
1661  $richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
1662  $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class);
1663  $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []);
1664  }
1665 
1666  return $valueArray;
1667  }
1668 
1680  protected function ‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field)
1681  {
1682  // Handle native date/time fields
1683  $isDateOrDateTimeField = false;
1684  $format = '';
1685  $emptyValue = '';
1686  $dateTimeTypes = ‪QueryHelper::getDateTimeTypes();
1687  // normal integer "date" fields (timestamps) are handled in checkValue_input_Eval
1688  if (isset($tcaFieldConf['dbType']) && in_array($tcaFieldConf['dbType'], $dateTimeTypes, true)) {
1689  if (empty($value)) {
1690  $value = null;
1691  } else {
1692  $isDateOrDateTimeField = true;
1693  $dateTimeFormats = ‪QueryHelper::getDateTimeFormats();
1694  $format = $dateTimeFormats[$tcaFieldConf['dbType']]['format'];
1695 
1696  // Convert the date/time into a timestamp for the sake of the checks
1697  $emptyValue = $dateTimeFormats[$tcaFieldConf['dbType']]['empty'];
1698  // We expect the ISO 8601 $value to contain a UTC timezone specifier.
1699  // We explicitly fallback to UTC if no timezone specifier is given (e.g. for copy operations).
1700  $dateTime = new \DateTime($value, new \DateTimeZone('UTC'));
1701  // The timestamp (UTC) returned by getTimestamp() will be converted to
1702  // a local time string by gmdate() later.
1703  $value = $value === $emptyValue ? null : $dateTime->getTimestamp();
1704  }
1705  }
1706  // Secures the string-length to be less than max.
1707  if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) {
1708  $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
1709  }
1710 
1711  if (empty($tcaFieldConf['eval'])) {
1712  $res = ['value' => $value];
1713  } else {
1714  // Process evaluation settings:
1715  $cacheId = $this->‪getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1716  $evalCodesArray = $this->runtimeCache->get($cacheId);
1717  if (!is_array($evalCodesArray)) {
1718  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1719  $this->runtimeCache->set($cacheId, $evalCodesArray);
1720  }
1721 
1722  $res = $this->‪checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table, $id);
1723  if (isset($tcaFieldConf['dbType']) && isset($res['value']) && !$res['value']) {
1724  // set the value to null if we have an empty value for a native field
1725  $res['value'] = null;
1726  }
1727 
1728  // Process UNIQUE settings:
1729  // 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
1730  if ($field && !empty($res['value'])) {
1731  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1732  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id, $realPid);
1733  }
1734  if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1735  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id);
1736  }
1737  }
1738  }
1739 
1740  // Skip range validation, if the default value equals 0 and the input value is 0, "0" or an empty string.
1741  // This is needed for timestamp date fields with ['range']['lower'] set.
1742  $skipRangeValidation =
1743  isset($tcaFieldConf['default'], $res['value'])
1744  && (int)$tcaFieldConf['default'] === 0
1745  && ($res['value'] === '' || $res['value'] === '0' || $res['value'] === 0);
1746 
1747  // Checking range of value:
1748  if (!$skipRangeValidation && isset($tcaFieldConf['range']) && is_array($tcaFieldConf['range'])) {
1749  if (isset($tcaFieldConf['range']['upper']) && ceil($res['value']) > (int)$tcaFieldConf['range']['upper']) {
1750  $res['value'] = (int)$tcaFieldConf['range']['upper'];
1751  }
1752  if (isset($tcaFieldConf['range']['lower']) && floor($res['value']) < (int)$tcaFieldConf['range']['lower']) {
1753  $res['value'] = (int)$tcaFieldConf['range']['lower'];
1754  }
1755  }
1756 
1757  // Handle native date/time fields
1758  if ($isDateOrDateTimeField) {
1759  // Convert the timestamp back to a date/time
1760  $res['value'] = $res['value'] ? gmdate($format, $res['value']) : $emptyValue;
1761  }
1762  return $res;
1763  }
1764 
1779  protected function ‪checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
1780  {
1781  $workspaceId = $this->BE_USER->workspace;
1782  $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
1783  $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray ?? []);
1784  // Generate a value if there is none, otherwise ensure that all characters are cleaned up
1785  if ($value === '') {
1786  $value = $helper->generate($fullRecord, $realPid);
1787  } else {
1788  $value = $helper->sanitize($value);
1789  }
1791  // Return directly in case no evaluations are defined
1792  if (empty($tcaFieldConf['eval'])) {
1793  return ['value' => $value];
1794  }
1795 
1796  $state = ‪RecordStateFactory::forName($table)
1797  ->fromArray($fullRecord, $realPid, $id);
1798  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1799  if (in_array('unique', $evalCodesArray, true)) {
1800  $value = $helper->buildSlugForUniqueInTable($value, $state);
1801  }
1802  if (in_array('uniqueInSite', $evalCodesArray, true)) {
1803  $value = $helper->buildSlugForUniqueInSite($value, $state);
1804  }
1805  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1806  $value = $helper->buildSlugForUniqueInPid($value, $state);
1807  }
1808 
1809  return ['value' => $value];
1810  }
1811 
1822  protected function ‪checkValueForLanguage(int $value, string $table, string $field): array
1823  {
1824  // If given table is localizable and the given field is the defined
1825  // languageField, check if the selected language is allowed for the user.
1826  // Note: Usually this method should never be reached, in case the language value is
1827  // not valid, since recordEditAccessInternals checks for proper permission beforehand.
1828  if (BackendUtility::isTableLocalizable($table)
1829  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') === $field
1830  && !$this->BE_USER->checkLanguageAccess($value)
1831  ) {
1832  return [];
1833  }
1834 
1835  // @todo Should we also check if the language is allowed for the current site - if record has site context?
1836 
1837  return ['value' => $value];
1838  }
1839 
1852  protected function ‪checkValueForCategory(
1853  array $result,
1854  string $value,
1855  array $tcaFieldConf,
1856  string $table,
1857  $id,
1858  string $status,
1859  string $field
1860  ): array {
1861  // Exploded comma-separated values and remove duplicates
1862  $valueArray = array_unique(‪GeneralUtility::trimExplode(',', $value, true));
1863  // If an exclusive key is found, discard all others:
1864  if ($tcaFieldConf['exclusiveKeys'] ?? false) {
1865  $exclusiveKeys = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
1866  foreach ($valueArray as $index => $key) {
1867  if (in_array($key, $exclusiveKeys, true)) {
1868  $valueArray = [$index => $key];
1869  break;
1870  }
1871  }
1872  }
1873  $unsetResult = false;
1874  if (str_contains($value, 'NEW')) {
1875  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1876  $this->‪addNewValuesToRemapStackChildIds($valueArray);
1877  $this->remapStack[] = [
1878  'func' => 'checkValue_category_processDBdata',
1879  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field],
1880  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
1881  'field' => $field,
1882  ];
1883  $unsetResult = true;
1884  } else {
1885  $valueArray = $this->‪checkValue_category_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
1886  }
1887  if ($unsetResult) {
1888  unset($result['value']);
1889  } else {
1890  $newVal = implode(',', $this->‪checkValue_checkMax($tcaFieldConf, $valueArray));
1891  $result['value'] = $newVal !== '' ? $newVal : 0;
1892  }
1893  return $result;
1894  }
1895 
1908  protected function ‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field)
1909  {
1910  $items = $tcaFieldConf['items'] ?? null;
1911  if (!empty($tcaFieldConf['itemsProcFunc'])) {
1912  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1913  $items = $processingService->getProcessingItems(
1914  $table,
1915  $realPid,
1916  $field,
1917  $this->checkValue_currentRecord,
1918  $tcaFieldConf,
1919  $tcaFieldConf['items']
1920  );
1921  }
1922 
1923  $itemC = 0;
1924  if ($items !== null) {
1925  $itemC = count($items);
1926  }
1927  if (!$itemC) {
1928  $itemC = 1;
1929  }
1930  $maxV = (2 ** $itemC) - 1;
1931  if ($value < 0) {
1932  // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error.
1933  $value = 0;
1934  }
1935  if ($value > $maxV) {
1936  // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic,
1937  // @todo: changing list of items, then it may happen that a value is transformed and vanished checkboxes
1938  // @todo: are permanently removed from the value.
1939  // @todo: Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a
1940  // @todo: error message to the user - dynamic item sets via itemProcFunc on check would be a bad idea anyway.
1941  $value = $value & $maxV;
1942  }
1943  if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) {
1944  $evalCodesArray = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1945  $otherRecordsWithSameValue = [];
1946  $maxCheckedRecords = 0;
1947  if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) {
1948  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value, $realPid);
1949  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid'];
1950  }
1951  if (in_array('maximumRecordsChecked', $evalCodesArray, true)) {
1952  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value);
1953  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked'];
1954  }
1955 
1956  // there are more than enough records with value "1" in the DB
1957  // if so, set this value to "0" again
1958  if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) {
1959  $value = 0;
1960  $this->‪log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Could not activate checkbox for field "%s". A total of %s record(s) can have this checkbox activated. Uncheck other records first in order to activate the checkbox of this record.', -1, [$this->‪getLanguageService()->sL(BackendUtility::getItemLabel($table, $field)), $maxCheckedRecords]);
1961  }
1962  }
1963  $res['value'] = $value;
1964  return $res;
1965  }
1966 
1979  protected function ‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $pid, $field)
1980  {
1981  if (is_array($tcaFieldConf['items'])) {
1982  foreach ($tcaFieldConf['items'] as $set) {
1983  if ((string)$set[1] === (string)$value) {
1984  $res['value'] = $value;
1985  break;
1986  }
1987  }
1988  }
1989 
1990  // if no value was found and an itemsProcFunc is defined, check that for the value
1991  if (!empty($tcaFieldConf['itemsProcFunc']) && empty($res['value'])) {
1992  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1993  $processedItems = $processingService->getProcessingItems(
1994  $table,
1995  $pid,
1996  $field,
1997  $this->checkValue_currentRecord,
1998  $tcaFieldConf,
1999  $tcaFieldConf['items']
2000  );
2001 
2002  foreach ($processedItems as $set) {
2003  if ((string)$set[1] === (string)$value) {
2004  $res['value'] = $value;
2005  break;
2006  }
2007  }
2008  }
2009 
2010  return $res;
2011  }
2012 
2025  protected function ‪checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field)
2026  {
2027  // Detecting if value sent is an array and if so, implode it around a comma:
2028  if (is_array($value)) {
2029  $value = implode(',', $value);
2030  } else {
2031  $value = (string)$value;
2032  }
2033 
2034  // When values are sent as group or select they come as comma-separated values which are exploded by this function:
2035  $valueArray = $this->‪checkValue_group_select_explodeSelectGroupValue($value);
2036  // If multiple is not set, remove duplicates:
2037  if (!($tcaFieldConf['multiple'] ?? false)) {
2038  $valueArray = array_unique($valueArray);
2039  }
2040  // If an exclusive key is found, discard all others:
2041  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) {
2042  $exclusiveKeys = ‪GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
2043  foreach ($valueArray as $index => $key) {
2044  if (in_array($key, $exclusiveKeys, true)) {
2045  $valueArray = [$index => $key];
2046  break;
2047  }
2048  }
2049  }
2050  // 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?)
2051  // 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!!
2052  $valueArray = $this->‪applyFiltersToValues($tcaFieldConf, $valueArray);
2053  // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed!
2054  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) {
2055  $preCount = count($valueArray);
2056  foreach ($valueArray as $index => $key) {
2057  if (!$this->BE_USER->checkAuthMode($table, $field, $key, $tcaFieldConf['authMode'])) {
2058  unset($valueArray[$index]);
2059  }
2060  }
2061  // 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.
2062  if ($preCount && empty($valueArray)) {
2063  return [];
2064  }
2065  }
2066  // For select types which has a foreign table attached:
2067  $unsetResult = false;
2068  if (($tcaFieldConf['type'] === 'group' && ($tcaFieldConf['internal_type'] ?? '') !== 'folder')
2069  || ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['foreign_table'] ?? false))
2070  ) {
2071  // check, if there is a NEW... id in the value, that should be substituted later
2072  if (str_contains($value, 'NEW')) {
2073  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2074  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2075  $this->remapStack[] = [
2076  'func' => 'checkValue_group_select_processDBdata',
2077  'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field],
2078  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5],
2079  'field' => $field,
2080  ];
2081  $unsetResult = true;
2082  } else {
2083  $valueArray = $this->‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field);
2084  }
2085  }
2086  if (!$unsetResult) {
2087  $newVal = $this->‪checkValue_checkMax($tcaFieldConf, $valueArray);
2088  $res['value'] = $this->‪castReferenceValue(implode(',', $newVal), $tcaFieldConf, str_contains($value, 'NEW'));
2089  } else {
2090  unset($res['value']);
2091  }
2092  return $res;
2093  }
2094 
2103  protected function ‪applyFiltersToValues(array $tcaFieldConfiguration, array $values)
2104  {
2105  if (!is_array($tcaFieldConfiguration['filter'] ?? null)) {
2106  return $values;
2107  }
2108  foreach ($tcaFieldConfiguration['filter'] as $filter) {
2109  if (empty($filter['userFunc'])) {
2110  continue;
2111  }
2112  $parameters = $filter['parameters'] ?? [];
2113  if (!is_array($parameters)) {
2114  $parameters = [];
2115  }
2116  $parameters['values'] = $values;
2117  $parameters['tcaFieldConfig'] = $tcaFieldConfiguration;
2118  $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this);
2119  if (!is_array($values)) {
2120  throw new \RuntimeException('Expected userFunc filter "' . $filter['userFunc'] . '" to return an array. Got ' . gettype($values) . '.', 1336051942);
2121  }
2122  }
2123  return $values;
2124  }
2125 
2142  protected function ‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field)
2143  {
2144  if (is_array($value)) {
2145  // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied.
2146  // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord
2147  // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new
2148  // records we do know the expected PID so therefore we send that with this special parameter. Only active when larger than zero.
2149  $row = $this->checkValue_currentRecord;
2150  if ($status === 'new') {
2151  $row['pid'] = $realPid;
2152  }
2153 
2154  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
2155 
2156  // Get data structure. The methods may throw various exceptions, with some of them being
2157  // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
2158  // and substitute with a dummy DS.
2159  $dataStructureArray = ['sheets' => ['sDEF' => []]];
2160  try {
2161  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
2162  ['config' => $tcaFieldConf],
2163  $table,
2164  $field,
2165  $row
2166  );
2167 
2168  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
2169  } catch (InvalidParentRowException|InvalidParentRowLoopException|InvalidParentRowRootException|InvalidPointerFieldValueException|InvalidIdentifierException $e) {
2170  }
2171 
2172  // Get current value array:
2173  $currentValueArray = (string)$curValue !== '' ? ‪GeneralUtility::xml2array($curValue) : [];
2174  if (!is_array($currentValueArray)) {
2175  $currentValueArray = [];
2176  }
2177  // Remove all old meta for languages...
2178  // Evaluation of input values:
2179  $value['data'] = $this->‪checkValue_flex_procInData($value['data'] ?? [], $currentValueArray['data'] ?? [], $dataStructureArray, [$table, $id, $curValue, $status, $realPid, $recFID, $tscPID]);
2180  // Create XML from input value:
2181  $xmlValue = $this->‪checkValue_flexArray2Xml($value, true);
2182 
2183  // 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
2184  // (provided that the current value was already stored IN the charset that the new value is converted to).
2185  $arrValue = ‪GeneralUtility::xml2array($xmlValue);
2186 
2187  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) {
2188  $hookObject = GeneralUtility::makeInstance($className);
2189  if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) {
2190  $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $arrValue);
2191  }
2192  }
2193 
2194  ‪ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $arrValue);
2195  $xmlValue = $this->‪checkValue_flexArray2Xml($currentValueArray, true);
2196 
2197  // Action commands (sorting order and removals of elements) for flexform sections,
2198  // see FormEngine for the use of this GP parameter
2199  $actionCMDs = GeneralUtility::_GP('_ACTION_FLEX_FORMdata');
2200  $relevantId = $id;
2201  if ($status === 'update'
2202  && BackendUtility::isTableWorkspaceEnabled($table)
2203  && (int)($row['t3ver_wsid'] ?? 0) > 0
2204  && (int)($row['t3ver_oid'] ?? 0) > 0
2205  && !is_array($actionCMDs[$table][$id][$field] ?? false)
2206  && is_array($actionCMDs[$table][(int)$row['t3ver_oid']][$field] ?? false)
2207  ) {
2208  // Scenario: A record with multiple container sections exists in live. The record has no workspace overlay, yet.
2209  // It is then edited in workspaces and sections are resorted or deleted, which should create the version overlay
2210  // plus the resorting or deleting of sections in the version overlay record.
2211  // FormEngine creates this '_ACTION_FLEX_FORMdata' data array with the uid of the live record, since FormEngine
2212  // does not know the uid of the overlay record, yet.
2213  // DataHandler first creates the new overlay record via copyRecord_raw(), which calls this method. At this point,
2214  // we leave the new version record untouched, sorting and deletions of flex sections are not applied.
2215  // DataHandler then calls this method a second time to apply modifications to the just created overlay record. The
2216  // incoming $row is now the version row, and $row['uid'] und incoming $id are the versione'd record uid.
2217  // The '_ACTION_FLEX_FORMdata' POST data however is still the uid of the live record!
2218  // Actions are then not applied since the uid lookups don't match.
2219  // To solve this situation we check for this scenario in the above if conditions and use the live version
2220  // uid (t3ver_oid) to access data from the '_ACTION_FLEX_FORMdata' array.
2221  $relevantId = (int)$row['t3ver_oid'];
2222  }
2223  if (is_array($actionCMDs[$table][$relevantId][$field]['data'] ?? false)) {
2224  $arrValue = ‪GeneralUtility::xml2array($xmlValue);
2225  $this->‪_ACTION_FLEX_FORMdata($arrValue['data'], $actionCMDs[$table][$relevantId][$field]['data']);
2226  $xmlValue = $this->‪checkValue_flexArray2Xml($arrValue, true);
2227  }
2228  // Create the value XML:
2229  $res['value'] = '';
2230  $res['value'] .= $xmlValue;
2231  } else {
2232  // Passthrough...:
2233  $res['value'] = $value;
2234  }
2235 
2236  return $res;
2237  }
2238 
2247  public function ‪checkValue_flexArray2Xml($array, $addPrologue = false)
2248  {
2249  $flexObj = GeneralUtility::makeInstance(FlexFormTools::class);
2250  return $flexObj->flexArray2Xml($array, $addPrologue);
2251  }
2252 
2260  protected function ‪_ACTION_FLEX_FORMdata(&$valueArray, $actionCMDs)
2261  {
2262  if (!is_array($valueArray) || !is_array($actionCMDs)) {
2263  return;
2264  }
2265 
2266  foreach ($actionCMDs as $key => $value) {
2267  if ($key === '_ACTION') {
2268  // First, check if there are "commands":
2269  if (empty(array_filter($actionCMDs[$key]))) {
2270  continue;
2271  }
2272 
2273  asort($actionCMDs[$key]);
2274  $newValueArray = [];
2275  foreach ($actionCMDs[$key] as $idx => $order) {
2276  // Just one reflection here: It is clear that when removing elements from a flexform, then we will get lost
2277  // files unless we act on this delete operation by traversing and deleting files that were referred to.
2278  if ($order !== 'DELETE') {
2279  $newValueArray[$idx] = $valueArray[$idx];
2280  }
2281  unset($valueArray[$idx]);
2282  }
2283  $valueArray += $newValueArray;
2284  } elseif (is_array($actionCMDs[$key]) && isset($valueArray[$key])) {
2285  $this->‪_ACTION_FLEX_FORMdata($valueArray[$key], $actionCMDs[$key]);
2286  }
2287  }
2288  }
2289 
2302  public function ‪checkValue_inline($res, $value, $tcaFieldConf, $PP, $field, ?array $additionalData = null)
2303  {
2304  [$table, $id, , $status] = $PP;
2305  $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData);
2306  }
2307 
2323  public function ‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, ?array $additionalData = null)
2324  {
2325  if (!$tcaFieldConf['foreign_table']) {
2326  // Fatal error, inline fields should always have a foreign_table defined
2327  return false;
2328  }
2329  // When values are sent they come as comma-separated values which are exploded by this function:
2330  $valueArray = ‪GeneralUtility::trimExplode(',', $value);
2331  // Remove duplicates: (should not be needed)
2332  $valueArray = array_unique($valueArray);
2333  // Example for received data:
2334  // $value = 45,NEW4555fdf59d154,12,123
2335  // We need to decide whether we use the stack or can save the relation directly.
2336  if (!empty($value) && (str_contains($value, 'NEW') || !‪MathUtility::canBeInterpretedAsInteger($id))) {
2337  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2338  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2339  $this->remapStack[] = [
2340  'func' => 'checkValue_inline_processDBdata',
2341  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData],
2342  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2343  'additionalData' => $additionalData,
2344  'field' => $field,
2345  ];
2346  unset($res['value']);
2347  } elseif ($value || ‪MathUtility::canBeInterpretedAsInteger($id)) {
2348  $res['value'] = $this->‪checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2349  }
2350  return $res;
2351  }
2352 
2362  public function ‪checkValue_checkMax($tcaFieldConf, $valueArray)
2363  {
2364  // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function
2365  // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check
2366  // before? Probably because we could not evaluate the validity of record uids etc... Hmm...
2367  // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first,
2368  // if the field is actual used regarding the CType.
2369  $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999;
2370  return array_slice($valueArray, 0, $maxitems);
2371  }
2372 
2373  /*********************************************
2374  *
2375  * Helper functions for evaluation functions.
2376  *
2377  ********************************************/
2390  public function ‪getUnique($table, $field, $value, $id, $newPid = 0)
2391  {
2392  if (!is_array(‪$GLOBALS['TCA'][$table]) || !is_array(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
2393  // Field is not configured in TCA
2394  return $value;
2395  }
2396 
2397  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude') {
2398  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
2399  $l10nParent = (int)$this->checkValue_currentRecord[$transOrigPointerField];
2400  if ($l10nParent > 0) {
2401  // Current record is a translation and l10n_mode "exclude" just copies the value from source language
2402  return $value;
2403  }
2404  }
2405 
2406  $newValue = $originalValue = $value;
2407  $queryBuilder = $this->‪getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid);
2408  // For as long as records with the test-value existing, try again (with incremented numbers appended)
2409  $statement = $queryBuilder->prepare();
2410  $result = $statement->executeQuery();
2411  if ($result->fetchOne()) {
2412  for ($counter = 0; $counter <= 100; $counter++) {
2413  $result->free();
2414  $newValue = $value . $counter;
2415  $statement->bindValue(1, $newValue);
2416  $result = $statement->executeQuery();
2417  if (!$result->fetchOne()) {
2418  break;
2419  }
2420  }
2421  $result->free();
2422  }
2423 
2424  if ($originalValue !== $newValue) {
2425  $this->‪log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::WARNING, 'The value of the field "%s" has been changed from "%s" to "%s" as it is required to be unique.', 1, [$field, $originalValue, $newValue], $newPid);
2426  }
2427 
2428  return $newValue;
2429  }
2430 
2441  protected function ‪getUniqueCountStatement(
2442  string $value,
2443  string $table,
2444  string $field,
2445  int $uid,
2446  int $pid
2447  ) {
2448  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
2449  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2450  $queryBuilder
2451  ->count('uid')
2452  ->from($table)
2453  ->where(
2454  $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)),
2455  $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter($uid, ‪Connection::PARAM_INT))
2456  );
2457  // ignore translations of current record if field is configured with l10n_mode = "exclude"
2458  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2459  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2460  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') !== '') {
2461  $queryBuilder
2462  ->andWhere(
2463  $queryBuilder->expr()->orX(
2464  // records without l10n_parent must be taken into account (in any language)
2465  $queryBuilder->expr()->eq(
2466  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2467  $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT)
2468  ),
2469  // translations of other records must be taken into account
2470  $queryBuilder->expr()->neq(
2471  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2472  $queryBuilder->createPositionalParameter($uid, ‪Connection::PARAM_INT)
2473  )
2474  )
2475  );
2476  }
2477  if ($pid !== 0) {
2478  $queryBuilder->andWhere(
2479  $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, ‪Connection::PARAM_INT))
2480  );
2481  } else {
2482  // pid>=0 for versioning
2483  $queryBuilder->andWhere(
2484  $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT))
2485  );
2486  }
2487  return $queryBuilder;
2488  }
2489 
2502  public function ‪getRecordsWithSameValue($tableName, $uid, $fieldName, $value, $pageId = 0)
2503  {
2504  $result = [];
2505  if (empty(‪$GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2506  return $result;
2507  }
2508 
2509  $uid = (int)$uid;
2510  $pageId = (int)$pageId;
2511 
2512  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
2513  $queryBuilder->getRestrictions()
2514  ->removeAll()
2515  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2516  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
2517 
2518  $queryBuilder->select('*')
2519  ->from($tableName)
2520  ->where(
2521  $queryBuilder->expr()->eq(
2522  $fieldName,
2523  $queryBuilder->createNamedParameter($value, ‪Connection::PARAM_STR)
2524  ),
2525  $queryBuilder->expr()->neq(
2526  'uid',
2527  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT)
2528  )
2529  );
2530 
2531  if ($pageId) {
2532  $queryBuilder->andWhere(
2533  $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT))
2534  );
2535  }
2536 
2537  $result = $queryBuilder->executeQuery()->fetchAllAssociative();
2538 
2539  return $result;
2540  }
2541 
2549  public function ‪checkValue_text_Eval($value, $evalArray, $is_in)
2550  {
2551  $res = [];
2552  $set = true;
2553  foreach ($evalArray as $func) {
2554  switch ($func) {
2555  case 'trim':
2556  $value = trim((string)$value);
2557  break;
2558  case 'required':
2559  if (!$value) {
2560  $set = false;
2561  }
2562  break;
2563  default:
2564  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2565  if (class_exists($func)) {
2566  $evalObj = GeneralUtility::makeInstance($func);
2567  if (method_exists($evalObj, 'evaluateFieldValue')) {
2568  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2569  }
2570  }
2571  }
2572  }
2573  }
2574  if ($set) {
2575  $res['value'] = $value;
2576  }
2577  return $res;
2578  }
2579 
2591  public function ‪checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array
2592  {
2593  $res = [];
2594  $set = true;
2595  foreach ($evalArray as $func) {
2596  switch ($func) {
2597  case 'int':
2598  case 'year':
2599  $value = (int)$value;
2600  break;
2601  case 'time':
2602  case 'timesec':
2603  // If $value is a pure integer we have the number of seconds, we can store that directly
2604  if ($value !== '' && !‪MathUtility::canBeInterpretedAsInteger($value)) {
2605  // $value is an ISO 8601 date
2606  $value = (new \DateTime($value))->getTimestamp();
2607  }
2608  break;
2609  case 'date':
2610  case 'datetime':
2611  // If $value is a pure integer we have the number of seconds, we can store that directly
2612  if ($value !== null && $value !== '' && !‪MathUtility::canBeInterpretedAsInteger($value)) {
2613  // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2614  // For instance "1999-11-11T11:11:11Z"
2615  // Since the user actually specifies the time in the server's local time, we need to mangle this
2616  // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2617  // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2618  // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2619  // TZ difference.
2620  try {
2621  // Make the date from JS a timestamp
2622  $value = (new \DateTime($value))->getTimestamp();
2623  } catch (\Exception $e) {
2624  // set the default timezone value to achieve the value of 0 as a result
2625  $value = (int)date('Z', 0);
2626  }
2627 
2628  // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2629  $value -= date('Z', $value);
2630  }
2631  break;
2632  case 'double2':
2633  $value = preg_replace('/[^0-9,\\.-]/', '', $value);
2634  $negative = substr($value, 0, 1) === '-';
2635  $value = strtr($value, [',' => '.', '-' => '']);
2636  if (!str_contains($value, '.')) {
2637  $value .= '.0';
2638  }
2639  $valueArray = explode('.', $value);
2640  $dec = array_pop($valueArray);
2641  $value = implode('', $valueArray) . '.' . $dec;
2642  if ($negative) {
2643  $value *= -1;
2644  }
2645  $value = number_format((float)$value, 2, '.', '');
2646  break;
2647  case 'md5':
2648  if (strlen($value) !== 32) {
2649  $set = false;
2650  }
2651  break;
2652  case 'trim':
2653  $value = trim($value);
2654  break;
2655  case 'upper':
2656  $value = mb_strtoupper($value, 'utf-8');
2657  break;
2658  case 'lower':
2659  $value = mb_strtolower($value, 'utf-8');
2660  break;
2661  case 'required':
2662  if (!isset($value) || $value === '') {
2663  $set = false;
2664  }
2665  break;
2666  case 'is_in':
2667  $c = mb_strlen($value);
2668  if ($c) {
2669  $newVal = '';
2670  for ($a = 0; $a < $c; $a++) {
2671  $char = mb_substr($value, $a, 1);
2672  if (str_contains($is_in, $char)) {
2673  $newVal .= $char;
2674  }
2675  }
2676  $value = $newVal;
2677  }
2678  break;
2679  case 'nospace':
2680  $value = str_replace(' ', '', $value);
2681  break;
2682  case 'alpha':
2683  $value = preg_replace('/[^a-zA-Z]/', '', $value);
2684  break;
2685  case 'num':
2686  $value = preg_replace('/[^0-9]/', '', $value);
2687  break;
2688  case 'alphanum':
2689  $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
2690  break;
2691  case 'alphanum_x':
2692  $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
2693  break;
2694  case 'domainname':
2695  if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
2696  $value = (string)idn_to_ascii($value);
2697  }
2698  break;
2699  case 'email':
2700  if ((string)$value !== '') {
2701  $this->‪checkValue_input_ValidateEmail($value, $set, $table, $id);
2702  }
2703  break;
2704  case 'saltedPassword':
2705  // An incoming value is either the salted password if the user did not change existing password
2706  // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
2707  // The strategy is to see if a salt instance can be created from the incoming value. If so,
2708  // no new password was submitted and we keep the value. If no salting instance can be created,
2709  // incoming value must be a new plain text value that needs to be hashed.
2710  $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
2711  $mode = $table === 'fe_users' ? 'FE' : 'BE';
2712  try {
2713  $hashFactory->get($value, $mode);
2714  } catch (InvalidPasswordHashException $e) {
2715  // We got no salted password instance, incoming value must be a new plaintext password
2716  // Get an instance of the current configured salted password strategy and hash the value
2717  $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
2718  $value = $newHashInstance->getHashedPassword($value);
2719  }
2720  break;
2721  default:
2722  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2723  if (class_exists($func)) {
2724  $evalObj = GeneralUtility::makeInstance($func);
2725  if (method_exists($evalObj, 'evaluateFieldValue')) {
2726  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2727  }
2728  }
2729  }
2730  }
2731  }
2732  if ($set) {
2733  $res['value'] = $value;
2734  }
2735  return $res;
2736  }
2737 
2748  protected function ‪checkValue_input_ValidateEmail($value, &$set, string $table, $id)
2749  {
2750  if (GeneralUtility::validEmail($value)) {
2751  return;
2752  }
2753 
2754  $set = false;
2755  $this->‪log(
2756  $table,
2757  $id,
2758  SystemLogDatabaseAction::UPDATE,
2759  0,
2760  SystemLogErrorClassification::SECURITY_NOTICE,
2761  '"' . $value . '" is not a valid e-mail address.',
2762  -1,
2763  [$this->‪getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.invalidEmail'), $value]
2764  );
2765  }
2766 
2779  public function ‪checkValue_category_processDBdata(
2780  array $valueArray,
2781  array $tcaFieldConf,
2782  $id,
2783  string $status,
2784  string $table,
2785  string $field
2786  ): array {
2787  $newRelations = implode(',', $valueArray);
2788  $relationHandler = $this->‪createRelationHandlerInstance();
2789  $relationHandler->start($newRelations, $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
2790  if ($tcaFieldConf['MM'] ?? false) {
2791  $relationHandler->convertItemArray();
2792  if ($status === 'update') {
2793  $relationHandleForOldRelations = $this->‪createRelationHandlerInstance();
2794  $relationHandleForOldRelations->start('', $tcaFieldConf['foreign_table'], $tcaFieldConf['MM'], $id, $table, $tcaFieldConf);
2795  $oldRelations = implode(',', $relationHandleForOldRelations->getValueArray());
2796  $relationHandler->writeMM($tcaFieldConf['MM'], $id);
2797  if ($oldRelations !== $newRelations) {
2798  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = $oldRelations;
2799  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = $newRelations;
2800  } else {
2801  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = '';
2802  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = '';
2803  }
2804  } else {
2805  $this->dbAnalysisStore[] = [$relationHandler, $tcaFieldConf['MM'], $id, '', $table];
2806  }
2807  $valueArray = $relationHandler->countItems();
2808  } else {
2809  $valueArray = $relationHandler->getValueArray();
2810  }
2811  return $valueArray;
2812  }
2813 
2827  public function ‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
2828  {
2829  $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table'];
2830  $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
2831  $newRelations = implode(',', $valueArray);
2832  $dbAnalysis = $this->‪createRelationHandlerInstance();
2833  $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2834  $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
2835  if ($tcaFieldConf['MM'] ?? false) {
2836  // convert submitted items to use version ids instead of live ids
2837  // (only required for MM relations in a workspace context)
2838  $dbAnalysis->convertItemArray();
2839  if ($status === 'update') {
2840  $oldRelations_dbAnalysis = $this->‪createRelationHandlerInstance();
2841  $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2842  // Db analysis with $id will initialize with the existing relations
2843  $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
2844  $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
2845  $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, $prep);
2846  if ($oldRelations != $newRelations) {
2847  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
2848  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
2849  } else {
2850  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
2851  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
2852  }
2853  } else {
2854  $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
2855  }
2856  $valueArray = $dbAnalysis->countItems();
2857  } else {
2858  $valueArray = $dbAnalysis->getValueArray($prep);
2859  }
2860  // 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.
2861  return $valueArray;
2862  }
2863 
2872  {
2873  $valueArray = ‪GeneralUtility::trimExplode(',', $value, true);
2874  foreach ($valueArray as &$newVal) {
2875  $temp = explode('|', $newVal, 2);
2876  $newVal = str_replace(['|', ','], '', rawurldecode($temp[0]));
2877  }
2878  unset($newVal);
2879  return $valueArray;
2880  }
2881 
2897  public function ‪checkValue_flex_procInData($dataPart, $dataPart_current, $dataStructure, $pParams, $callBackFunc = '', array $workspaceOptions = [])
2898  {
2899  if (is_array($dataPart)) {
2900  foreach ($dataPart as $sKey => $sheetDef) {
2901  if (isset($dataStructure['sheets'][$sKey]) && is_array($dataStructure['sheets'][$sKey]) && is_array($sheetDef)) {
2902  foreach ($sheetDef as $lKey => $lData) {
2904  $dataPart[$sKey][$lKey],
2905  $dataPart_current[$sKey][$lKey] ?? null,
2906  $dataStructure['sheets'][$sKey]['ROOT']['el'] ?? null,
2907  $pParams,
2908  $callBackFunc,
2909  $sKey . '/' . $lKey . '/',
2910  $workspaceOptions
2911  );
2912  }
2913  }
2914  }
2915  }
2916  return $dataPart;
2917  }
2918 
2933  public function ‪checkValue_flex_procInData_travDS(&$dataValues, $dataValues_current, $DSelements, $pParams, $callBackFunc, $structurePath, array $workspaceOptions = [])
2934  {
2935  if (!is_array($DSelements)) {
2936  return;
2937  }
2938 
2939  // For each DS element:
2940  foreach ($DSelements as $key => $dsConf) {
2941  // Array/Section:
2942  if (isset($DSelements[$key]['type']) && $DSelements[$key]['type'] === 'array') {
2943  if (!is_array($dataValues[$key]['el'] ?? null)) {
2944  continue;
2945  }
2946 
2947  if ($DSelements[$key]['section']) {
2948  foreach ($dataValues[$key]['el'] as $ik => $el) {
2949  if (!is_array($el)) {
2950  continue;
2951  }
2952 
2953  if (!is_array($dataValues_current[$key]['el'] ?? false)) {
2954  $dataValues_current[$key]['el'] = [];
2955  }
2956  $theKey = key($el);
2957  if (!is_array($dataValues[$key]['el'][$ik][$theKey]['el'])) {
2958  continue;
2959  }
2960 
2962  $dataValues[$key]['el'][$ik][$theKey]['el'],
2963  $dataValues_current[$key]['el'][$ik][$theKey]['el'] ?? [],
2964  $DSelements[$key]['el'][$theKey]['el'] ?? [],
2965  $pParams,
2966  $callBackFunc,
2967  $structurePath . $key . '/el/' . $ik . '/' . $theKey . '/el/',
2968  $workspaceOptions
2969  );
2970  }
2971  } else {
2972  if (!isset($dataValues[$key]['el'])) {
2973  $dataValues[$key]['el'] = [];
2974  }
2975  $this->‪checkValue_flex_procInData_travDS($dataValues[$key]['el'], $dataValues_current[$key]['el'], $DSelements[$key]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/', $workspaceOptions);
2976  }
2977  } else {
2978  // When having no specific sheets, it's "TCEforms.config", when having a sheet, it's just "config"
2979  $fieldConfiguration = $dsConf['TCEforms']['config'] ?? $dsConf['config'] ?? null;
2980  // init with value from config for passthrough fields
2981  if (!empty($fieldConfiguration['type']) && $fieldConfiguration['type'] === 'passthrough') {
2982  if (!empty($dataValues_current[$key]['vDEF'])) {
2983  // If there is existing value, keep it
2984  $dataValues[$key]['vDEF'] = $dataValues_current[$key]['vDEF'];
2985  } elseif (
2986  !empty($fieldConfiguration['default'])
2987  && isset($pParams[1])
2989  ) {
2990  // If is new record and a default is specified for field, use it.
2991  $dataValues[$key]['vDEF'] = $fieldConfiguration['default'];
2992  }
2993  }
2994  if (!is_array($fieldConfiguration) || !isset($dataValues[$key]) || !is_array($dataValues[$key])) {
2995  continue;
2996  }
2997 
2998  foreach ($dataValues[$key] as $vKey => $data) {
2999  if ($callBackFunc) {
3000  if (is_object($this->callBackObj)) {
3001  $res = $this->callBackObj->{$callBackFunc}(
3002  $pParams,
3003  $fieldConfiguration,
3004  $dataValues[$key][$vKey] ?? null,
3005  $dataValues_current[$key][$vKey] ?? null,
3006  $structurePath . $key . '/' . $vKey . '/',
3007  $workspaceOptions
3008  );
3009  } else {
3010  $res = $this->{$callBackFunc}(
3011  $pParams,
3012  $fieldConfiguration,
3013  $dataValues[$key][$vKey] ?? null,
3014  $dataValues_current[$key][$vKey] ?? null,
3015  $structurePath . $key . '/' . $vKey . '/',
3016  $workspaceOptions
3017  );
3018  }
3019  } else {
3020  // Default
3021  [$CVtable, $CVid, $CVcurValue, $CVstatus, $CVrealPid, $CVrecFID, $CVtscPID] = $pParams;
3022 
3023  $additionalData = [
3024  'flexFormId' => $CVrecFID,
3025  'flexFormPath' => trim(rtrim($structurePath, '/') . '/' . $key . '/' . $vKey, '/'),
3026  ];
3027 
3028  $res = $this->‪checkValue_SW(
3029  [],
3030  $dataValues[$key][$vKey] ?? null,
3031  $fieldConfiguration,
3032  $CVtable,
3033  $CVid,
3034  $dataValues_current[$key][$vKey] ?? null,
3035  $CVstatus,
3036  $CVrealPid,
3037  $CVrecFID,
3038  '',
3039  $CVtscPID,
3040  $additionalData
3041  );
3042  }
3043  // Adding the value:
3044  if (isset($res['value'])) {
3045  $dataValues[$key][$vKey] = $res['value'];
3046  }
3047  }
3048  }
3049  }
3050  }
3051 
3063  protected function checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field)
3064  {
3065  $foreignTable = $tcaFieldConf['foreign_table'];
3066  $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
3067  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3068  $dbAnalysis = $this->createRelationHandlerInstance();
3069  $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf);
3070  // IRRE with a pointer field (database normalization):
3071  if ($tcaFieldConf['foreign_field'] ?? false) {
3072  // update record in intermediate table (sorting & pointer uid to parent record)
3073  $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0);
3074  $newValue = $dbAnalysis->countItems(false);
3075  } elseif ($this->getInlineFieldType($tcaFieldConf) === 'mm') {
3076  // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here
3077  $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field);
3078  $newValue = $valueArray[0];
3079  } else {
3080  $valueArray = $dbAnalysis->getValueArray();
3081  // Checking that the number of items is correct:
3082  $valueArray = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
3083  $newValue = $this->castReferenceValue(implode(',', $valueArray), $tcaFieldConf, ($status === 'new'));
3084  }
3085  return $newValue;
3086  }
3087 
3088  /*********************************************
3089  *
3090  * PROCESSING COMMANDS
3091  *
3092  ********************************************/
3099  public function process_cmdmap()
3100  {
3101  // Editing frozen:
3102  if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
3103  $this->log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen!');
3104  return false;
3105  }
3106  // Hook initialization:
3107  $hookObjectsArr = [];
3108  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
3109  $hookObj = GeneralUtility::makeInstance($className);
3110  if (method_exists($hookObj, 'processCmdmap_beforeStart')) {
3111  $hookObj->processCmdmap_beforeStart($this);
3112  }
3113  $hookObjectsArr[] = $hookObj;
3114  }
3115  $pasteDatamap = [];
3116  // Traverse command map:
3117  foreach ($this->cmdmap as $table => $_) {
3118  // Check if the table may be modified!
3119  $modifyAccessList = $this->checkModifyAccessList($table);
3120  if (!$modifyAccessList) {
3121  $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
3122  }
3123  // Check basic permissions and circumstances:
3124  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->cmdmap[$table]) || !$modifyAccessList) {
3125  continue;
3126  }
3127 
3128  // Traverse the command map:
3129  foreach ($this->cmdmap[$table] as $id => $incomingCmdArray) {
3130  if (!is_array($incomingCmdArray)) {
3131  continue;
3132  }
3133 
3134  if ($table === 'pages') {
3135  // for commands on pages do a pagetree-refresh
3136  $this->pagetreeNeedsRefresh = true;
3137  }
3138 
3139  foreach ($incomingCmdArray as $command => $value) {
3140  $pasteUpdate = false;
3141  if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') {
3142  // Extended paste command: $command is set to "move" or "copy"
3143  // $value['update'] holds field/value pairs which should be updated after copy/move operation
3144  // $value['target'] holds original $value (target of move/copy)
3145  $pasteUpdate = $value['update'];
3146  $value = $value['target'];
3147  }
3148  foreach ($hookObjectsArr as $hookObj) {
3149  if (method_exists($hookObj, 'processCmdmap_preProcess')) {
3150  $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate);
3151  }
3152  }
3153  // Init copyMapping array:
3154  // Must clear this array before call from here to those functions:
3155  // Contains mapping information between new and old id numbers.
3156  $this->copyMappingArray = [];
3157  // process the command
3158  $commandIsProcessed = false;
3159  foreach ($hookObjectsArr as $hookObj) {
3160  if (method_exists($hookObj, 'processCmdmap')) {
3161  $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate);
3162  }
3163  }
3164  // Only execute default commands if a hook hasn't been processed the command already
3165  if (!$commandIsProcessed) {
3166  $procId = $id;
3167  $backupUseTransOrigPointerField = $this->useTransOrigPointerField;
3168  // Branch, based on command
3169  switch ($command) {
3170  case 'move':
3171  $this->moveRecord($table, (int)$id, $value);
3172  break;
3173  case 'copy':
3174  $target = $value['target'] ?? $value;
3175  $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false);
3176  if ($table === 'pages') {
3177  $this->copyPages((int)$id, $target);
3178  } else {
3179  $this->copyRecord($table, (int)$id, $target, true, [], '', 0, $ignoreLocalization);
3180  }
3181  $procId = $this->copyMappingArray[$table][$id] ?? null;
3182  break;
3183  case 'localize':
3184  $this->useTransOrigPointerField = true;
3185  $this->localize($table, (int)$id, $value);
3186  break;
3187  case 'copyToLanguage':
3188  $this->useTransOrigPointerField = false;
3189  $this->localize($table, (int)$id, $value);
3190  break;
3191  case 'inlineLocalizeSynchronize':
3192  $this->inlineLocalizeSynchronize($table, (int)$id, $value);
3193  break;
3194  case 'delete':
3195  $this->deleteAction($table, (int)$id);
3196  break;
3197  case 'undelete':
3198  $this->undeleteRecord((string)$table, (int)$id);
3199  break;
3200  }
3201  $this->useTransOrigPointerField = $backupUseTransOrigPointerField;
3202  if (is_array($pasteUpdate) && $procId > 0) {
3203  $pasteDatamap[$table][$procId] = $pasteUpdate;
3204  }
3205  }
3206  foreach ($hookObjectsArr as $hookObj) {
3207  if (method_exists($hookObj, 'processCmdmap_postProcess')) {
3208  $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap);
3209  }
3210  }
3211  // Merging the copy-array info together for remapping purposes.
3212  ‪ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray);
3213  }
3214  }
3215  }
3216  $copyTCE = $this->getLocalTCE();
3217  $copyTCE->start($pasteDatamap, [], $this->BE_USER);
3218  $copyTCE->process_datamap();
3219  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3220  unset($copyTCE);
3221 
3222  // Finally, before exit, check if there are ID references to remap.
3223  // This might be the case if versioning or copying has taken place!
3224  $this->remapListedDBRecords();
3225  $this->processRemapStack();
3226  foreach ($hookObjectsArr as $hookObj) {
3227  if (method_exists($hookObj, 'processCmdmap_afterFinish')) {
3228  $hookObj->processCmdmap_afterFinish($this);
3229  }
3230  }
3231  if ($this->isOuterMostInstance()) {
3232  $this->referenceIndexUpdater->update();
3233  $this->processClearCacheQueue();
3234  $this->resetNestedElementCalls();
3235  }
3236  }
3237 
3238  /*********************************************
3239  *
3240  * Cmd: Copying
3241  *
3242  ********************************************/
3257  public function copyRecord($table, $uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '', $language = 0, $ignoreLocalization = false)
3258  {
3259  $uid = ($origUid = (int)$uid);
3260  // Only copy if the table is defined in $GLOBALS['TCA'], a uid is given and the record wasn't copied before:
3261  if (empty(‪$GLOBALS['TCA'][$table]) || $uid === 0) {
3262  return null;
3263  }
3264  if ($this->isRecordCopied($table, $uid)) {
3265  return null;
3266  }
3267 
3268  // Fetch record with permission check
3269  $row = $this->recordInfoWithPermissionCheck($table, $uid, ‪Permission::PAGE_SHOW);
3270 
3271  // This checks if the record can be selected which is all that a copy action requires.
3272  if ($row === false) {
3273  $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "%s:%s" which does not exist or you do not have permission to read', -1, [$table, $uid]);
3274  return null;
3275  }
3276 
3277  // 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...
3278  $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, $uid, $destPid);
3279 
3280  // Check if table is allowed on destination page
3281  if (!$this->isTableAllowedForThisPage($tscPID, $table)) {
3282  $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record "%s:%s" on a page (%s) that can\'t store record type.', -1, [$table, $uid, $tscPID]);
3283  return null;
3284  }
3285 
3286  $fullLanguageCheckNeeded = $table !== 'pages';
3287  // Used to check language and general editing rights
3288  if (!$ignoreLocalization && ($language <= 0 || !$this->BE_USER->checkLanguageAccess($language)) && !$this->BE_USER->recordEditAccessInternals($table, $uid, false, false, $fullLanguageCheckNeeded)) {
3289  $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "%s:%s" without having permissions to do so. [%s].', -1, [$table, $uid, $this->BE_USER->errorMsg]);
3290  return null;
3291  }
3292 
3293  $data = [];
3294  $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));
3295  BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace);
3296  $row = BackendUtility::purgeComputedPropertiesFromRecord($row);
3297 
3298  // Initializing:
3299  $theNewID = ‪StringUtility::getUniqueId('NEW');
3300  $enableField = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] ?? '';
3301  $headerField = ‪$GLOBALS['TCA'][$table]['ctrl']['label'];
3302  // Getting "copy-after" fields if applicable:
3303  $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields($table, $uid, abs($destPid), false) : [];
3304  // Page TSconfig related:
3305  $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3306  $tE = $this->getTableEntries($table, $TSConfig);
3307  // Traverse ALL fields of the selected record:
3308  foreach ($row as $field => $value) {
3309  if (!in_array($field, $nonFields, true)) {
3310  // Get TCA configuration for the field:
3311  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3312  // Preparation/Processing of the value:
3313  // "pid" is hardcoded of course:
3314  // isset() won't work here, since values can be NULL in each of the arrays
3315  // except setDefaultOnCopyArray, since we exploded that from a string
3316  if ($field === 'pid') {
3317  $value = $destPid;
3318  } elseif (array_key_exists($field, $overrideValues)) {
3319  // Override value...
3320  $value = $overrideValues[$field];
3321  } elseif (array_key_exists($field, $copyAfterFields)) {
3322  // Copy-after value if available:
3323  $value = $copyAfterFields[$field];
3324  } else {
3325  // Hide at copy may override:
3326  if ($first && $field == $enableField
3327  && (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3328  && !$this->neverHideAtCopy
3329  && !($tE['disableHideAtCopy'] ?? false)
3330  ) {
3331  $value = 1;
3332  }
3333  // Prepend label on copy:
3334  if ($first && $field == $headerField
3335  && (‪$GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3336  && !($tE['disablePrependAtCopy'] ?? false)
3337  ) {
3338  $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0);
3339  }
3340  // Processing based on the TCA config field type (files, references, flexforms...)
3341  $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $language);
3342  }
3343  // Add value to array.
3344  $data[$table][$theNewID][$field] = $value;
3345  }
3346  }
3347  // Overriding values:
3348  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3349  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3350  }
3351  // Setting original UID:
3352  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3353  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3354  }
3355  // Do the copy by simply submitting the array through DataHandler:
3356  $copyTCE = $this->getLocalTCE();
3357  $copyTCE->start($data, [], $this->BE_USER);
3358  $copyTCE->process_datamap();
3359  // Getting the new UID:
3360  $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null;
3361  if ($theNewSQLID) {
3362  $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3363  // Keep automatically versionized record information:
3364  if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3365  $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3366  }
3367  }
3368  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3369  unset($copyTCE);
3370  if (!$ignoreLocalization && $language == 0) {
3371  //repointing the new translation records to the parent record we just created
3372  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
3373  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3374  }
3375  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3376  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3377  }
3378  $this->copyL10nOverlayRecords($table, $uid, $destPid, $first, $overrideValues, $excludeFields);
3379  }
3380 
3381  return $theNewSQLID;
3382  }
3383 
3392  public function copyPages($uid, $destPid)
3393  {
3394  // Initialize:
3395  $uid = (int)$uid;
3396  $destPid = (int)$destPid;
3397 
3398  $copyTablesAlongWithPage = $this->getAllowedTablesToCopyWhenCopyingAPage();
3399  // Begin to copy pages if we're allowed to:
3400  if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3401  // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3402  // This method also copies the localizations of a page
3403  $theNewRootID = $this->copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true);
3404  // If we're going to copy recursively
3405  if ($theNewRootID && $this->copyTree) {
3406  // Get ALL subpages to copy (read-permissions are respected!):
3407  $CPtable = $this->int_pageTreeInfo([], $uid, (int)$this->copyTree, $theNewRootID);
3408  // Now copying the subpages:
3409  foreach ($CPtable as $thePageUid => $thePagePid) {
3410  $newPid = $this->copyMappingArray['pages'][$thePagePid] ?? null;
3411  if (isset($newPid)) {
3412  $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3413  } else {
3414  $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3415  break;
3416  }
3417  }
3418  }
3419  } else {
3420  $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page {uid} without permission to this table', -1, ['uid' => $uid]);
3421  }
3422  }
3423 
3433  protected function getAllowedTablesToCopyWhenCopyingAPage(): array
3434  {
3435  // Finding list of tables to copy.
3436  // These are the tables, the user may modify
3437  $copyTablesArray = $this->admin ? $this->compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3438  // If not all tables are allowed then make a list of allowed tables.
3439  // That is the tables that figure in both allowed tables AND the copyTable-list
3440  if (!str_contains($this->copyWhichTables, '*')) {
3441  $definedTablesToCopy = ‪GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3442  // Pages are always allowed
3443  $definedTablesToCopy[] = 'pages';
3444  $definedTablesToCopy = array_flip($definedTablesToCopy);
3445  foreach ($copyTablesArray as $k => $table) {
3446  if (!$table || !isset($definedTablesToCopy[$table])) {
3447  unset($copyTablesArray[$k]);
3448  }
3449  }
3450  }
3451  $copyTablesArray = array_unique($copyTablesArray);
3452  return $copyTablesArray;
3453  }
3464  public function copySpecificPage($uid, $destPid, $copyTablesArray, $first = false)
3465  {
3466  // Copy the page itself:
3467  $theNewRootID = $this->copyRecord('pages', $uid, $destPid, $first);
3468  $currentWorkspaceId = (int)$this->BE_USER->workspace;
3469  // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3470  ‪if ($theNewRootID) {
3471  foreach ($copyTablesArray as $table) {
3472  // All records under the page is copied.
3473  if ($table && is_array(‪$GLOBALS['TCA'][$table] ?? false) && $table !== 'pages') {
3474  ‪$fields = ['uid'];
3475  $languageField = null;
3476  $transOrigPointerField = null;
3477  $translationSourceField = null;
3478  if (BackendUtility::isTableLocalizable($table)) {
3479  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
3480  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3481  ‪$fields[] = $languageField;
3482  ‪$fields[] = $transOrigPointerField;
3483  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3484  $translationSourceField = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3485  ‪$fields[] = $translationSourceField;
3486  }
3487  }
3488  $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3489  if ($isTableWorkspaceEnabled) {
3490  ‪$fields[] = 't3ver_oid';
3491  ‪$fields[] = 't3ver_state';
3492  ‪$fields[] = 't3ver_wsid';
3493  }
3494  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3495  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3496  $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3497  $queryBuilder
3498  ->select(...‪$fields)
3499  ->from($table)
3500  ->where(
3501  $queryBuilder->expr()->eq(
3502  'pid',
3503  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT)
3504  )
3505  );
3506  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3507  $queryBuilder->orderBy(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3508  }
3509  $queryBuilder->addOrderBy('uid');
3510  try {
3511  $result = $queryBuilder->executeQuery();
3512  $rows = [];
3513  $movedLiveIds = [];
3514  $movedLiveRecords = [];
3515  while ($row = $result->fetchAssociative()) {
3516  if ($isTableWorkspaceEnabled && (int)$row['t3ver_state'] === ‪VersionState::MOVE_POINTER) {
3517  $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3518  }
3519  $rows[(int)$row['uid']] = $row;
3520  }
3521  // Resolve placeholders of workspace versions
3522  if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3523  // If a record was moved within the page, the PlainDataResolver needs the moved record
3524  // but not the original live version, otherwise the moved record is not considered at all.
3525  // For this reason, we find the live ids, where there was also a moved record in the SQL
3526  // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3527  // see changeContentSortingAndCopyDraftPage test
3528  foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3529  if (isset($rows[$liveId])) {
3530  $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3531  unset($rows[$liveId]);
3532  }
3533  }
3534  $rows = array_reverse(
3535  $this->resolveVersionedRecords(
3536  $table,
3537  implode(',', ‪$fields),
3538  ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '',
3539  array_keys($rows)
3540  ),
3541  true
3542  );
3543  foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3544  $rows[$movePlaceHolderId] = $liveRecord;
3545  }
3546  }
3547  if (is_array($rows)) {
3548  $languageSourceMap = [];
3549  $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3550  $doRemap = false;
3551  foreach ($rows as $row) {
3552  // Skip localized records that will be processed in
3553  // copyL10nOverlayRecords() on copying the default language record
3554  $transOrigPointer = $row[$transOrigPointerField] ?? 0;
3555  if (!empty($languageField)
3556  && $row[$languageField] > 0
3557  && $transOrigPointer > 0
3558  && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))
3559  ) {
3560  continue;
3561  }
3562  // Copying each of the underlying records...
3563  $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3564  if ($translationSourceField) {
3565  $languageSourceMap[$row['uid']] = $newUid;
3566  if ($row[$languageField] > 0) {
3567  $doRemap = true;
3568  }
3569  }
3570  }
3571  if ($doRemap) {
3572  //remap is needed for records in non-default language records in the "free mode"
3573  $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3574  }
3575  }
3576  } catch (DBALException $e) {
3577  $databaseErrorMessage = $e->getPrevious()->getMessage();
3578  $this->log($table, $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: {reason}', -1, ['reason' => $databaseErrorMessage]);
3579  }
3580  }
3581  }
3582  $this->processRemapStack();
3583  return $theNewRootID;
3584  }
3585  return null;
3586  }
3587 
3604  public function copyRecord_raw($table, $uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3605  {
3606  $uid = (int)$uid;
3607  // Stop any actions if the record is marked to be deleted:
3608  // (this can occur if IRRE elements are versionized and child elements are removed)
3609  if ($this->isElementToBeDeleted($table, $uid)) {
3610  return null;
3611  }
3612  // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3613  if (!‪$GLOBALS['TCA'][$table] || !$uid || $this->isRecordCopied($table, $uid)) {
3614  return null;
3615  }
3616 
3617  // Fetch record with permission check
3618  $row = $this->recordInfoWithPermissionCheck($table, $uid, ‪Permission::PAGE_SHOW);
3619 
3620  // This checks if the record can be selected which is all that a copy action requires.
3621  if ($row === false) {
3622  $this->log(
3623  $table,
3624  $uid,
3625  SystemLogDatabaseAction::INSERT,
3626  0,
3627  SystemLogErrorClassification::USER_ERROR,
3628  'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3629  );
3630  return null;
3631  }
3632 
3633  // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3634  $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3635 
3636  // Merge in override array.
3637  $row = array_merge($row, $overrideArray);
3638  // Traverse ALL fields of the selected record:
3639  foreach ($row as $field => $value) {
3641  if (!in_array($field, $nonFields, true)) {
3642  // Get TCA configuration for the field:
3643  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3644  if (is_array($conf)) {
3645  // Processing based on the TCA config field type (files, references, flexforms...)
3646  $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3647  }
3648  // Add value to array.
3649  $row[$field] = $value;
3650  }
3651  }
3652  $row['pid'] = $pid;
3653  // Setting original UID:
3654  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
3655  $row[‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3656  }
3657  // Do the copy by internal function
3658  $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid);
3659 
3660  // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
3661  // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
3662  // that refers e.g. to a tt_content record is marked as deleted. The tt_content record then needs a reference index update.
3663  // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
3664  if (!empty($workspaceOptions)) {
3665  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
3666  }
3667 
3668  if ($theNewSQLID) {
3669  $this->dbAnalysisStoreExec();
3670  $this->dbAnalysisStore = [];
3671  return $this->copyMappingArray[$table][$uid] = $theNewSQLID;
3672  }
3673  return null;
3674  }
3675 
3686  public function insertNewCopyVersion($table, $fieldArray, $realPid)
3687  {
3688  $id = ‪StringUtility::getUniqueId('NEW');
3689  // $fieldArray is set as current record.
3690  // 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...
3691  $this->checkValue_currentRecord = $fieldArray;
3692  // Makes sure that transformations aren't processed on the copy.
3693  $backupDontProcessTransformations = $this->dontProcessTransformations;
3694  $this->dontProcessTransformations = true;
3695  // Traverse record and input-process each value:
3696  foreach ($fieldArray as $field => $fieldValue) {
3697  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
3698  // Evaluating the value.
3699  $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
3700  if (isset($res['value'])) {
3701  $fieldArray[$field] = $res['value'];
3702  }
3703  }
3704  }
3705  // System fields being set:
3706  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
3707  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
3708  }
3709  if (‪$GLOBALS['TCA'][$table]['ctrl']['cruser_id'] ?? false) {
3710  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
3711  }
3712  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
3713  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
3714  }
3715  // Finally, insert record:
3716  $this->insertDB($table, $id, $fieldArray, BackendUtility::isTableWorkspaceEnabled($table));
3717  // Resets dontProcessTransformations to the previous state.
3718  $this->dontProcessTransformations = $backupDontProcessTransformations;
3719  // Return new id:
3720  return $this->substNEWwithIDs[$id] ?? null;
3721  }
3722 
3739  public function copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
3740  {
3741  $inlineSubType = $this->getInlineFieldType($conf);
3742  // Get the localization mode for the current (parent) record (keep|select):
3743  // Register if there are references to take care of or MM is used on an inline field (no change to value):
3744  if ($this->isReferenceField($conf) || $inlineSubType === 'mm') {
3745  $value = $this->copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language);
3746  } elseif ($inlineSubType !== false) {
3747  $value = $this->copyRecord_processInline($table, $uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
3748  }
3749  // 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())
3750  if (isset($conf['type']) && $conf['type'] === 'flex') {
3751  // Get current value array:
3752  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
3753  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
3754  ['config' => $conf],
3755  $table,
3756  $field,
3757  $row
3758  );
3759  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
3760  $currentValue = is_string($value) ? ‪GeneralUtility::xml2array($value) : null;
3761  // Traversing the XML structure, processing files:
3762  if (is_array($currentValue)) {
3763  $currentValue['data'] = $this->checkValue_flex_procInData($currentValue['data'] ?? [], [], $dataStructureArray, [$table, $uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
3764  // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
3765  $value = $currentValue;
3766  }
3767  }
3768  return $value;
3769  }
3770 
3782  protected function copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language)
3783  {
3784  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
3785  $allowedTablesArray = ‪GeneralUtility::trimExplode(',', $allowedTables, true);
3786  $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
3787  $mmTable = !empty($conf['MM']) ? $conf['MM'] : '';
3788 
3789  $dbAnalysis = $this->createRelationHandlerInstance();
3790  $dbAnalysis->start($value, $allowedTables, $mmTable, $uid, $table, $conf);
3791  $purgeItems = false;
3792 
3793  // Check if referenced records of select or group fields should also be localized in general.
3794  // A further check is done in the loop below for each table name.
3795  if ($language > 0 && $mmTable === '' && !empty($conf['localizeReferencesAtParentLocalization'])) {
3796  // Check whether allowed tables can be localized.
3797  $localizeTables = [];
3798  foreach ($allowedTablesArray as $allowedTable) {
3799  $localizeTables[$allowedTable] = BackendUtility::isTableLocalizable($allowedTable);
3800  }
3801 
3802  foreach ($dbAnalysis->itemArray as $index => $item) {
3803  // No action required, if referenced tables cannot be localized (current value will be used).
3804  if (empty($localizeTables[$item['table']])) {
3805  continue;
3806  }
3807 
3808  // Since select or group fields can reference many records, check whether there's already a localization.
3809  $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
3810  if ($recordLocalization) {
3811  $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
3812  } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . $language) === false) {
3813  $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], $item['id'], $language);
3814  }
3815  }
3816  $purgeItems = true;
3817  }
3818 
3819  if ($purgeItems || $mmTable !== '') {
3820  $dbAnalysis->purgeItemArray();
3821  $value = implode(',', $dbAnalysis->getValueArray($prependName));
3822  }
3823  // Setting the value in this array will notify the remapListedDBRecords() function that this field MAY need references to be corrected.
3824  if ($value) {
3825  $this->registerDBList[$table][$uid][$field] = $value;
3826  }
3827 
3828  return $value;
3829  }
3830 
3845  protected function copyRecord_processInline(
3846  $table,
3847  $uid,
3848  $field,
3849  $value,
3850  $row,
3851  $conf,
3852  $realDestPid,
3853  $language,
3854  array $workspaceOptions
3855  ) {
3856  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3857  $dbAnalysis = $this->createRelationHandlerInstance();
3858  $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
3859  // Walk through the items, copy them and remember the new id:
3860  foreach ($dbAnalysis->itemArray as $k => $v) {
3861  $newId = null;
3862  // If language is set and differs from original record, this isn't a copy action but a localization of our parent/ancestor:
3863  if ($language > 0 && BackendUtility::isTableLocalizable($table) && $language != $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]) {
3864  // Children should be localized when the parent gets localized the first time, just do it:
3865  $newId = $this->localize($v['table'], $v['id'], $language);
3866  } else {
3867  if (!‪MathUtility::canBeInterpretedAsInteger($realDestPid)) {
3868  $newId = $this->copyRecord($v['table'], $v['id'], -(int)($v['id']));
3869  // If the destination page id is a NEW string, keep it on the same page
3870  } elseif ($this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($v['table'])) {
3871  // A filled $workspaceOptions indicated that this call
3872  // has it's origin in previous versionizeRecord() processing
3873  if (!empty($workspaceOptions)) {
3874  // Versions use live default id, thus the "new"
3875  // id is the original live default child record
3876  $newId = $v['id'];
3877  $this->versionizeRecord(
3878  $v['table'],
3879  $v['id'],
3880  $workspaceOptions['label'] ?? 'Auto-created for WS #' . $this->BE_USER->workspace,
3881  $workspaceOptions['delete'] ?? false
3882  );
3883  // Otherwise just use plain copyRecord() to create placeholders etc.
3884  } else {
3885  // If a record has been copied already during this request,
3886  // prevent superfluous duplication and use the existing copy
3887  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
3888  $newId = $this->copyMappingArray[$v['table']][$v['id']];
3889  } else {
3890  $newId = $this->copyRecord($v['table'], $v['id'], $realDestPid);
3891  }
3892  }
3893  } elseif ($this->BE_USER->workspace > 0 && !BackendUtility::isTableWorkspaceEnabled($v['table'])) {
3894  // We are in workspace context creating a new parent version and have a child table
3895  // that is not workspace aware. We don't do anything with this child.
3896  continue;
3897  } else {
3898  // If a record has been copied already during this request,
3899  // prevent superfluous duplication and use the existing copy
3900  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
3901  $newId = $this->copyMappingArray[$v['table']][$v['id']];
3902  } else {
3903  $newId = $this->copyRecord_raw($v['table'], $v['id'], $realDestPid, [], $workspaceOptions);
3904  }
3905  }
3906  }
3907  // If the current field is set on a page record, update the pid of related child records:
3908  if ($table === 'pages') {
3909  $this->registerDBPids[$v['table']][$v['id']] = $uid;
3910  } elseif (isset($this->registerDBPids[$table][$uid])) {
3911  $this->registerDBPids[$v['table']][$v['id']] = $this->registerDBPids[$table][$uid];
3912  }
3913  $dbAnalysis->itemArray[$k]['id'] = $newId;
3914  }
3915  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
3916  $value = implode(',', $dbAnalysis->getValueArray());
3917  $this->registerDBList[$table][$uid][$field] = $value;
3918 
3919  return $value;
3920  }
3921 
3936  public function copyRecord_flexFormCallBack($pParams, $dsConf, $dataValue, $_1, $_2, $workspaceOptions)
3937  {
3938  // Extract parameters:
3939  [$table, $uid, $field, $realDestPid] = $pParams;
3940  // If references are set for this field, set flag so they can be corrected later (in ->remapListedDBRecords())
3941  if (($this->isReferenceField($dsConf) || $this->getInlineFieldType($dsConf) !== false) && (string)$dataValue !== '') {
3942  $dataValue = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $dataValue, [], $dsConf, $realDestPid, 0, $workspaceOptions);
3943  $this->registerDBList[$table][$uid][$field] = 'FlexForm_reference';
3944  }
3945  // Return
3946  return ['value' => $dataValue];
3947  }
3948 
3960  public function copyL10nOverlayRecords($table, $uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '')
3961  {
3962  // There's no need to perform this for tables that are not localizable
3963  if (!BackendUtility::isTableLocalizable($table)) {
3964  return;
3965  }
3966 
3967  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
3968  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
3969 
3970  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3971  $queryBuilder->getRestrictions()
3972  ->removeAll()
3973  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
3974  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
3975 
3976  $queryBuilder->select('*')
3977  ->from($table)
3978  ->where(
3979  $queryBuilder->expr()->eq(
3980  $transOrigPointerField,
3981  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT, ':pointer')
3982  )
3983  );
3984 
3985  // Never copy the actual placeholders around, as the newly copied records are
3986  // always created as new record / new placeholder pairs
3987  if (BackendUtility::isTableWorkspaceEnabled($table)) {
3988  $queryBuilder->andWhere(
3989  $queryBuilder->expr()->neq(
3990  't3ver_state',
3992  )
3993  );
3994  }
3995 
3996  // If $destPid is < 0, get the pid of the record with uid equal to abs($destPid)
3997  $tscPID = BackendUtility::getTSconfig_pidValue($table, $uid, $destPid) ?? 0;
3998  // Get the localized records to be copied
3999  $l10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4000  if (is_array($l10nRecords)) {
4001  $localizedDestPids = [];
4002  // If $destPid < 0, then it is the uid of the original language record we are inserting after
4003  if ($destPid < 0) {
4004  // Get the localized records of the record we are inserting after
4005  $queryBuilder->setParameter('pointer', abs($destPid), ‪Connection::PARAM_INT);
4006  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4007  // Index the localized record uids by language
4008  if (is_array($destL10nRecords)) {
4009  foreach ($destL10nRecords as $record) {
4010  $localizedDestPids[$record[$languageField]] = -$record['uid'];
4011  }
4012  }
4013  }
4014  $languageSourceMap = [
4015  $uid => $overrideValues[$transOrigPointerField],
4016  ];
4017  // Copy the localized records after the corresponding localizations of the destination record
4018  foreach ($l10nRecords as $record) {
4019  $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0);
4020  if ($localizedDestPid < 0) {
4021  $newUid = $this->copyRecord($table, $record['uid'], $localizedDestPid, $first, $overrideValues, $excludeFields, $record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4022  } else {
4023  $newUid = $this->copyRecord($table, $record['uid'], $destPid < 0 ? $tscPID : $destPid, $first, $overrideValues, $excludeFields, $record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4024  }
4025  $languageSourceMap[$record['uid']] = $newUid;
4026  }
4027  $this->copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap);
4028  }
4029  }
4030 
4038  protected function copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap)
4039  {
4040  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4041  return;
4042  }
4043  $translationSourceFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
4044  $translationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
4045 
4046  //We can avoid running these update queries by sorting the $l10nRecords by languageSource dependency (in copyL10nOverlayRecords)
4047  //and first copy records depending on default record (and map the field).
4048  foreach ($l10nRecords as $record) {
4049  $oldSourceUid = $record[$translationSourceFieldName];
4050  if ($oldSourceUid <= 0 && $record[$translationParentFieldName] > 0) {
4051  //BC fix - in connected mode 'translationSource' field should not be 0
4052  $oldSourceUid = $record[$translationParentFieldName];
4053  }
4054  if ($oldSourceUid > 0) {
4055  if (empty($languageSourceMap[$oldSourceUid])) {
4056  // we don't have mapping information available e.g when copyRecord returned null
4057  continue;
4058  }
4059  $newFieldValue = $languageSourceMap[$oldSourceUid];
4060  $updateFields = [
4061  $translationSourceFieldName => $newFieldValue,
4062  ];
4063  if (isset($languageSourceMap[$record['uid']])) {
4064  GeneralUtility::makeInstance(ConnectionPool::class)
4065  ->getConnectionForTable($table)
4066  ->update($table, $updateFields, ['uid' => (int)$languageSourceMap[$record['uid']]]);
4067  if ($this->BE_USER->workspace > 0) {
4068  GeneralUtility::makeInstance(ConnectionPool::class)
4069  ->getConnectionForTable($table)
4070  ->update($table, $updateFields, ['t3ver_oid' => (int)$languageSourceMap[$record['uid']], 't3ver_wsid' => $this->BE_USER->workspace]);
4071  }
4072  }
4073  }
4074  }
4075  }
4076 
4077  /*********************************************
4078  *
4079  * Cmd: Moving, Localizing
4080  *
4081  ********************************************/
4090  public function moveRecord($table, $uid, $destPid)
4091  {
4092  if (!‪$GLOBALS['TCA'][$table]) {
4093  return;
4094  }
4095 
4096  // In case the record to be moved turns out to be an offline version,
4097  // we have to find the live version and work on that one.
4098  if ($lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, $uid, 'uid')) {
4099  $uid = $lookForLiveVersion['uid'];
4100  }
4101  // Initialize:
4102  $destPid = (int)$destPid;
4103  // Get this before we change the pid (for logging)
4104  $propArr = $this->getRecordProperties($table, $uid);
4105  $moveRec = $this->getRecordProperties($table, $uid, true);
4106  // This is the actual pid of the moving to destination
4107  $resolvedPid = $this->resolvePid($table, $destPid);
4108  // 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.
4109  // If the record is a page, then there are two options: If the page is moved within itself,
4110  // (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.
4111  if ($table !== 'pages' || $resolvedPid == $moveRec['pid']) {
4112  // Edit rights for the record...
4113  $mayMoveAccess = $this->checkRecordUpdateAccess($table, $uid);
4114  } else {
4115  $mayMoveAccess = $this->doesRecordExist($table, $uid, ‪Permission::PAGE_DELETE);
4116  }
4117  // Finding out, if the record may be moved TO another place. Here we check insert-rights (non-pages = edit, pages = new),
4118  // unless the pages are moved on the same pid, then edit-rights are checked
4119  if ($table !== 'pages' || $resolvedPid != $moveRec['pid']) {
4120  // Insert rights for the record...
4121  $mayInsertAccess = $this->checkRecordInsertAccess($table, $resolvedPid, SystemLogDatabaseAction::MOVE);
4122  } else {
4123  $mayInsertAccess = $this->checkRecordUpdateAccess($table, $uid);
4124  }
4125  // Checking if there is anything else disallowing moving the record by checking if editing is allowed
4126  $fullLanguageCheckNeeded = $table !== 'pages';
4127  $mayEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, false, $fullLanguageCheckNeeded);
4128  // If moving is allowed, begin the processing:
4129  if (!$mayEditAccess) {
4130  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "%s" (%s) without having permissions to do so. [%s]', 14, [$propArr['header'], $table . ':' . $uid, $this->BE_USER->errorMsg], $propArr['event_pid']);
4131  return;
4132  }
4133 
4134  if (!$mayMoveAccess) {
4135  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) without having permissions to do so.', 14, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4136  return;
4137  }
4138 
4139  if (!$mayInsertAccess) {
4140  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) without having permissions to insert.', 14, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4141  return;
4142  }
4143 
4144  $recordWasMoved = false;
4145  // Move the record via a hook, used e.g. for versioning
4146  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4147  $hookObj = GeneralUtility::makeInstance($className);
4148  if (method_exists($hookObj, 'moveRecord')) {
4149  $hookObj->moveRecord($table, $uid, $destPid, $propArr, $moveRec, $resolvedPid, $recordWasMoved, $this);
4150  }
4151  }
4152  // Move the record if a hook hasn't moved it yet
4153  if (!$recordWasMoved) {
4154  $this->moveRecord_raw($table, $uid, $destPid);
4155  }
4156  }
4157 
4168  public function moveRecord_raw($table, $uid, $destPid)
4169  {
4170  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
4171  $origDestPid = $destPid;
4172  // This is the actual pid of the moving to destination
4173  $resolvedPid = $this->resolvePid($table, $destPid);
4174  // Checking if the pid is negative, but no sorting row is defined. In that case, find the correct pid.
4175  // Basically this check make the error message 4-13 meaning less... But you can always remove this check if you
4176  // prefer the error instead of a no-good action (which is to move the record to its own page...)
4177  if (($destPid < 0 && !$sortColumn) || $destPid >= 0) {
4178  $destPid = $resolvedPid;
4179  }
4180  // Get this before we change the pid (for logging)
4181  $propArr = $this->getRecordProperties($table, $uid);
4182  $moveRec = $this->getRecordProperties($table, $uid, true);
4183  // Prepare user defined objects (if any) for hooks which extend this function:
4184  $hookObjectsArr = [];
4185  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4186  $hookObjectsArr[] = GeneralUtility::makeInstance($className);
4187  }
4188  // Timestamp field:
4189  $updateFields = [];
4190  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
4191  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
4192  }
4193 
4194  // Check if this is a translation of a page, if so then it just needs to be kept "sorting" in sync
4195  // Usually called from moveL10nOverlayRecords()
4196  if ($table === 'pages') {
4197  $defaultLanguagePageUid = $this->getDefaultLanguagePageId((int)$uid);
4198  // In workspaces, the default language page may have been moved to a different pid than the
4199  // default language page record of live workspace. In this case, localized pages need to be
4200  // moved to the pid of the workspace move record.
4201  $defaultLanguagePageWorkspaceOverlay = BackendUtility::getWorkspaceVersionOfRecord((int)$this->BE_USER->workspace, 'pages', $defaultLanguagePageUid, 'uid');
4202  if (is_array($defaultLanguagePageWorkspaceOverlay)) {
4203  $defaultLanguagePageUid = (int)$defaultLanguagePageWorkspaceOverlay['uid'];
4204  }
4205  if ($defaultLanguagePageUid !== (int)$uid) {
4206  // If the default language page has been moved, localized pages need to be moved to
4207  // that pid and sorting, too.
4208  $originalTranslationRecord = $this->recordInfo($table, $defaultLanguagePageUid, 'pid,' . $sortColumn);
4209  $updateFields[$sortColumn] = $originalTranslationRecord[$sortColumn];
4210  $destPid = $originalTranslationRecord['pid'];
4211  }
4212  }
4213 
4214  // Insert as first element on page (where uid = $destPid)
4215  if ($destPid >= 0) {
4216  if ($table !== 'pages' || $this->destNotInsideSelf($destPid, $uid)) {
4217  // Clear cache before moving
4218  [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
4219  $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
4220  // Setting PID
4221  $updateFields['pid'] = $destPid;
4222  // Table is sorted by 'sortby'
4223  if ($sortColumn && !isset($updateFields[$sortColumn])) {
4224  $sortNumber = $this->getSortNumber($table, $uid, $destPid);
4225  $updateFields[$sortColumn] = $sortNumber;
4226  }
4227  // Check for child records that have also to be moved
4228  $this->moveRecord_procFields($table, $uid, $destPid);
4229  // Create query for update:
4230  GeneralUtility::makeInstance(ConnectionPool::class)
4231  ->getConnectionForTable($table)
4232  ->update($table, $updateFields, ['uid' => (int)$uid]);
4233  // Check for the localizations of that element
4234  $this->moveL10nOverlayRecords($table, $uid, $destPid, $destPid);
4235  // Call post processing hooks:
4236  foreach ($hookObjectsArr as $hookObj) {
4237  if (method_exists($hookObj, 'moveRecord_firstElementPostProcess')) {
4238  $hookObj->moveRecord_firstElementPostProcess($table, $uid, $destPid, $moveRec, $updateFields, $this);
4239  }
4240  }
4241 
4242  $this->getRecordHistoryStore()->moveRecord($table, $uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4243  if ($this->enableLogging) {
4244  // Logging...
4245  $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4246  if ($destPid != $propArr['pid']) {
4247  // Logged to old page
4248  $newPropArr = $this->getRecordProperties($table, $uid);
4249  $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4250  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) to page \'%s\' (%s)', 2, [$propArr['header'], $table . ':' . $uid, $newpagePropArr['header'], $newPropArr['pid']], $propArr['pid']);
4251  // Logged to new page
4252  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) from page \'%s\' (%s)', 3, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4253  } else {
4254  // Logged to new page
4255  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) on page \'%s\' (%s)', 4, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4256  }
4257  }
4258  // Clear cache after moving
4259  $this->registerRecordIdForPageCacheClearing($table, $uid);
4260  $this->fixUniqueInPid($table, $uid);
4261  $this->fixUniqueInSite($table, (int)$uid);
4262  if ($table === 'pages') {
4263  $this->fixUniqueInSiteForSubpages((int)$uid);
4264  }
4265  } elseif ($this->enableLogging) {
4266  $destPropArr = $this->getRecordProperties('pages', $destPid);
4267  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page \'%s\' (%s) to inside of its own rootline (at page \'%s\' (%s))', 10, [$propArr['header'], $uid, $destPropArr['header'], $destPid], $propArr['pid']);
4268  }
4269  } elseif ($sortColumn) {
4270  // Put after another record
4271  // Table is being sorted
4272  // Save the position to which the original record is requested to be moved
4273  $originalRecordDestinationPid = $destPid;
4274  $sortInfo = $this->getSortNumber($table, $uid, $destPid);
4275  // If not an array, there was an error (which is already logged)
4276  if (is_array($sortInfo)) {
4277  // Setting the destPid to the new pid of the record.
4278  $destPid = $sortInfo['pid'];
4279  if ($table !== 'pages' || $this->destNotInsideSelf($destPid, $uid)) {
4280  // clear cache before moving
4281  $this->registerRecordIdForPageCacheClearing($table, $uid);
4282  // We now update the pid and sortnumber (if not set for page translations)
4283  $updateFields['pid'] = $destPid;
4284  if (!isset($updateFields[$sortColumn])) {
4285  $updateFields[$sortColumn] = $sortInfo['sortNumber'];
4286  }
4287  // Check for child records that have also to be moved
4288  $this->moveRecord_procFields($table, $uid, $destPid);
4289  // Create query for update:
4290  GeneralUtility::makeInstance(ConnectionPool::class)
4291  ->getConnectionForTable($table)
4292  ->update($table, $updateFields, ['uid' => (int)$uid]);
4293  // Check for the localizations of that element
4294  $this->moveL10nOverlayRecords($table, $uid, $destPid, $originalRecordDestinationPid);
4295  // Call post processing hooks:
4296  foreach ($hookObjectsArr as $hookObj) {
4297  if (method_exists($hookObj, 'moveRecord_afterAnotherElementPostProcess')) {
4298  $hookObj->moveRecord_afterAnotherElementPostProcess($table, $uid, $destPid, $origDestPid, $moveRec, $updateFields, $this);
4299  }
4300  }
4301  $this->getRecordHistoryStore()->moveRecord($table, $uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4302  if ($this->enableLogging) {
4303  // Logging...
4304  $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4305  if ($destPid != $propArr['pid']) {
4306  // Logged to old page
4307  $newPropArr = $this->getRecordProperties($table, $uid);
4308  $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4309  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) to page \'%s\' (%s)', 2, [$propArr['header'], $table . ':' . $uid, $newpagePropArr['header'], $newPropArr['pid']], $propArr['pid']);
4310  // Logged to old page
4311  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) from page \'%s\' (%s)', 3, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4312  } else {
4313  // Logged to old page
4314  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) on page \'%s\' (%s)', 4, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4315  }
4316  }
4317  // Clear cache after moving
4318  $this->registerRecordIdForPageCacheClearing($table, $uid);
4319  $this->fixUniqueInPid($table, $uid);
4320  $this->fixUniqueInSite($table, (int)$uid);
4321  if ($table === 'pages') {
4322  $this->fixUniqueInSiteForSubpages((int)$uid);
4323  }
4324  } elseif ($this->enableLogging) {
4325  $destPropArr = $this->getRecordProperties('pages', $destPid);
4326  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page \'%s\' (%s) to inside of its own rootline (at page \'%s\' (%s))', 10, [$propArr['header'], $uid, $destPropArr['header'], $destPid], $propArr['pid']);
4327  }
4328  } else {
4329  $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) to after another record, although the table has no sorting row.', 13, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4330  }
4331  }
4332  }
4333 
4343  public function moveRecord_procFields($table, $uid, $destPid)
4344  {
4345  $row = BackendUtility::getRecordWSOL($table, $uid);
4346  if (is_array($row) && (int)$destPid !== (int)$row['pid']) {
4347  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
4348  foreach ($row as $field => $value) {
4349  $this->moveRecord_procBasedOnFieldType($table, $uid, $destPid, $value, $conf[$field]['config'] ?? []);
4350  }
4351  }
4352  }
4353 
4364  public function moveRecord_procBasedOnFieldType($table, $uid, $destPid, $value, $conf)
4365  {
4366  $dbAnalysis = null;
4367  if (!empty($conf['type']) && $conf['type'] === 'inline') {
4368  $foreign_table = $conf['foreign_table'];
4369  $moveChildrenWithParent = !isset($conf['behaviour']['disableMovingChildrenWithParent']) || !$conf['behaviour']['disableMovingChildrenWithParent'];
4370  if ($foreign_table && $moveChildrenWithParent) {
4371  $inlineType = $this->getInlineFieldType($conf);
4372  if ($inlineType === 'list' || $inlineType === 'field') {
4373  if ($table === 'pages') {
4374  // If the inline elements are related to a page record,
4375  // make sure they reside at that page and not at its parent
4376  $destPid = $uid;
4377  }
4378  $dbAnalysis = $this->createRelationHandlerInstance();
4379  $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
4380  }
4381  }
4382  }
4383  // Move the records
4384  if (isset($dbAnalysis)) {
4385  // Moving records to a positive destination will insert each
4386  // record at the beginning, thus the order is reversed here:
4387  foreach (array_reverse($dbAnalysis->itemArray) as $v) {
4388  $this->moveRecord($v['table'], $v['id'], $destPid);
4389  }
4390  }
4391  }
4392 
4402  public function moveL10nOverlayRecords($table, $uid, $destPid, $originalRecordDestinationPid)
4403  {
4404  // There's no need to perform this for non-localizable tables
4405  if (!BackendUtility::isTableLocalizable($table)) {
4406  return;
4407  }
4408 
4409  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4410  $queryBuilder->getRestrictions()
4411  ->removeAll()
4412  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4413  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace));
4414 
4415  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
4416  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4417  $l10nRecords = $queryBuilder->select('*')
4418  ->from($table)
4419  ->where(
4420  $queryBuilder->expr()->eq(
4421  $transOrigPointerField,
4422  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT, ':pointer')
4423  )
4424  )
4425  ->executeQuery()
4426  ->fetchAllAssociative();
4427 
4428  if (is_array($l10nRecords)) {
4429  $localizedDestPids = [];
4430  // If $$originalRecordDestinationPid < 0, then it is the uid of the original language record we are inserting after
4431  if ($originalRecordDestinationPid < 0) {
4432  // Get the localized records of the record we are inserting after
4433  $queryBuilder->setParameter('pointer', abs($originalRecordDestinationPid), ‪Connection::PARAM_INT);
4434  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4435  // Index the localized record uids by language
4436  if (is_array($destL10nRecords)) {
4437  foreach ($destL10nRecords as $record) {
4438  $localizedDestPids[$record[$languageField]] = -$record['uid'];
4439  }
4440  }
4441  }
4442  // Move the localized records after the corresponding localizations of the destination record
4443  foreach ($l10nRecords as $record) {
4444  $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0);
4445  if ($localizedDestPid < 0) {
4446  $this->moveRecord($table, $record['uid'], $localizedDestPid);
4447  } else {
4448  $this->moveRecord($table, $record['uid'], $destPid);
4449  }
4450  }
4451  }
4452  }
4453 
4463  public function localize($table, $uid, $language)
4464  {
4465  $newId = false;
4466  $uid = (int)$uid;
4467  if (!‪$GLOBALS['TCA'][$table] || !$uid || $this->isNestedElementCallRegistered($table, $uid, 'localize-' . (string)$language) !== false) {
4468  return false;
4469  }
4470 
4471  $this->registerNestedElementCall($table, $uid, 'localize-' . (string)$language);
4472  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4473  $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]);
4474  return false;
4475  }
4476 
4477  if (!$this->doesRecordExist($table, $uid, ‪Permission::PAGE_SHOW)) {
4478  $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} without permission.', -1, ['table' => $table, 'uid' => (int)$uid]);
4479  return false;
4480  }
4481 
4482  // Getting workspace overlay if possible - this will localize versions in workspace if any
4483  $row = BackendUtility::getRecordWSOL($table, $uid);
4484  if (!is_array($row)) {
4485  $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]);
4486  return false;
4487  }
4488 
4489  [$pageId] = BackendUtility::getTSCpid($table, $uid, '');
4490  // Try to fetch the site language from the pages' associated site
4491  $siteLanguage = $this->getSiteLanguageForPage((int)$pageId, (int)$language);
4492  if ($siteLanguage === null) {
4493  $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]);
4494  return false;
4495  }
4496 
4497  // Make sure that records which are translated from another language than the default language have a correct
4498  // localization source set themselves, before translating them to another language.
4499  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4500  && $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4501  $localizationParentRecord = BackendUtility::getRecord(
4502  $table,
4503  $row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4504  );
4505  if ((int)$localizationParentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4506  $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']]);
4507  return false;
4508  }
4509  }
4510 
4511  // Default language records must never have a localization parent as they are the origin of any translation.
4512  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4513  && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4514  $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']]);
4515  return false;
4516  }
4517 
4518  $recordLocalizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'AND pid=' . (int)$row['pid']);
4519 
4520  if (!empty($recordLocalizations)) {
4521  $this->log(
4522  $table,
4523  $uid,
4524  SystemLogDatabaseAction::LOCALIZE,
4525  0,
4526  SystemLogErrorClassification::USER_ERROR,
4527  'Localization failed: there already are localizations (%s) for language %d of the "%s" record %d!',
4528  -1,
4529  [
4530  implode(', ', array_column($recordLocalizations, 'uid')),
4531  $language,
4532  $table,
4533  $uid,
4534  ]
4535  );
4536  return false;
4537  }
4538 
4539  // Initialize:
4540  $overrideValues = [];
4541  // Set override values:
4542  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] = (int)$language;
4543  // If the translated record is a default language record, set it's uid as localization parent of the new record.
4544  // If translating from any other language, no override is needed; we just can copy the localization parent of
4545  // the original record (which is pointing to the correspondent default language record) to the new record.
4546  // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4547  // For pages, there is no "copy/free mode".
4548  if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4549  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $uid;
4550  } elseif (!$this->useTransOrigPointerField) {
4551  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4552  }
4553  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4554  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = $uid;
4555  }
4556  // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4557  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['type'])) {
4558  // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4559  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[‪$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4560  }
4561  // Set exclude Fields:
4562  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4563  $translateToMsg = '';
4564  // Check if we are just prefixing:
4565  if (isset($fCfg['l10n_mode']) && $fCfg['l10n_mode'] === 'prefixLangTitle') {
4566  if (($fCfg['config']['type'] === 'text' || $fCfg['config']['type'] === 'input') && (string)$row[$fN] !== '') {
4567  $TSConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? [];
4568  $tableEntries = $this->getTableEntries($table, $TSConfig);
4569  if (!empty($TSConfig['translateToMessage']) && !($tableEntries['disablePrependAtCopy'] ?? false)) {
4570  $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']);
4571  $translateToMsg = @sprintf($translateToMsg, $siteLanguage->getTitle());
4572  }
4573 
4574  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4575  $hookObj = GeneralUtility::makeInstance($className);
4576  if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4577  // @todo Deprecate passing an array and pass the full SiteLanguage object instead
4578  $hookObj->processTranslateTo_copyAction(
4579  $row[$fN],
4580  ['uid' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle()],
4581  $this,
4582  $fN
4583  );
4584  }
4585  }
4586  if (!empty($translateToMsg)) {
4587  $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4588  } else {
4589  $overrideValues[$fN] = $row[$fN];
4590  }
4591  }
4592  }
4593  if (($fCfg['config']['MM'] ?? false) && !empty($fCfg['config']['MM_oppositeUsage'])) {
4594  // We are localizing the 'local' side of an MM relation. (eg. localizing a category).
4595  // In this case, MM relations connected to the default lang record should not be copied,
4596  // so we set an override here to not trigger mm handling of 'items' field for this.
4597  $overrideValues[$fN] = 0;
4598  }
4599  }
4600 
4601  if ($table !== 'pages') {
4602  // Get the uid of record after which this localized record should be inserted
4603  $previousUid = $this->getPreviousLocalizedRecordUid($table, $uid, $row['pid'], $language);
4604  // Execute the copy:
4605  $newId = $this->copyRecord($table, $uid, -$previousUid, true, $overrideValues, '', $language);
4606  } else {
4607  // Create new page which needs to contain the same pid as the original page
4608  $overrideValues['pid'] = $row['pid'];
4609  // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4610  // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4611  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4612  $hiddenFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4613  $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? ‪$GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4614  // Override by TCA "hideAtCopy" or pageTS "disableHideAtCopy"
4615  // Only for visible pages to get the same behaviour as for copy
4616  if (!$overrideValues[$hiddenFieldName]) {
4617  $TSConfig = BackendUtility::getPagesTSconfig($uid)['TCEMAIN.'] ?? [];
4618  $tableEntries = $this->getTableEntries($table, $TSConfig);
4619  if (
4620  (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
4621  && !$this->neverHideAtCopy
4622  && !($tableEntries['disableHideAtCopy'] ?? false)
4623  ) {
4624  $overrideValues[$hiddenFieldName] = 1;
4625  }
4626  }
4627  }
4628  $temporaryId = ‪StringUtility::getUniqueId('NEW');
4629  $copyTCE = $this->getLocalTCE();
4630  $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4631  $copyTCE->process_datamap();
4632  // Getting the new UID as if it had been copied:
4633  $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4634  if ($theNewSQLID) {
4635  $this->copyMappingArray[$table][$uid] = $theNewSQLID;
4636  $newId = $theNewSQLID;
4637  }
4638  }
4639 
4640  return $newId;
4641  }
4642 
4659  protected function inlineLocalizeSynchronize($table, $id, $command)
4660  {
4661  $parentRecord = BackendUtility::getRecordWSOL($table, $id);
4662 
4663  // Backward-compatibility handling
4664  if (!is_array($command)) {
4665  // @deprecated, will be removed in TYPO3 v12.0.
4666  trigger_error('DataHandler command InlineLocalizeSynchronize needs to use an array as command input, which is available since TYPO3 v7.6. This fallback mechanism will be removed in TYPO3 v12.0.', E_USER_DEPRECATED);
4667  // <field>, (localize | synchronize | <uid>):
4668  $parts = ‪GeneralUtility::trimExplode(',', $command);
4669  $command = [
4670  'field' => $parts[0],
4671  // The previous process expected $id to point to the localized record already
4672  'language' => (int)$parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']],
4673  ];
4675  $command['action'] = $parts[1];
4676  } else {
4677  $command['ids'] = [$parts[1]];
4678  }
4679  }
4680 
4681  // In case the parent record is the default language record, fetch the localization
4682  if (empty($parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
4683  // Fetch the live record
4684  // @todo: this needs to be revisited, as getRecordLocalization() does a BackendWorkspaceRestriction
4685  // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
4686  $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
4687  if (empty($parentRecordLocalization)) {
4688  $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']));
4689  return;
4690  }
4691  $parentRecord = $parentRecordLocalization[0];
4692  $id = $parentRecord['uid'];
4693  // Process overlay for current selected workspace
4694  BackendUtility::workspaceOL($table, $parentRecord);
4695  }
4696 
4697  $field = $command['field'] ?? '';
4698  $language = $command['language'] ?? 0;
4699  $action = $command['action'] ?? '';
4700  $ids = $command['ids'] ?? [];
4701 
4702  if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset(‪$GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
4703  return;
4704  }
4705 
4706  $config = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
4707  $foreignTable = $config['foreign_table'];
4708 
4709  $transOrigPointer = (int)$parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
4710  $childTransOrigPointerField = ‪$GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
4711 
4712  if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
4713  return;
4714  }
4715 
4716  $inlineSubType = $this->getInlineFieldType($config);
4717  if ($inlineSubType === false) {
4718  return;
4719  }
4720 
4721  $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
4722 
4723  $removeArray = [];
4724  $mmTable = $inlineSubType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
4725  // Fetch children from original language parent:
4726  $dbAnalysisOriginal = $this->createRelationHandlerInstance();
4727  $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
4728  $elementsOriginal = [];
4729  foreach ($dbAnalysisOriginal->itemArray as $item) {
4730  $elementsOriginal[$item['id']] = $item;
4731  }
4732  unset($dbAnalysisOriginal);
4733  // Fetch children from current localized parent:
4734  $dbAnalysisCurrent = $this->createRelationHandlerInstance();
4735  $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
4736  // Perform synchronization: Possibly removal of already localized records:
4737  if ($action === 'synchronize') {
4738  foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
4739  $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
4740  if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
4741  $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
4742  // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
4743  if (!isset($elementsOriginal[$childTransOrigPointer])) {
4744  unset($dbAnalysisCurrent->itemArray[$index]);
4745  $removeArray[$item['table']][$item['id']]['delete'] = 1;
4746  }
4747  }
4748  }
4749  }
4750  // Perform synchronization/localization: Possibly add unlocalized records for original language:
4751  if ($action === 'localize' || $action === 'synchronize') {
4752  foreach ($elementsOriginal as $originalId => $item) {
4753  if ($this->‪isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4754  continue;
4755  }
4756  $item['id'] = $this->localize($item['table'], $item['id'], $language);
4757 
4758  if (is_int($item['id'])) {
4759  $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4760  }
4761  $dbAnalysisCurrent->itemArray[] = $item;
4762  }
4763  } elseif (!empty($ids)) {
4764  foreach ($ids as $childId) {
4765  if (!‪MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
4766  continue;
4767  }
4768  $item = $elementsOriginal[$childId];
4769  if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4770  continue;
4771  }
4772  $item['id'] = $this->localize($item['table'], $item['id'], $language);
4773  if (is_int($item['id'])) {
4774  $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4775  }
4776  $dbAnalysisCurrent->itemArray[] = $item;
4777  }
4778  }
4779  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4780  $value = implode(',', $dbAnalysisCurrent->getValueArray());
4781  $this->registerDBList[$table][$id][$field] = $value;
4782  // Remove child records (if synchronization requested it):
4783  if (is_array($removeArray) && !empty($removeArray)) {
4784  $tce = GeneralUtility::makeInstance(self::class, $this->referenceIndexUpdater);
4785  $tce->enableLogging = $this->enableLogging;
4786  $tce->start([], $removeArray, $this->BE_USER);
4787  $tce->process_cmdmap();
4788  unset($tce);
4789  }
4790  $updateFields = [];
4791  // Handle, reorder and store relations:
4792  if ($inlineSubType === 'list') {
4793  $updateFields = [$field => $value];
4794  } elseif ($inlineSubType === 'field') {
4795  $dbAnalysisCurrent->writeForeignField($config, $id);
4796  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4797  } elseif ($inlineSubType === 'mm') {
4798  $dbAnalysisCurrent->writeMM($config['MM'], $id);
4799  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4800  }
4801  // Update field referencing to child records of localized parent record:
4802  if (!empty($updateFields)) {
4803  $this->updateDB($table, $id, $updateFields);
4804  }
4805  }
4806 
4815  protected function isRecordLocalized(string $table, int $uid, int $language): bool
4816  {
4817  $row = BackendUtility::getRecordWSOL($table, $uid);
4818  $localizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'pid=' . (int)$row['pid']);
4819  return !empty($localizations);
4820  }
4821 
4822  /*********************************************
4823  *
4824  * Cmd: delete
4825  *
4826  ********************************************/
4834  public function deleteAction($table, $id)
4835  {
4836  $recordToDelete = BackendUtility::getRecord($table, $id);
4837 
4838  if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
4839  // When dealing with a workspace record, use discard.
4840  $this->discard($table, null, $recordToDelete);
4841  return;
4842  }
4843 
4844  // Record asked to be deleted was found:
4845  if (is_array($recordToDelete)) {
4846  $recordWasDeleted = false;
4847  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
4848  $hookObj = GeneralUtility::makeInstance($className);
4849  if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
4850  $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
4851  }
4852  }
4853  // Delete the record if a hook hasn't deleted it yet
4854  if (!$recordWasDeleted) {
4855  $this->deleteEl($table, $id);
4856  }
4857  }
4858  }
4859 
4870  public function deleteEl($table, $uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4871  {
4872  if ($table === 'pages') {
4873  $this->deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
4874  } else {
4875  $this->discardLocalizedWorkspaceVersionsOfRecord((string)$table, (int)$uid);
4876  $this->discardWorkspaceVersionsOfRecord($table, $uid);
4877  $this->deleteRecord($table, $uid, $noRecordCheck, $forceHardDelete);
4878  }
4879  }
4880 
4887  protected function discardLocalizedWorkspaceVersionsOfRecord(string $table, int $uid): void
4888  {
4889  if (!BackendUtility::isTableLocalizable($table)
4890  || !BackendUtility::isTableWorkspaceEnabled($table)
4891  || !$this->BE_USER->recordEditAccessInternals($table, $uid)
4892  ) {
4893  return;
4894  }
4895  $liveRecord = BackendUtility::getRecord($table, $uid);
4896  if ((int)($liveRecord['sys_language_uid'] ?? 0) !== 0 || (int)($liveRecord['t3ver_wsid'] ?? 0) !== 0) {
4897  // Don't do anything if we're not deleting a live record in default language
4898  return;
4899  }
4900  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
4901  $localizationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
4902  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4903  $queryBuilder->getRestrictions()->removeAll();
4904  $queryBuilder = $queryBuilder->select('*')->from($table)
4905  ->where(
4906  // workspace elements
4907  $queryBuilder->expr()->gt('t3ver_wsid', $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
4908  // with sys_language_uid > 0
4909  $queryBuilder->expr()->gt($languageField, $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
4910  // in state 'new'
4911  $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(‪VersionState::NEW_PLACEHOLDER, ‪Connection::PARAM_INT)),
4912  // with "l10n_parent" set to uid of live record
4913  $queryBuilder->expr()->eq($localizationParentFieldName, $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT))
4914  );
4915  $result = $queryBuilder->executeQuery();
4916  while ($row = $result->fetchAssociative()) {
4917  // BE user must be put into this workspace temporarily so stuff like refindex updating
4918  // is properly registered for this workspace when discarding records in there.
4919  $currentUserWorkspace = $this->BE_USER->workspace;
4920  $this->BE_USER->workspace = (int)$row['t3ver_wsid'];
4921  $this->discard($table, null, $row);
4922  // Switch user back to original workspace
4923  $this->BE_USER->workspace = $currentUserWorkspace;
4924  }
4925  }
4926 
4935  protected function discardWorkspaceVersionsOfRecord($table, $uid): void
4936  {
4937  $versions = BackendUtility::selectVersionsOfRecord($table, $uid, '*', null);
4938  if ($versions === null) {
4939  // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
4940  return;
4941  }
4942  foreach ($versions as $record) {
4943  if ($record['_CURRENT_VERSION'] ?? false) {
4944  // The live record is included in the result from selectVersionsOfRecord()
4945  // and marked as '_CURRENT_VERSION'. Skip this one.
4946  continue;
4947  }
4948  // BE user must be put into this workspace temporarily so stuff like refindex updating
4949  // is properly registered for this workspace when discarding records in there.
4950  $currentUserWorkspace = $this->BE_USER->workspace;
4951  $this->BE_USER->workspace = (int)$record['t3ver_wsid'];
4952  $this->discard($table, null, $record);
4953  // Switch user back to original workspace
4954  $this->BE_USER->workspace = $currentUserWorkspace;
4955  }
4956  }
4957 
4970  public function deleteRecord($table, $uid, $noRecordCheck = false, $forceHardDelete = false)
4971  {
4972  $currentUserWorkspace = (int)$this->BE_USER->workspace;
4973  $uid = (int)$uid;
4974  if (!‪$GLOBALS['TCA'][$table] || !$uid) {
4975  $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions. [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
4976  return;
4977  }
4978  // Skip processing already deleted records
4979  if (!$forceHardDelete && $this->hasDeletedRecord($table, $uid)) {
4980  return;
4981  }
4982 
4983  // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
4984  $fullLanguageAccessCheck = true;
4985  if ($table === 'pages') {
4986  // If this is a page translation, the full language access check should not be done
4987  $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
4988  if ($defaultLanguagePageId !== $uid) {
4989  $fullLanguageAccessCheck = false;
4990  }
4991  }
4992  $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, $forceHardDelete, $fullLanguageAccessCheck);
4993  if (!$hasEditAccess) {
4994  $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
4995  return;
4996  }
4997  if ($table === 'pages') {
4998  $perms = ‪Permission::PAGE_DELETE;
4999  } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5000  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5001  $perms = ‪Permission::PAGE_EDIT;
5002  } else {
5003  $perms = ‪Permission::CONTENT_EDIT;
5004  }
5005  if (!$noRecordCheck && !$this->doesRecordExist($table, $uid, $perms)) {
5006  return;
5007  }
5008 
5009  $recordToDelete = [];
5010  $recordWorkspaceId = 0;
5011  if (BackendUtility::isTableWorkspaceEnabled($table)) {
5012  $recordToDelete = BackendUtility::getRecord($table, $uid);
5013  $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0);
5014  }
5015 
5016  // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
5017  [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
5018  $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
5019  $deleteField = ‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? false;
5020  $databaseErrorMessage = '';
5021  if ($recordWorkspaceId > 0) {
5022  // If this is a workspace record, use discard
5023  $this->BE_USER->workspace = $recordWorkspaceId;
5024  $this->discard($table, null, $recordToDelete);
5025  // Switch user back to original workspace
5026  $this->BE_USER->workspace = $currentUserWorkspace;
5027  } elseif ($deleteField && !$forceHardDelete) {
5028  $updateFields = [
5029  $deleteField => 1,
5030  ];
5031  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
5032  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
5033  }
5034  // before deleting this record, check for child records or references
5035  $this->deleteRecord_procFields($table, $uid);
5036  try {
5037  // Delete all l10n records as well
5038  $this->deletedRecords[$table][] = (int)$uid;
5039  $this->deleteL10nOverlayRecords($table, $uid);
5040  GeneralUtility::makeInstance(ConnectionPool::class)
5041  ->getConnectionForTable($table)
5042  ->update($table, $updateFields, ['uid' => (int)$uid]);
5043  } catch (DBALException $e) {
5044  $databaseErrorMessage = $e->getPrevious()->getMessage();
5045  }
5046  } else {
5047  // Delete the hard way...:
5048  try {
5049  $this->hardDeleteSingleRecord($table, (int)$uid);
5050  $this->deletedRecords[$table][] = (int)$uid;
5051  $this->deleteL10nOverlayRecords($table, $uid);
5052  } catch (DBALException $e) {
5053  $databaseErrorMessage = $e->getPrevious()->getMessage();
5054  }
5055  }
5056  if ($this->enableLogging) {
5057  $state = SystemLogDatabaseAction::DELETE;
5058  if ($databaseErrorMessage === '') {
5059  if ($forceHardDelete) {
5060  $message = 'Record \'%s\' (%s) was deleted unrecoverable from page \'%s\' (%s)';
5061  } else {
5062  $message = 'Record \'%s\' (%s) was deleted from page \'%s\' (%s)';
5063  }
5064  $propArr = $this->getRecordProperties($table, $uid);
5065  $pagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
5066 
5067  $this->log($table, $uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
5068  $propArr['header'],
5069  $table . ':' . $uid,
5070  $pagePropArr['header'],
5071  $propArr['pid'],
5072  ], $propArr['event_pid']);
5073  } else {
5074  $this->log($table, $uid, $state, 0, SystemLogErrorClassification::SYSTEM_ERROR, $databaseErrorMessage);
5075  }
5076  }
5078  // Add history entry
5079  $this->getRecordHistoryStore()->deleteRecord($table, $uid, $this->correlationId);
5080 
5081  // Update reference index with table/uid on left side (recuid)
5082  $this->updateRefIndex($table, $uid);
5083  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
5084  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $currentUserWorkspace);
5085  }
5086 
5096  public function deletePages($uid, $force = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5097  {
5098  $uid = (int)$uid;
5099  if ($uid === 0) {
5100  $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled.', -1, [], 0);
5101  return;
5102  }
5103  // Getting list of pages to delete:
5104  if ($force) {
5105  // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
5106  $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true);
5107  $res = ‪GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
5108  } else {
5109  $res = $this->canDeletePage($uid);
5110  }
5111  // Perform deletion if not error:
5112  if (is_array($res)) {
5113  foreach ($res as $deleteId) {
5114  $this->deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
5115  }
5116  } else {
5117  $this->log(
5118  'pages',
5119  $uid,
5120  SystemLogDatabaseAction::DELETE,
5121  0,
5122  SystemLogErrorClassification::SYSTEM_ERROR,
5123  $res,
5124  -1,
5125  [$res],
5126  );
5127  }
5128  }
5129 
5139  public function deleteSpecificPage($uid, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5140  {
5141  $uid = (int)$uid;
5142  if (!$uid) {
5143  // Early void return on invalid uid
5144  return;
5145  }
5146  $forceHardDelete = (bool)$forceHardDelete;
5147 
5148  // Delete either a default language page or a translated page
5149  $pageIdInDefaultLanguage = $this->getDefaultLanguagePageId($uid);
5150  $isPageTranslation = false;
5151  $pageLanguageId = 0;
5152  if ($pageIdInDefaultLanguage !== $uid) {
5153  // For translated pages, translated records in other tables (eg. tt_content) for the
5154  // to-delete translated page have their pid field set to the uid of the default language record,
5155  // NOT the uid of the translated page record.
5156  // If a translated page is deleted, only translations of records in other tables of this language
5157  // should be deleted. The code checks if the to-delete page is a translated page and
5158  // adapts the query for other tables to use the uid of the default language page as pid together
5159  // with the language id of the translated page.
5160  $isPageTranslation = true;
5161  $pageLanguageId = $this->pageInfo($uid, ‪$GLOBALS['TCA']['pages']['ctrl']['languageField']);
5162  }
5163 
5164  if ($deleteRecordsOnPage) {
5165  $tableNames = $this->compileAdminTables();
5166  foreach ($tableNames as $table) {
5167  if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
5168  // Skip pages table. And skip table if not translatable, but a translated page is deleted
5169  continue;
5170  }
5171 
5172  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5173  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5174  $queryBuilder
5175  ->select('uid')
5176  ->from($table)
5177  // order by uid is needed here to process possible live records first - overlays always
5178  // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
5179  // leading to hard to debug issues. This is especially relevant for the
5180  // discardWorkspaceVersionsOfRecord() call below.
5181  ->addOrderBy('uid');
5182 
5183  if ($isPageTranslation) {
5184  // Only delete records in the specified language
5185  $queryBuilder->where(
5186  $queryBuilder->expr()->eq(
5187  'pid',
5188  $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, ‪Connection::PARAM_INT)
5189  ),
5190  $queryBuilder->expr()->eq(
5191  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
5192  $queryBuilder->createNamedParameter($pageLanguageId, ‪Connection::PARAM_INT)
5193  )
5194  );
5195  } else {
5196  // Delete all records on this page
5197  $queryBuilder->where(
5198  $queryBuilder->expr()->eq(
5199  'pid',
5200  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT)
5201  )
5202  );
5203  }
5204 
5205  $currentUserWorkspace = (int)$this->BE_USER->workspace;
5206  ‪if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5207  // If we are in a workspace, make sure only records of this workspace are deleted.
5208  $queryBuilder->andWhere(
5209  $queryBuilder->expr()->eq(
5210  't3ver_wsid',
5211  $queryBuilder->createNamedParameter($currentUserWorkspace, ‪Connection::PARAM_INT)
5212  )
5213  );
5214  }
5215 
5216  $statement = $queryBuilder->executeQuery();
5217 
5218  while ($row = $statement->fetchAssociative()) {
5219  // Delete any further workspace overlays of the record in question, then delete the record.
5220  $this->discardWorkspaceVersionsOfRecord($table, $row['uid']);
5221  $this->deleteRecord($table, $row['uid'], true, $forceHardDelete);
5222  }
5223  }
5224  }
5225 
5226  // Delete any further workspace overlays of the record in question, then delete the record.
5227  $this->discardWorkspaceVersionsOfRecord('pages', $uid);
5228  $this->deleteRecord('pages', $uid, true, $forceHardDelete);
5229  }
5230 
5238  public function canDeletePage($uid)
5239  {
5240  $uid = (int)$uid;
5241  $isTranslatedPage = null;
5242 
5243  // If we may at all delete this page
5244  // If this is a page translation, do the check against the perms_* of the default page
5245  // Because it is currently only deleting the translation
5246  $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
5247  if ($defaultLanguagePageId !== $uid) {
5248  if ($this->doesRecordExist('pages', (int)$defaultLanguagePageId, ‪Permission::PAGE_DELETE)) {
5249  $isTranslatedPage = true;
5250  } else {
5251  return 'Attempt to delete page without permissions';
5252  }
5253  } elseif (!$this->doesRecordExist('pages', $uid, ‪Permission::PAGE_DELETE)) {
5254  return 'Attempt to delete page without permissions';
5255  }
5256 
5257  $pageIdsInBranch = $this->doesBranchExist('', $uid, ‪Permission::PAGE_DELETE, true);
5258 
5259  if ($pageIdsInBranch === -1) {
5260  return 'Attempt to delete pages in branch without permissions';
5261  }
5262 
5263  $pagesInBranch = ‪GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
5264 
5265  if ($disallowedTables = $this->checkForRecordsFromDisallowedTables($pagesInBranch)) {
5266  return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
5267  }
5268 
5269  foreach ($pagesInBranch as $pageInBranch) {
5270  if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
5271  return 'Attempt to delete page which has prohibited localizations.';
5272  }
5273  }
5274  return $pagesInBranch;
5275  }
5276 
5285  public function cannotDeleteRecord($table, $id)
5286  {
5287  if ($table === 'pages') {
5288  $res = $this->canDeletePage($id);
5289  return is_array($res) ? false : $res;
5290  }
5291  if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5292  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5293  $perms = ‪Permission::PAGE_EDIT;
5294  } else {
5295  $perms = ‪Permission::CONTENT_EDIT;
5296  }
5297  return $this->doesRecordExist($table, $id, $perms) ? false : 'No permission to delete record';
5298  }
5299 
5309  public function deleteRecord_procFields($table, $uid)
5310  {
5311  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
5312  $row = BackendUtility::getRecord($table, $uid, '*', '', false);
5313  if (empty($row)) {
5314  return;
5315  }
5316  foreach ($row as $field => $value) {
5317  $this->deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf[$field]['config'] ?? []);
5318  }
5319  }
5320 
5332  public function deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf): void
5333  {
5334  if (!isset($conf['type'])) {
5335  return;
5336  }
5337  if ($conf['type'] === 'inline') {
5338  $foreign_table = $conf['foreign_table'];
5339  if ($foreign_table) {
5340  $inlineType = $this->getInlineFieldType($conf);
5341  if ($inlineType === 'list' || $inlineType === 'field') {
5342  $dbAnalysis = $this->createRelationHandlerInstance();
5343  $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
5344  $dbAnalysis->undeleteRecord = true;
5345 
5346  $enableCascadingDelete = true;
5347  // non type save comparison is intended!
5348  if (isset($conf['behaviour']['enableCascadingDelete']) && $conf['behaviour']['enableCascadingDelete'] == false) {
5349  $enableCascadingDelete = false;
5350  }
5351 
5352  // Walk through the items and remove them
5353  foreach ($dbAnalysis->itemArray as $v) {
5354  if ($enableCascadingDelete) {
5355  $this->deleteAction($v['table'], $v['id']);
5356  }
5357  }
5358  }
5359  }
5360  } elseif ($this->isReferenceField($conf)) {
5361  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5362  $dbAnalysis = $this->createRelationHandlerInstance();
5363  $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $uid, $table, $conf);
5364  foreach ($dbAnalysis->itemArray as $v) {
5365  $this->updateRefIndex($v['table'], $v['id']);
5366  }
5367  }
5368  }
5369 
5377  public function deleteL10nOverlayRecords($table, $uid)
5378  {
5379  // Check whether table can be localized
5380  if (!BackendUtility::isTableLocalizable($table)) {
5381  return;
5382  }
5383 
5384  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5385  $queryBuilder->getRestrictions()
5386  ->removeAll()
5387  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5388  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5389 
5390  $queryBuilder->select('*')
5391  ->from($table)
5392  ->where(
5393  $queryBuilder->expr()->eq(
5394  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5395  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT)
5396  )
5397  );
5398 
5399  $result = $queryBuilder->executeQuery();
5400  while ($record = $result->fetchAssociative()) {
5401  // Ignore workspace delete placeholders. Those records have been marked for
5402  // deletion before - deleting them again in a workspace would revert that state.
5403  if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5404  BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace);
5405  if (‪VersionState::cast($record['t3ver_state'])->equals(‪VersionState::DELETE_PLACEHOLDER)) {
5406  continue;
5407  }
5408  }
5409  $this->deleteAction($table, (int)($record['t3ver_oid'] ?? 0) > 0 ? (int)$record['t3ver_oid'] : (int)$record['uid']);
5410  }
5411  }
5412 
5413  /*********************************************
5414  *
5415  * Cmd: undelete / restore
5416  *
5417  ********************************************/
5418 
5429  protected function undeleteRecord(string $table, int $uid): void
5430  {
5431  $record = BackendUtility::getRecord($table, $uid, '*', '', false);
5432  $deleteField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5433  $timestampField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5434 
5435  if ($record === null
5436  || $deleteField === ''
5437  || !isset($record[$deleteField])
5438  || (bool)$record[$deleteField] === false
5439  || ($timestampField !== '' && !isset($record[$timestampField]))
5440  || (int)$this->BE_USER->workspace > 0
5441  || (BackendUtility::isTableWorkspaceEnabled($table) && (int)($record['t3ver_wsid'] ?? 0) > 0)
5442  ) {
5443  // Return early and silently, if:
5444  // * Record not found
5445  // * Table is not soft-delete aware
5446  // * Record does not have deleted field - db analyzer not up-to-date?
5447  // * Record is not deleted - may eventually happen via recursion with self referencing records?
5448  // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5449  // * User is in a workspace - does not make sense
5450  // * Record is in a workspace - workspace records are not soft-delete aware
5451  return;
5452  }
5453 
5454  $recordPid = (int)($record['pid'] ?? 0);
5455  if ($recordPid > 0) {
5456  // Record is not on root level. Parent page record must exist and must not be deleted itself.
5457  $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5458  if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5459  $this->log(
5460  $table,
5461  $uid,
5462  SystemLogDatabaseAction::DELETE,
5463  0,
5464  SystemLogErrorClassification::USER_ERROR,
5465  sprintf('Record "%s:%s" can\'t be restored: The page:%s containing it does not exist or is soft-deleted.', $table, $uid, $recordPid),
5466  0,
5467  [],
5468  $recordPid
5469  );
5470  return;
5471  }
5472  }
5473 
5474  // @todo: When restoring a not-default language record, it should be verified the default language
5475  // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5476 
5477  if (!$this->BE_USER->recordEditAccessInternals($table, $record, false, true)) {
5478  // User misses access permissions to record
5479  $this->log(
5480  $table,
5481  $uid,
5482  SystemLogDatabaseAction::DELETE,
5483  0,
5484  SystemLogErrorClassification::USER_ERROR,
5485  sprintf('Record "%s:%s" can\'t be restored: Insufficient user permissions.', $table, $uid),
5486  0,
5487  [],
5488  $recordPid
5489  );
5490  return;
5491  }
5492 
5493  // Restore referenced child records
5494  $this->undeleteRecordRelations($table, $uid, $record);
5495 
5496  // Restore record
5497  $updateFields[$deleteField] = 0;
5498  if ($timestampField !== '') {
5499  $updateFields[$timestampField] = ‪$GLOBALS['EXEC_TIME'];
5500  }
5501  GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table)
5502  ->update(
5503  $table,
5504  $updateFields,
5505  ['uid' => $uid]
5506  );
5507 
5508  if ($this->enableLogging) {
5509  $this->log(
5510  $table,
5511  $uid,
5512  SystemLogDatabaseAction::INSERT,
5513  0,
5514  SystemLogErrorClassification::MESSAGE,
5515  sprintf('Record "%s:%s" was restored on page:%s', $table, $uid, $recordPid),
5516  0,
5517  [],
5518  $recordPid
5519  );
5520  }
5521 
5522  // Register cache clearing of page, or parent page if a page is restored.
5523  $this->registerRecordIdForPageCacheClearing($table, $uid, $recordPid);
5524  // Add history entry
5525  $this->getRecordHistoryStore()->undeleteRecord($table, $uid, $this->correlationId);
5526  // Update reference index with table/uid on left side (recuid)
5527  $this->updateRefIndex($table, $uid);
5528  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5529  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, 0);
5530  }
5540  protected function undeleteRecordRelations(string $table, int $uid, array $record): void
5541  {
5542  foreach ($record as $fieldName => $value) {
5543  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5544  $fieldType = (string)($fieldConfig['type'] ?? '');
5545  if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5546  continue;
5547  }
5548  $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5549  if ($fieldType === 'inline') {
5550  // @todo: Inline MM not handled here, and what about group / select?
5551  if ($foreignTable === ''
5552  || !in_array($this->getInlineFieldType($fieldConfig), ['list', 'field'], true)
5553  ) {
5554  continue;
5555  }
5556  $relationHandler = $this->createRelationHandlerInstance();
5557  $relationHandler->start($value, $foreignTable, '', $uid, $table, $fieldConfig);
5558  $relationHandler->undeleteRecord = true;
5559  foreach ($relationHandler->itemArray as $reference) {
5560  $this->undeleteRecord($reference['table'], (int)$reference['id']);
5561  }
5562  } elseif ($this->isReferenceField($fieldConfig)) {
5563  $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5564  $relationHandler = $this->createRelationHandlerInstance();
5565  $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', $uid, $table, $fieldConfig);
5566  foreach ($relationHandler->itemArray as $reference) {
5567  // @todo: Unsure if this is ok / enough. Needs coverage.
5568  $this->updateRefIndex($reference['table'], $reference['id']);
5569  }
5570  }
5571  }
5572  }
5573 
5574  /*********************************************
5575  *
5576  * Cmd: Workspace discard & flush
5577  *
5578  ********************************************/
5579 
5593  public function discard(string $table, ?int $uid, ?array $record = null): void
5594  {
5595  if ($uid === null && $record === null) {
5596  throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5597  }
5598 
5599  // Fetch record we are dealing with if not given
5600  if ($record === null) {
5601  $record = BackendUtility::getRecord($table, (int)$uid);
5602  }
5603  if (!is_array($record)) {
5604  return;
5605  }
5606  $uid = (int)$record['uid'];
5607 
5608  // Call hook and return if hook took care of the element
5609  $recordWasDiscarded = false;
5610  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5611  $hookObj = GeneralUtility::makeInstance($className);
5612  if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5613  $hookObj->processCmdmap_discardAction($table, $uid, $record, $recordWasDiscarded);
5614  }
5615  }
5616 
5617  $userWorkspace = (int)$this->BE_USER->workspace;
5618  ‪if ($recordWasDiscarded
5619  || $userWorkspace === 0
5620  || !BackendUtility::isTableWorkspaceEnabled($table)
5621  || $this->hasDeletedRecord($table, $uid)
5622  ) {
5623  return;
5624  }
5625 
5626  // Gather versioned record
5627  if ((int)$record['t3ver_wsid'] === 0) {
5628  $record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, $uid);
5629  }
5630  if (!is_array($record)) {
5631  return;
5632  }
5633  $versionRecord = $record;
5634 
5635  // User access checks
5636  if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5637  $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']]);
5638  return;
5639  }
5640  if ($errorCode = $this->workspaceCannotEditOfflineVersion($table, $versionRecord)) {
5641  $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]);
5642  return;
5643  }
5644  if (!$this->checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5645  $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']]);
5646  return;
5647  }
5648  $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5649  if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5650  $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']]);
5651  return;
5652  }
5653 
5654  // Perform discard operations
5655  $versionState = ‪VersionState::cast($versionRecord['t3ver_state']);
5656  if ($table === 'pages' && $versionState->equals(‪VersionState::NEW_PLACEHOLDER)) {
5657  // When discarding a new page, there can be new sub pages and new records.
5658  // Those need to be discarded, otherwise they'd end up as records without parent page.
5659  $this->discardSubPagesAndRecordsOnPage($versionRecord);
5660  }
5661 
5662  $this->discardLocalizationOverlayRecords($table, $versionRecord);
5663  $this->discardRecordRelations($table, $versionRecord);
5664  $this->discardCsvReferencesToRecord($table, $versionRecord);
5665  $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5666  $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5667  $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5668  $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5669  $this->log(
5670  $table,
5671  (int)$versionRecord['uid'],
5672  SystemLogDatabaseAction::DELETE,
5673  0,
5674  SystemLogErrorClassification::MESSAGE,
5675  'Record {table}:{uid} was deleted unrecoverable from page {pageId}',
5676  0,
5677  ['table' => $table, 'uid' => $versionRecord['uid'], 'pageId' => $versionRecord['pid']],
5678  (int)$versionRecord['pid']
5679  );
5680  }
5681 
5688  protected function discardSubPagesAndRecordsOnPage(array $page): void
5689  {
5690  $isLocalizedPage = false;
5691  $sysLanguageId = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['languageField']];
5692  $versionState = ‪VersionState::cast($page['t3ver_state']);
5693  if ($sysLanguageId > 0) {
5694  // New or moved localized page.
5695  // Discard records on this page localization, but no sub pages.
5696  // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
5697  // @todo: Discard other page translations that inherit from this?! (l10n_source field)
5698  $isLocalizedPage = true;
5699  $pid = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
5700  } elseif ($versionState->equals(‪VersionState::NEW_PLACEHOLDER)) {
5701  // New default language page.
5702  // Discard any sub pages and all other records of this page, including any page localizations.
5703  // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
5704  // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
5705  $pid = (int)$page['uid'];
5706  } else {
5707  // Moved default language page.
5708  // Discard any sub pages and all other records of this page, including any page localizations.
5709  $pid = (int)$page['t3ver_oid'];
5710  }
5711  $tables = $this->compileAdminTables();
5712  foreach ($tables as $table) {
5713  if (($isLocalizedPage && $table === 'pages')
5714  || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
5715  || !BackendUtility::isTableWorkspaceEnabled($table)
5716  ) {
5717  continue;
5718  }
5719  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5720  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5721  $queryBuilder->select('*')
5722  ->from($table)
5723  ->where(
5724  $queryBuilder->expr()->eq(
5725  'pid',
5726  $queryBuilder->createNamedParameter($pid, ‪Connection::PARAM_INT)
5727  ),
5728  $queryBuilder->expr()->eq(
5729  't3ver_wsid',
5730  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
5731  )
5732  );
5733  if ($isLocalizedPage) {
5734  // Add sys_language_uid = x restriction if discarding a localized page
5735  $queryBuilder->andWhere(
5736  $queryBuilder->expr()->eq(
5737  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
5738  $queryBuilder->createNamedParameter($sysLanguageId, ‪Connection::PARAM_INT)
5739  )
5740  );
5741  }
5742  $statement = $queryBuilder->executeQuery();
5743  while ($row = $statement->fetchAssociative()) {
5744  $this->discard($table, null, $row);
5745  }
5746  }
5747  }
5748 
5755  protected function discardRecordRelations(string $table, array $record): void
5756  {
5757  foreach ($record as $field => $value) {
5758  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
5759  if (!isset($fieldConfig['type'])) {
5760  continue;
5761  }
5762  if ($fieldConfig['type'] === 'inline') {
5763  $foreignTable = $fieldConfig['foreign_table'] ?? null;
5764  if (!$foreignTable
5765  || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
5766  && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
5767  ) {
5768  continue;
5769  }
5770  $inlineType = $this->getInlineFieldType($fieldConfig);
5771  if ($inlineType === 'list' || $inlineType === 'field') {
5772  $dbAnalysis = $this->createRelationHandlerInstance();
5773  $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)$record['uid'], $table, $fieldConfig);
5774  $dbAnalysis->undeleteRecord = true;
5775  foreach ($dbAnalysis->itemArray as $relationRecord) {
5776  $this->discard($relationRecord['table'], (int)$relationRecord['id']);
5777  }
5778  }
5779  } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
5780  $this->discardMmRelations($table, $fieldConfig, $record);
5781  }
5782  // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
5783  }
5784  }
5785 
5805  protected function discardCsvReferencesToRecord(string $table, array $record): void
5806  {
5807  // @see test workspaces Group Discard createContentAndCreateElementRelationAndDiscardElement
5808  // Records referencing the to-discard record.
5809  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex');
5810  $statement = $queryBuilder->select('tablename', 'recuid', 'field')
5811  ->from('sys_refindex')
5812  ->where(
5813  $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($record['t3ver_wsid'], ‪Connection::PARAM_INT)),
5814  $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
5815  $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($record['uid'], ‪Connection::PARAM_INT))
5816  )
5817  ->executeQuery();
5818  while ($row = $statement->fetchAssociative()) {
5819  // For each record referencing the to-discard record, see if it is a CSV group field definition.
5820  // If so, update that record to drop both the possible "uid" and "table_name_uid" variants from the list.
5821  $fieldTca = ‪$GLOBALS['TCA'][$row['tablename']]['columns'][$row['field']]['config'] ?? [];
5822  $groupAllowed = ‪GeneralUtility::trimExplode(',', $fieldTca['allowed'] ?? '', true);
5823  // @todo: "select" may be affected too, but it has no coverage to show this, yet?
5824  if (($fieldTca['type'] ?? '') === 'group'
5825  && empty($fieldTca['MM'])
5826  && (in_array('*', $groupAllowed, true) || in_array($table, $groupAllowed, true))
5827  ) {
5828  // Note it would be possible to a) update multiple records with only one DB call, and b) combine the
5829  // select and update to a single update query by doing the CSV manipulation as string function in sql.
5830  // That's harder to get right though and probably not *that* beneficial performance-wise since we're
5831  // most likely dealing with a very small number of records here anyways. Still, an optimization should
5832  // be considered after we drop TCA 'prepend_tname' handling and always rely only on "table_name_uid"
5833  // variant for CSV storage.
5834 
5835  // Get that record
5836  $recordReferencingDiscardedRecord = BackendUtility::getRecord($row['tablename'], $row['recuid'], $row['field']);
5837  if (!$recordReferencingDiscardedRecord) {
5838  continue;
5839  }
5840  // Drop "uid" and "table_name_uid" from list
5841  $listOfRelatedRecords = ‪GeneralUtility::trimExplode(',', $recordReferencingDiscardedRecord[$row['field']], true);
5842  $listOfRelatedRecordsWithoutDiscardedRecord = array_diff($listOfRelatedRecords, [$record['uid'], $table . '_' . $record['uid']]);
5843  if ($listOfRelatedRecords !== $listOfRelatedRecordsWithoutDiscardedRecord) {
5844  // Update record if list changed
5845  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($row['tablename']);
5846  $queryBuilder->update($row['tablename'])
5847  ->set($row['field'], implode(',', $listOfRelatedRecordsWithoutDiscardedRecord))
5848  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($row['recuid'], ‪Connection::PARAM_INT)))
5849  ->executeStatement();
5850  }
5851  }
5852  }
5853  }
5854 
5863  protected function discardMmRelations(string $table, array $fieldConfig, array $record): void
5864  {
5865  $recordUid = (int)$record['uid'];
5866  $mmTableName = $fieldConfig['MM'];
5867  // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
5868  $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
5869  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
5870  $queryBuilder->delete($mmTableName)->where(
5871  // uid_local = given uid OR uid_foreign = given uid
5872  $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, ‪Connection::PARAM_INT))
5873  );
5874  if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
5875  if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('runtimeDbQuotingOfTcaConfiguration')) {
5876  $queryBuilder->andWhere(
5877  ‪QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, ‪QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $fieldConfig['MM_table_where'])))
5878  );
5879  } else {
5880  $queryBuilder->andWhere(
5881  ‪QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, $fieldConfig['MM_table_where']))
5882  );
5883  }
5884  }
5885  $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
5886  foreach ($mmMatchFields as $fieldName => $fieldValue) {
5887  $queryBuilder->andWhere(
5888  $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue, ‪Connection::PARAM_STR))
5889  );
5890  }
5891  $queryBuilder->executeStatement();
5892 
5893  // refindex treatment for mm relation handling: If the to discard record is foreign side of an mm relation,
5894  // there may be other refindex rows that become obsolete when that record is discarded. See Modify
5895  // addCategoryRelation sys_category-29->tt_content-298. We thus register an update for references
5896  // to this item (right side - ref_table, ref_uid) in reference index updater to catch these.
5897  if ($relationUidFieldName === 'uid_foreign') {
5898  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $recordUid, (int)$record['t3ver_wsid']);
5899  }
5900  }
5901 
5908  protected function discardLocalizationOverlayRecords(string $table, array $record): void
5909  {
5910  if (!BackendUtility::isTableLocalizable($table)) {
5911  return;
5912  }
5913  $uid = (int)$record['uid'];
5914  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5915  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5916  $statement = $queryBuilder->select('*')
5917  ->from($table)
5918  ->where(
5919  $queryBuilder->expr()->eq(
5920  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5921  $queryBuilder->createNamedParameter($uid, ‪Connection::PARAM_INT)
5922  ),
5923  $queryBuilder->expr()->eq(
5924  't3ver_wsid',
5925  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
5926  )
5927  )
5928  ->executeQuery();
5929  while ($record = $statement->fetchAssociative()) {
5930  $this->discard($table, null, $record);
5931  }
5932  }
5933 
5934  /*********************************************
5935  *
5936  * Cmd: Versioning
5937  *
5938  ********************************************/
5951  public function versionizeRecord($table, $id, $label, $delete = false)
5952  {
5953  $id = (int)$id;
5954  // Stop any actions if the record is marked to be deleted:
5955  // (this can occur if IRRE elements are versionized and child elements are removed)
5956  if ($this->isElementToBeDeleted($table, $id)) {
5957  return null;
5958  }
5959  if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
5960  $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]);
5961  return null;
5962  }
5963 
5964  // Fetch record with permission check
5965  $row = $this->recordInfoWithPermissionCheck($table, $id, ‪Permission::PAGE_SHOW);
5966 
5967  // This checks if the record can be selected which is all that a copy action requires.
5968  if ($row === false) {
5969  $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]);
5970  return null;
5971  }
5972 
5973  // Record must be online record, otherwise we would create a version of a version
5974  if (($row['t3ver_oid'] ?? 0) > 0) {
5975  $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]);
5976  return null;
5977  }
5978 
5979  if ($delete && $errorCode = $this->cannotDeleteRecord($table, $id)) {
5980  $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]);
5981  return null;
5982  }
5983 
5984  // Set up the values to override when making a raw-copy:
5985  $overrideArray = [
5986  't3ver_oid' => $id,
5987  't3ver_wsid' => $this->BE_USER->workspace,
5988  't3ver_state' => (string)($delete ? new VersionState(‪VersionState::DELETE_PLACEHOLDER) : new VersionState(‪VersionState::DEFAULT_STATE)),
5989  't3ver_stage' => 0,
5990  ];
5991  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
5992  $overrideArray[‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
5993  }
5994  // Checking if the record already has a version in the current workspace of the backend user
5995  $versionRecord = ['uid' => null];
5996  if ($this->BE_USER->workspace !== 0) {
5997  // Look for version already in workspace:
5998  $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
5999  }
6000  // Create new version of the record and return the new uid
6001  if (empty($versionRecord['uid'])) {
6002  // Create raw-copy and return result:
6003  // The information of the label to be used for the workspace record
6004  // as well as the information whether the record shall be removed
6005  // must be forwarded (creating delete placeholders on a workspace are
6006  // done by copying the record and override several fields).
6007  $workspaceOptions = [
6008  'delete' => $delete,
6009  'label' => $label,
6010  ];
6011  return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
6012  }
6013  // Reuse the existing record and return its uid
6014  // (prior to TYPO3 CMS 6.2, an error was thrown here, which
6015  // did not make much sense since the information is available)
6016  return $versionRecord['uid'];
6017  }
6018 
6032  public function versionPublishManyToManyRelations(string $table, array $liveRecord, array $workspaceRecord, int $fromWorkspace): void
6033  {
6034  if (!is_array(‪$GLOBALS['TCA'][$table]['columns'])) {
6035  return;
6036  }
6037  $toDeleteRegistry = [];
6038  $toUpdateRegistry = [];
6039  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $dbFieldName => $dbFieldConfig) {
6040  if (empty($dbFieldConfig['config']['type'])) {
6041  continue;
6042  }
6043  if (!empty($dbFieldConfig['config']['MM']) && $this->isReferenceField($dbFieldConfig['config'])) {
6044  $toDeleteRegistry[] = $dbFieldConfig['config'];
6045  $toUpdateRegistry[] = $dbFieldConfig['config'];
6046  }
6047  if ($dbFieldConfig['config']['type'] === 'flex') {
6048  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6049  // Find possible mm tables attached to live record flex from data structures, mark as to delete
6050  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $liveRecord);
6051  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6052  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6053  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6054  if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6055  $toDeleteRegistry[] = $flexFieldDefinition['TCEforms']['config'];
6056  }
6057  }
6058  }
6059  // Find possible mm tables attached to workspace record flex from data structures, mark as to update uid
6060  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $workspaceRecord);
6061  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6062  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6063  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6064  if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6065  $toUpdateRegistry[] = $flexFieldDefinition['TCEforms']['config'];
6066  }
6067  }
6068  }
6069  }
6070  }
6071 
6072  // Delete mm table relations of live record
6073  foreach ($toDeleteRegistry as $config) {
6074  $uidFieldName = $this->mmRelationIsLocalSide($config) ? 'uid_local' : 'uid_foreign';
6075  $mmTableName = $config['MM'];
6076  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6077  $queryBuilder->delete($mmTableName);
6078  $queryBuilder->where($queryBuilder->expr()->eq(
6079  $uidFieldName,
6080  $queryBuilder->createNamedParameter((int)$liveRecord['uid'], ‪Connection::PARAM_INT)
6081  ));
6082  if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6083  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6084  'tablenames',
6085  $queryBuilder->createNamedParameter($table)
6086  ));
6087  }
6088  $queryBuilder->executeStatement();
6089  }
6090 
6091  // Update mm table relations of workspace record to uid of live record
6092  foreach ($toUpdateRegistry as $config) {
6093  $mmRelationIsLocalSide = $this->mmRelationIsLocalSide($config);
6094  $uidFieldName = $mmRelationIsLocalSide ? 'uid_local' : 'uid_foreign';
6095  $mmTableName = $config['MM'];
6096  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6097  $queryBuilder->update($mmTableName);
6098  $queryBuilder->set($uidFieldName, (int)$liveRecord['uid'], true, ‪Connection::PARAM_INT);
6099  $queryBuilder->where($queryBuilder->expr()->eq(
6100  $uidFieldName,
6101  $queryBuilder->createNamedParameter((int)$workspaceRecord['uid'], ‪Connection::PARAM_INT)
6102  ));
6103  if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6104  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6105  'tablenames',
6106  $queryBuilder->createNamedParameter($table)
6107  ));
6108  }
6109  $queryBuilder->executeStatement();
6110 
6111  if (!$mmRelationIsLocalSide) {
6112  // refindex treatment for mm relation handling: If the to publish record is foreign side of an mm relation, we need
6113  // to instruct refindex updater to update all local side references for the live record the current workspace record
6114  // has on foreign side. See ManyToMany Publish addCategoryRelation, this will create the sys_category-31->tt_content-297 entry.
6115  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$workspaceRecord['uid'], $fromWorkspace, 0);
6116  // Similar, when in mm foreign side and relations are deleted in live during publish, other relations pointing to the
6117  // same local side record may need updates due to different sorting, and the former refindex entry of the live record
6118  // needs updates. See ManyToMany Publish deleteCategoryRelation scenario.
6119  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$liveRecord['uid'], 0);
6120  }
6121  }
6122  }
6123 
6128  private function flexFieldDefinitionIsMmRelation(array $flexFieldDefinition): bool
6129  {
6130  return ($flexFieldDefinition['type'] ?? '') !== 'array' // is a field, not a section
6131  && is_array($flexFieldDefinition['TCEforms']['config'] ?? false) // config array exists
6132  && $this->isReferenceField($flexFieldDefinition['TCEforms']['config']) // select, group, category
6133  && !empty($flexFieldDefinition['TCEforms']['config']['MM']); // MM exists
6134  }
6135 
6142  private function mmQueryShouldUseTablenamesColumn(array $config): bool
6143  {
6144  if ($this->mmRelationIsLocalSide($config)) {
6145  return false;
6146  }
6147  if ($config['type'] === 'group' && !empty($config['prepend_tname'])) {
6148  // prepend_tname in MM on foreign side forces 'tablenames' column
6149  // @todo: See if we can get rid of prepend_tname in MM altogether?
6150  return true;
6151  }
6152  if ($config['type'] === 'group' && is_string($config['allowed'] ?? false)
6153  && (str_contains($config['allowed'], ',') || $config['allowed'] === '*')
6154  ) {
6155  // 'allowed' with *, or more than one table
6156  // @todo: Neither '*' nor 'multiple tables' make sense for MM on foreign side.
6157  // There is a hint in the docs about this, too. Sanitize in TCA bootstrap?!
6158  return true;
6159  }
6160  $localSideTableName = $config['type'] === 'group' ? $config['allowed'] ?? '' : $config['foreign_table'] ?? '';
6161  $localSideFieldName = $config['MM_opposite_field'] ?? '';
6162  $localSideAllowed = ‪$GLOBALS['TCA'][$localSideTableName]['columns'][$localSideFieldName]['config']['allowed'] ?? '';
6163  // Local side with 'allowed' = '*' or multiple tables forces 'tablenames' column
6164  return $localSideAllowed === '*' || str_contains($localSideAllowed, ',');
6165  }
6166 
6171  private function mmRelationIsLocalSide(array $config): bool
6172  {
6173  return empty($config['MM_opposite_field']);
6174  }
6175 
6176  /*********************************************
6177  *
6178  * Cmd: Helper functions
6179  *
6180  ********************************************/
6181 
6187  protected function getLocalTCE()
6188  {
6189  $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
6190  $copyTCE->copyTree = $this->copyTree;
6191  $copyTCE->enableLogging = $this->enableLogging;
6192  // Transformations should NOT be carried out during copy
6193  $copyTCE->dontProcessTransformations = true;
6194  // make sure the isImporting flag is transferred, so all hooks know if
6195  // the current process is an import process
6196  $copyTCE->isImporting = $this->isImporting;
6197  $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
6198  $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
6199  return $copyTCE;
6200  }
6201 
6206  public function remapListedDBRecords()
6207  {
6208  if (!empty($this->registerDBList)) {
6209  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6210  foreach ($this->registerDBList as $table => $records) {
6211  foreach ($records as $uid => ‪$fields) {
6212  $newData = [];
6213  $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
6214  $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
6215  foreach (‪$fields as $fieldName => $value) {
6216  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
6217  switch ($conf['type']) {
6218  case 'group':
6219  case 'select':
6220  case 'category':
6221  $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6222  if (is_array($vArray)) {
6223  $newData[$fieldName] = implode(',', $vArray);
6224  }
6225  break;
6226  case 'flex':
6227  if ($value === 'FlexForm_reference') {
6228  // This will fetch the new row for the element
6229  $origRecordRow = $this->recordInfo($table, $theUidToUpdate, '*');
6230  if (is_array($origRecordRow)) {
6231  BackendUtility::workspaceOL($table, $origRecordRow);
6232  // Get current data structure and value array:
6233  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
6234  ['config' => $conf],
6235  $table,
6236  $fieldName,
6237  $origRecordRow
6238  );
6239  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6240  $currentValueArray = ‪GeneralUtility::xml2array($origRecordRow[$fieldName]);
6241  // Do recursive processing of the XML data:
6242  $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
6243  // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
6244  if (is_array($currentValueArray['data'])) {
6245  $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray, true);
6246  }
6247  }
6248  }
6249  break;
6250  case 'inline':
6251  $this->remapListedDBRecords_procInline($conf, $value, $uid, $table);
6252  break;
6253  default:
6254  $this->logger->debug('Field type should not appear here: {type}', ['type' => $conf['type']]);
6255  }
6256  }
6257  // If any fields were changed, those fields are updated!
6258  if (!empty($newData)) {
6259  $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
6260  }
6261  }
6262  }
6263  }
6264  }
6265 
6277  public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue)
6278  {
6279  // Extract parameters:
6280  [$table, $uid, $field] = $pParams;
6281  // If references are set for this field, set flag so they can be corrected later:
6282  if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
6283  $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, $uid, $table);
6284  if (is_array($vArray)) {
6285  $dataValue = implode(',', $vArray);
6286  }
6287  }
6288  // Return
6289  return ['value' => $dataValue];
6290  }
6291 
6303  public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
6304  {
6305  // Initialize variables
6306  // Will be set TRUE if an upgrade should be done...
6307  $set = false;
6308  // Allowed tables for references.
6309  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
6310  // Table name to prepend the UID
6311  $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
6312  // Which tables that should possibly not be remapped
6313  $dontRemapTables = ‪GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
6314  // Convert value to list of references:
6315  $dbAnalysis = $this->createRelationHandlerInstance();
6316  $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
6317  $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
6318  // Traverse those references and map IDs:
6319  foreach ($dbAnalysis->itemArray as $k => $v) {
6320  $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
6321  if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
6322  $dbAnalysis->itemArray[$k]['id'] = $mapID;
6323  $set = true;
6324  }
6325  }
6326  if (!empty($conf['MM'])) {
6327  // Purge invalid items (live/version)
6328  $dbAnalysis->purgeItemArray();
6329  if ($dbAnalysis->isPurged()) {
6330  $set = true;
6331  }
6332 
6333  // If record has been versioned/copied in this process, handle invalid relations of the live record
6334  $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
6335  $originalId = 0;
6336  if (!empty($this->copyMappingArray_merged[$table])) {
6337  $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
6338  }
6339  if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
6340  $liveRelations = $this->createRelationHandlerInstance();
6341  $liveRelations->setWorkspaceId(0);
6342  $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
6343  // Purge invalid relations in the live workspace ("0")
6344  $liveRelations->purgeItemArray(0);
6345  if ($liveRelations->isPurged()) {
6346  $liveRelations->writeMM($conf['MM'], $liveId, $prependName);
6347  }
6348  }
6349  }
6350  // If a change has been done, set the new value(s)
6351  if ($set) {
6352  if ($conf['MM'] ?? false) {
6353  $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
6354  } else {
6355  return $dbAnalysis->getValueArray($prependName);
6356  }
6357  }
6358  return null;
6359  }
6360 
6370  public function remapListedDBRecords_procInline($conf, $value, $uid, $table)
6371  {
6372  $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
6373  if ($conf['foreign_table']) {
6374  $inlineType = $this->getInlineFieldType($conf);
6375  if ($inlineType === 'mm') {
6376  $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6377  } elseif ($inlineType !== false) {
6378  $dbAnalysis = $this->createRelationHandlerInstance();
6379  $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf);
6380 
6381  $updatePidForRecords = [];
6382  // Update values for specific versioned records
6383  foreach ($dbAnalysis->itemArray as &$item) {
6384  $updatePidForRecords[$item['table']][] = $item['id'];
6385  $versionedId = $this->getAutoVersionId($item['table'], $item['id']);
6386  if ($versionedId !== null) {
6387  $updatePidForRecords[$item['table']][] = $versionedId;
6388  $item['id'] = $versionedId;
6389  }
6390  }
6391 
6392  // Update child records if using pointer fields ('foreign_field'):
6393  if ($inlineType === 'field') {
6394  $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate);
6395  }
6396  $thePidToUpdate = null;
6397  // If the current field is set on a page record, update the pid of related child records:
6398  if ($table === 'pages') {
6399  $thePidToUpdate = $theUidToUpdate;
6400  } elseif (isset($this->registerDBPids[$table][$uid])) {
6401  $thePidToUpdate = $this->registerDBPids[$table][$uid];
6402  $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate] ?? null;
6403  }
6404 
6405  // Update child records if change to pid is required
6406  if ($thePidToUpdate && !empty($updatePidForRecords)) {
6407  // Ensure that only the default language page is used as PID
6408  $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate);
6409  // @todo: this can probably go away
6410  // ensure, only live page ids are used as 'pid' values
6411  $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate);
6412  if ($liveId !== null) {
6413  $thePidToUpdate = $liveId;
6414  }
6415  $updateValues = ['pid' => $thePidToUpdate];
6416  foreach ($updatePidForRecords as $tableName => $uids) {
6417  if (empty($tableName) || empty($uids)) {
6418  continue;
6419  }
6420  $conn = GeneralUtility::makeInstance(ConnectionPool::class)
6421  ->getConnectionForTable($tableName);
6422  foreach ($uids as $updateUid) {
6423  $conn->update($tableName, $updateValues, ['uid' => $updateUid]);
6424  }
6425  }
6426  }
6427  }
6428  }
6429  }
6430 
6436  public function processRemapStack()
6437  {
6438  // Processes the remap stack:
6439  if (is_array($this->remapStack)) {
6440  $remapFlexForms = [];
6441  $hookPayload = [];
6442 
6443  $newValue = null;
6444  foreach ($this->remapStack as $remapAction) {
6445  // If no position index for the arguments was set, skip this remap action:
6446  if (!is_array($remapAction['pos'])) {
6447  continue;
6448  }
6449  // Load values from the argument array in remapAction:
6450  $isNew = false;
6451  $field = $remapAction['field'];
6452  $id = $remapAction['args'][$remapAction['pos']['id']];
6453  $rawId = $id;
6454  $table = $remapAction['args'][$remapAction['pos']['table']];
6455  $valueArray = $remapAction['args'][$remapAction['pos']['valueArray']];
6456  $tcaFieldConf = $remapAction['args'][$remapAction['pos']['tcaFieldConf']];
6457  $additionalData = $remapAction['additionalData'] ?? [];
6458  // The record is new and has one or more new ids (in case of versioning/workspaces):
6459  if (str_contains($id, 'NEW')) {
6460  $isNew = true;
6461  // Replace NEW...-ID with real uid:
6462  $id = $this->substNEWwithIDs[$id] ?? '';
6463  // If the new parent record is on a non-live workspace or versionized, it has another new id:
6464  if (isset($this->autoVersionIdMap[$table][$id])) {
6465  $id = $this->autoVersionIdMap[$table][$id];
6466  }
6467  $remapAction['args'][$remapAction['pos']['id']] = $id;
6468  }
6469  // Replace relations to NEW...-IDs in field value (uids of child records):
6470  if (is_array($valueArray)) {
6471  foreach ($valueArray as $key => $value) {
6472  if (str_contains($value, 'NEW')) {
6473  if (!str_contains($value, '_')) {
6474  $affectedTable = $tcaFieldConf['foreign_table'] ?? '';
6475  $prependTable = false;
6476  } else {
6477  $parts = explode('_', $value);
6478  $value = array_pop($parts);
6479  $affectedTable = implode('_', $parts);
6480  $prependTable = true;
6481  }
6482  $value = $this->substNEWwithIDs[$value] ?? '';
6483  // The record is new, but was also auto-versionized and has another new id:
6484  if (isset($this->autoVersionIdMap[$affectedTable][$value])) {
6485  $value = $this->autoVersionIdMap[$affectedTable][$value];
6486  }
6487  if ($prependTable) {
6488  $value = $affectedTable . '_' . $value;
6489  }
6490  // Set a hint that this was a new child record:
6491  $this->newRelatedIDs[$affectedTable][] = $value;
6492  $valueArray[$key] = $value;
6493  }
6494  }
6495  $remapAction['args'][$remapAction['pos']['valueArray']] = $valueArray;
6496  }
6497  // Process the arguments with the defined function:
6498  if (!empty($remapAction['func'])) {
6499  $callable = [$this, $remapAction['func']];
6500  if (is_callable($callable)) {
6501  $newValue = $callable(...$remapAction['args']);
6502  }
6503  }
6504  // If array is returned, check for maxitems condition, if string is returned this was already done:
6505  if (is_array($newValue)) {
6506  $newValue = implode(',', $this->checkValue_checkMax($tcaFieldConf, $newValue));
6507  // The reference casting is only required if
6508  // checkValue_group_select_processDBdata() returns an array
6509  $newValue = $this->castReferenceValue($newValue, $tcaFieldConf, $isNew);
6510  }
6511  // Update in database (list of children (csv) or number of relations (foreign_field)):
6512  if (!empty($field)) {
6513  $fieldArray = [$field => $newValue];
6514  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
6515  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
6516  }
6517  $this->updateDB($table, $id, $fieldArray);
6518  } elseif (!empty($additionalData['flexFormId']) && !empty($additionalData['flexFormPath'])) {
6519  // Collect data to update FlexForms
6520  $flexFormId = $additionalData['flexFormId'];
6521  $flexFormPath = $additionalData['flexFormPath'];
6522 
6523  if (!isset($remapFlexForms[$flexFormId])) {
6524  $remapFlexForms[$flexFormId] = [];
6525  }
6526 
6527  $remapFlexForms[$flexFormId][$flexFormPath] = $newValue;
6528  }
6529 
6530  // Collect elements that shall trigger processDatamap_afterDatabaseOperations
6531  if (isset($this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'])) {
6532  $hookArgs = $this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'];
6533  if (!isset($hookPayload[$table][$rawId])) {
6534  $hookPayload[$table][$rawId] = [
6535  'status' => $hookArgs['status'],
6536  'fieldArray' => $hookArgs['fieldArray'],
6537  'hookObjects' => $hookArgs['hookObjectsArr'],
6538  ];
6539  }
6540  $hookPayload[$table][$rawId]['fieldArray'][$field] = $newValue;
6541  }
6542  }
6543 
6544  if ($remapFlexForms) {
6545  foreach ($remapFlexForms as $flexFormId => $modifications) {
6546  $this->updateFlexFormData((string)$flexFormId, $modifications);
6547  }
6548  }
6549 
6550  foreach ($hookPayload as $tableName => $rawIdPayload) {
6551  foreach ($rawIdPayload as $rawId => $payload) {
6552  foreach ($payload['hookObjects'] as $hookObject) {
6553  if (!method_exists($hookObject, 'processDatamap_afterDatabaseOperations')) {
6554  continue;
6555  }
6556  $hookObject->processDatamap_afterDatabaseOperations(
6557  $payload['status'],
6558  $tableName,
6559  $rawId,
6560  $payload['fieldArray'],
6561  $this
6562  );
6563  }
6564  }
6565  }
6566  }
6567  // Processes the remap stack actions:
6568  if ($this->remapStackActions) {
6569  foreach ($this->remapStackActions as $action) {
6570  if (isset($action['callback'], $action['arguments'])) {
6571  $action['callback'](...$action['arguments']);
6572  }
6573  }
6574  }
6575  // Reset:
6576  $this->remapStack = [];
6577  $this->remapStackRecords = [];
6578  $this->remapStackActions = [];
6579  }
6580 
6587  protected function updateFlexFormData($flexFormId, array $modifications)
6588  {
6589  [$table, $uid, $field] = explode(':', $flexFormId, 3);
6590 
6591  if (!‪MathUtility::canBeInterpretedAsInteger($uid) && !empty($this->substNEWwithIDs[$uid])) {
6592  $uid = $this->substNEWwithIDs[$uid];
6593  }
6594 
6595  $record = $this->recordInfo($table, $uid, '*');
6597  if (!$table || !$uid || !$field || !is_array($record)) {
6598  return;
6599  }
6600 
6601  BackendUtility::workspaceOL($table, $record);
6602 
6603  // Get current data structure and value array:
6604  $valueStructure = ‪GeneralUtility::xml2array($record[$field]);
6605 
6606  // Do recursive processing of the XML data:
6607  foreach ($modifications as $path => $value) {
6608  $valueStructure['data'] = ‪ArrayUtility::setValueByPath(
6609  $valueStructure['data'],
6610  $path,
6611  $value
6612  );
6613  }
6614 
6615  if (is_array($valueStructure['data'])) {
6616  // The return value should be compiled back into XML
6617  $values = [
6618  $field => $this->checkValue_flexArray2Xml($valueStructure, true),
6619  ];
6620 
6621  $this->updateDB($table, $uid, $values);
6622  }
6623  }
6624 
6634  public function addRemapAction($table, $id, callable $callback, array $arguments)
6635  {
6636  $this->remapStackActions[] = [
6637  'affects' => [
6638  'table' => $table,
6639  'id' => $id,
6640  ],
6641  'callback' => $callback,
6642  'arguments' => $arguments,
6643  ];
6644  }
6645 
6658  public function getVersionizedIncomingFieldArray($table, $id, &$incomingFieldArray, &$registerDBList): void
6659  {
6660  if (!isset($registerDBList[$table][$id]) || !is_array($registerDBList[$table][$id])) {
6661  return;
6662  }
6663  foreach ($incomingFieldArray as $field => $value) {
6664  $foreignTable = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config']['foreign_table'] ?? '';
6665  if (($registerDBList[$table][$id][$field] ?? false)
6666  && !empty($foreignTable)
6667  ) {
6668  $newValueArray = [];
6669  $origValueArray = is_array($value) ? $value : explode(',', $value);
6670  // Update the uids of the copied records, but also take care about new records:
6671  foreach ($origValueArray as $childId) {
6672  $newValueArray[] = $this->autoVersionIdMap[$foreignTable][$childId] ?? $childId;
6673  }
6674  // Set the changed value to the $incomingFieldArray
6675  $incomingFieldArray[$field] = implode(',', $newValueArray);
6676  }
6677  }
6678  // Clean up the $registerDBList array:
6679  unset($registerDBList[$table][$id]);
6680  if (empty($registerDBList[$table])) {
6681  unset($registerDBList[$table]);
6682  }
6683  }
6684 
6691  protected function hardDeleteSingleRecord(string $table, int $uid): void
6692  {
6693  GeneralUtility::makeInstance(ConnectionPool::class)
6694  ->getConnectionForTable($table)
6695  ->delete($table, ['uid' => $uid], [‪Connection::PARAM_INT]);
6696  }
6697 
6698  /*****************************
6699  *
6700  * Access control / Checking functions
6701  *
6702  *****************************/
6710  public function checkModifyAccessList($table)
6711  {
6712  $res = $this->admin || (!$this->tableAdminOnly($table) && isset($this->BE_USER->groupData['tables_modify']) && GeneralUtility::inList($this->BE_USER->groupData['tables_modify'], $table));
6713  // Hook 'checkModifyAccessList': Post-processing of the state of access
6714  foreach ($this->getCheckModifyAccessListHookObjects() as $hookObject) {
6716  $hookObject->checkModifyAccessList($res, $table, $this);
6717  }
6718  return $res;
6719  }
6720 
6729  public function isRecordInWebMount($table, $id)
6730  {
6731  if (!isset($this->isRecordInWebMount_Cache[$table . ':' . $id])) {
6732  $recP = $this->getRecordProperties($table, $id);
6733  $this->isRecordInWebMount_Cache[$table . ':' . $id] = $this->isInWebMount($recP['event_pid']);
6734  }
6735  return $this->isRecordInWebMount_Cache[$table . ':' . $id];
6736  }
6737 
6745  public function isInWebMount($pid)
6746  {
6747  if (!isset($this->isInWebMount_Cache[$pid])) {
6748  $this->isInWebMount_Cache[$pid] = $this->BE_USER->isInWebMount($pid);
6749  }
6750  return $this->isInWebMount_Cache[$pid];
6751  }
6752 
6763  public function checkRecordUpdateAccess($table, $id, $data = false, $hookObjectsArr = null)
6764  {
6765  $res = null;
6766  if (is_array($hookObjectsArr)) {
6767  foreach ($hookObjectsArr as $hookObj) {
6768  if (method_exists($hookObj, 'checkRecordUpdateAccess')) {
6769  $res = $hookObj->checkRecordUpdateAccess($table, $id, $data, $res, $this);
6770  }
6771  }
6772  if (isset($res)) {
6773  return (bool)$res;
6774  }
6775  }
6776  $res = false;
6777 
6778  if (‪$GLOBALS['TCA'][$table] && (int)$id > 0) {
6779  $cacheId = 'checkRecordUpdateAccess_' . $table . '_' . $id;
6780 
6781  // If information is cached, return it
6782  $cachedValue = $this->runtimeCache->get($cacheId);
6783  if (!empty($cachedValue)) {
6784  return $cachedValue;
6785  }
6786 
6787  if ($table === 'pages' || ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap))) {
6788  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6789  $perms = ‪Permission::PAGE_EDIT;
6790  } else {
6791  $perms = ‪Permission::CONTENT_EDIT;
6792  }
6793  if ($this->doesRecordExist($table, $id, $perms)) {
6794  $res = 1;
6795  }
6796  // Cache the result
6797  $this->runtimeCache->set($cacheId, $res);
6798  }
6799  return $res;
6800  }
6801 
6811  public function checkRecordInsertAccess($insertTable, $pid, $action = SystemLogDatabaseAction::INSERT)
6812  {
6813  $pid = (int)$pid;
6814  if ($pid < 0) {
6815  return false;
6816  }
6817  // If information is cached, return it
6818  if (isset($this->recInsertAccessCache[$insertTable][$pid])) {
6819  return $this->recInsertAccessCache[$insertTable][$pid];
6820  }
6821 
6822  $res = false;
6823  if ($insertTable === 'pages') {
6824  $perms = ‪Permission::PAGE_NEW;
6825  } elseif (($insertTable === 'sys_file_reference') && array_key_exists('pages', $this->datamap)) {
6826  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6827  $perms = ‪Permission::PAGE_EDIT;
6828  } else {
6829  $perms = ‪Permission::CONTENT_EDIT;
6830  }
6831  $pageExists = (bool)$this->doesRecordExist('pages', $pid, $perms);
6832  // If either admin and root-level or if page record exists and 1) if 'pages' you may create new ones 2) if page-content, new content items may be inserted on the $pid page
6833  if ($pageExists || $pid === 0 && ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($insertTable))) {
6834  // Check permissions
6835  if ($this->isTableAllowedForThisPage($pid, $insertTable)) {
6836  $res = true;
6837  // Cache the result
6838  $this->recInsertAccessCache[$insertTable][$pid] = $res;
6839  } elseif ($this->enableLogging) {
6840  $propArr = $this->getRecordProperties('pages', $pid);
6841  $this->log($insertTable, $pid, $action, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record on page \'%s\' (%s) where this table, %s, is not allowed', 11, [$propArr['header'], $pid, $insertTable], $propArr['event_pid']);
6842  }
6843  } elseif ($this->enableLogging) {
6844  $propArr = $this->getRecordProperties('pages', $pid);
6845  $this->log($insertTable, $pid, $action, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert a record on page \'%s\' (%s) from table \'%s\' without permissions. Or non-existing page.', 12, [$propArr['header'], $pid, $insertTable], $propArr['event_pid']);
6846  }
6847  return $res;
6848  }
6858  public function isTableAllowedForThisPage($page_uid, $checkTable)
6859  {
6860  $page_uid = (int)$page_uid;
6861  $rootLevelSetting = (int)(‪$GLOBALS['TCA'][$checkTable]['ctrl']['rootLevel'] ?? 0);
6862  // Check if rootLevel flag is set and we're trying to insert on rootLevel - and reversed - and that the table is not "pages" which are allowed anywhere.
6863  if ($checkTable !== 'pages' && $rootLevelSetting !== -1 && ($rootLevelSetting xor !$page_uid)) {
6864  return false;
6865  }
6866  $allowed = false;
6867  // Check root-level
6868  if (!$page_uid) {
6869  if ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($checkTable)) {
6870  $allowed = true;
6871  }
6872  } else {
6873  // Check non-root-level
6874  $doktype = $this->pageInfo($page_uid, 'doktype');
6875  $allowedTableList = ‪$GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? ‪$GLOBALS['PAGES_TYPES']['default']['allowedTables'];
6876  $allowedArray = ‪GeneralUtility::trimExplode(',', $allowedTableList, true);
6877  // If all tables or the table is listed as an allowed type, return TRUE
6878  if (str_contains($allowedTableList, '*') || in_array($checkTable, $allowedArray, true)) {
6879  $allowed = true;
6880  }
6881  }
6882  return $allowed;
6883  }
6884 
6896  public function doesRecordExist($table, $id, int $perms)
6897  {
6898  return $this->recordInfoWithPermissionCheck($table, $id, $perms, 'uid, pid') !== false;
6899  }
6900 
6911  protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
6912  {
6913  $permission = new Permission($perms);
6914  $cacheId = md5('doesRecordExist_pageLookUp_' . $id . '_' . $perms . '_' . implode(
6915  '_',
6916  $columns
6917  ) . '_' . (string)$this->admin);
6918 
6919  // If result is cached, return it
6920  $cachedResult = $this->runtimeCache->get($cacheId);
6921  if (!empty($cachedResult)) {
6922  return $cachedResult;
6923  }
6924 
6925  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6926  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6927  $queryBuilder
6928  ->select(...$columns)
6929  ->from('pages')
6930  ->where($queryBuilder->expr()->eq(
6931  'uid',
6932  $queryBuilder->createNamedParameter($id, ‪Connection::PARAM_INT)
6933  ));
6934  if (!$permission->nothingIsGranted() && !$this->admin) {
6935  $queryBuilder->andWhere($this->BE_USER->getPagePermsClause($perms));
6936  }
6937  if (!$this->admin && ‪$GLOBALS['TCA']['pages']['ctrl']['editlock'] &&
6938  ($permission->editPagePermissionIsGranted() || $permission->deletePagePermissionIsGranted() || $permission->editContentPermissionIsGranted())
6939  ) {
6940  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6941  ‪$GLOBALS['TCA']['pages']['ctrl']['editlock'],
6942  $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)
6943  ));
6944  }
6945 
6946  $row = $queryBuilder->executeQuery()->fetchAssociative();
6947  $this->runtimeCache->set($cacheId, $row);
6948 
6949  return $row;
6950  }
6951 
6965  public function doesBranchExist($inList, $pid, $perms, $recurse)
6966  {
6967  $pid = (int)$pid;
6968  $perms = (int)$perms;
6969  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6970  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6971  $result = $queryBuilder
6972  ->select('uid', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody')
6973  ->from('pages')
6974  ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, ‪Connection::PARAM_INT)))
6975  ->orderBy('sorting')
6976  ->executeQuery();
6977  while ($row = $result->fetchAssociative()) {
6978  // IF admin, then it's OK
6979  if ($this->admin || $this->BE_USER->doesUserHaveAccess($row, $perms)) {
6980  $inList .= $row['uid'] . ',';
6981  if ($recurse) {
6982  // Follow the subpages recursively...
6983  $inList = $this->doesBranchExist($inList, $row['uid'], $perms, $recurse);
6984  if ($inList === -1) {
6985  return -1;
6986  }
6987  }
6988  } else {
6989  // No permissions
6990  return -1;
6991  }
6992  }
6993  return $inList;
6994  }
6995 
7003  public function tableReadOnly($table)
7004  {
7005  // Returns TRUE if table is readonly
7006  return (bool)(‪$GLOBALS['TCA'][$table]['ctrl']['readOnly'] ?? false);
7007  }
7008 
7016  public function tableAdminOnly($table)
7017  {
7018  // Returns TRUE if table is admin-only
7019  return !empty(‪$GLOBALS['TCA'][$table]['ctrl']['adminOnly']);
7020  }
7021 
7031  public function destNotInsideSelf($destinationId, $id)
7032  {
7033  $loopCheck = 100;
7034  $destinationId = (int)$destinationId;
7035  $id = (int)$id;
7036  if ($destinationId === $id) {
7037  return false;
7038  }
7039  while ($destinationId !== 0 && $loopCheck > 0) {
7040  $loopCheck--;
7041  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
7042  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7043  $result = $queryBuilder
7044  ->select('pid', 'uid', 't3ver_oid', 't3ver_wsid')
7045  ->from('pages')
7046  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($destinationId, ‪Connection::PARAM_INT)))
7047  ->executeQuery();
7048  if ($row = $result->fetchAssociative()) {
7049  // Ensure that the moved location is used as the PID value
7050  BackendUtility::workspaceOL('pages', $row, $this->BE_USER->workspace);
7051  if ($row['pid'] == $id) {
7052  return false;
7053  }
7054  $destinationId = (int)$row['pid'];
7055  } else {
7056  return false;
7057  }
7058  }
7059  return true;
7060  }
7061 
7069  public function getExcludeListArray()
7070  {
7071  $list = [];
7072  if (isset($this->BE_USER->groupData['non_exclude_fields'])) {
7073  $nonExcludeFieldsArray = array_flip(‪GeneralUtility::trimExplode(',', $this->BE_USER->groupData['non_exclude_fields']));
7074  foreach (‪$GLOBALS['TCA'] as $table => $tableConfiguration) {
7075  if (isset($tableConfiguration['columns'])) {
7076  foreach ($tableConfiguration['columns'] as $field => $config) {
7077  $isExcludeField = ($config['exclude'] ?? false);
7078  $isOnlyVisibleForAdmins = (‪$GLOBALS['TCA'][$table]['columns'][$field]['displayCond'] ?? '') === 'HIDE_FOR_NON_ADMINS';
7079  $editorHasPermissionForThisField = isset($nonExcludeFieldsArray[$table . ':' . $field]);
7080  if ($isOnlyVisibleForAdmins || ($isExcludeField && !$editorHasPermissionForThisField)) {
7081  $list[] = $table . '-' . $field;
7082  }
7083  }
7084  }
7085  }
7086  }
7087 
7088  return $list;
7089  }
7090 
7099  public function doesPageHaveUnallowedTables($page_uid, $doktype)
7100  {
7101  $page_uid = (int)$page_uid;
7102  if (!$page_uid) {
7103  // Not a number. Probably a new page
7104  return false;
7105  }
7106  $allowedTableList = ‪$GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? ‪$GLOBALS['PAGES_TYPES']['default']['allowedTables'];
7107  // If all tables are allowed, return early
7108  if (str_contains($allowedTableList, '*')) {
7109  return false;
7110  }
7111  $allowedArray = ‪GeneralUtility::trimExplode(',', $allowedTableList, true);
7112  $tableList = [];
7113  $allTableNames = $this->compileAdminTables();
7114  foreach ($allTableNames as $table) {
7115  // If the table is not in the allowed list, check if there are records...
7116  if (in_array($table, $allowedArray, true)) {
7117  continue;
7118  }
7119  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7120  $queryBuilder->getRestrictions()->removeAll();
7121  $count = $queryBuilder
7122  ->count('uid')
7123  ->from($table)
7124  ->where($queryBuilder->expr()->eq(
7125  'pid',
7126  $queryBuilder->createNamedParameter($page_uid, ‪Connection::PARAM_INT)
7127  ))
7128  ->executeQuery()
7129  ->fetchOne();
7130  if ($count) {
7131  $tableList[] = $table;
7132  }
7133  }
7134  return implode(',', $tableList);
7135  }
7136 
7137  /*****************************
7138  *
7139  * Information lookup
7140  *
7141  *****************************/
7151  public function pageInfo($id, $field)
7152  {
7153  if (!isset($this->pageCache[$id])) {
7154  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
7155  $queryBuilder->getRestrictions()->removeAll();
7156  $row = $queryBuilder
7157  ->select('*')
7158  ->from('pages')
7159  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, ‪Connection::PARAM_INT)))