‪TYPO3CMS  ‪main
DataHandler.php
Go to the documentation of this file.
1 <?php
2 
3 /*
4  * This file is part of the TYPO3 CMS project.
5  *
6  * It is free software; you can redistribute it and/or modify it under
7  * the terms of the GNU General Public License, either version 2
8  * of the License, or any later version.
9  *
10  * For the full copyright and license information, please read the
11  * LICENSE.txt file that was distributed with this source code.
12  *
13  * The TYPO3 project - inspiring people to share!
14  */
15 
17 
18 use Doctrine\DBAL\Exception as DBALException;
19 use Doctrine\DBAL\Platforms\PostgreSQL94Platform as PostgreSQLPlatform;
20 use Doctrine\DBAL\Types\IntegerType;
21 use Doctrine\DBAL\Types\JsonType;
22 use Psr\Container\ContainerInterface;
23 use Psr\EventDispatcher\EventDispatcherInterface;
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerAwareTrait;
26 use Symfony\Component\Uid\Uuid;
27 use TYPO3\CMS\Backend\Utility\BackendUtility;
40 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
54 use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
68 use ‪TYPO3\CMS\Core\SysLog\Action\Cache as SystemLogCacheAction;
69 use ‪TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
70 use ‪TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
71 use ‪TYPO3\CMS\Core\SysLog\Type as SystemLogType;
80 
94 class ‪DataHandler implements LoggerAwareInterface
95 {
96  use ‪LogDataTrait;
97  use LoggerAwareTrait;
98 
99  // *********************
100  // Public variables you can configure before using the class:
101  // *********************
108  public ‪$storeLogMessages = true;
109 
115  public ‪$enableLogging = true;
116 
123  public ‪$reverseOrder = false;
124 
126  public ‪$checkStoredRecords = true;
128  public ‪$checkStoredRecords_loose = true;
129 
135  public ‪$neverHideAtCopy = false;
136 
142  public ‪$isImporting = false;
143 
149  public ‪$dontProcessTransformations = false;
150 
158  protected ‪$useTransOrigPointerField = true;
159 
167  public ‪$bypassWorkspaceRestrictions = false;
168 
175  public ‪$bypassAccessCheckForRecords = false;
176 
184  public ‪$copyWhichTables = '*';
185 
193  public ‪$copyTree = 0;
194 
204  public ‪$defaultValues = [];
205 
214  public ‪$overrideValues = [];
215 
224  public ‪$data_disableFields = [];
225 
236 
244  public ‪$callBackObj;
245 
252  protected ‪$correlationId;
253 
254  // *********************
255  // Internal variables (mapping arrays) which can be used (read-only) from outside
256  // *********************
263  public $autoVersionIdMap = [];
264 
270  public $substNEWwithIDs = [];
271 
278  public $substNEWwithIDs_table = [];
279 
286  public $newRelatedIDs = [];
287 
294  public $copyMappingArray_merged = [];
295 
301  protected $deletedRecords = [];
302 
309  public $errorLog = [];
310 
316  public $pagetreeRefreshFieldsFromPages = ['pid', 'sorting', 'deleted', 'hidden', 'title', 'doktype', 'is_siteroot', 'fe_group', 'nav_hide', 'nav_title', 'module', 'starttime', 'endtime', 'content_from_pid', 'extendToSubpages'];
317 
324  public $pagetreeNeedsRefresh = false;
325 
326  // *********************
327  // Internal Variables, do not touch.
328  // *********************
329 
330  // Variables set in init() function:
331 
337  public $BE_USER;
338 
345  public $userid;
346 
353  public $admin;
354 
358  protected $pagePermissionAssembler;
359 
365  protected $excludedTablesAndFields = [];
366 
373  protected $control = [];
374 
380  public $datamap = [];
381 
387  public $cmdmap = [];
388 
394  protected $mmHistoryRecords = [];
395 
401  protected $historyRecords = [];
402 
403  // Internal static:
404 
413  public $sortIntervals = 256;
414 
415  // Internal caching arrays
421  protected $recInsertAccessCache = [];
422 
428  protected $isRecordInWebMount_Cache = [];
429 
435  protected $isInWebMount_Cache = [];
436 
442  protected $pageCache = [];
443 
444  // Other arrays:
451  public $dbAnalysisStore = [];
452 
459  public $registerDBList = [];
460 
467  public $registerDBPids = [];
468 
480  public $copyMappingArray = [];
481 
488  public $remapStack = [];
489 
497  public $remapStackRecords = [];
498 
504  protected $remapStackChildIds = [];
505 
511  protected $remapStackActions = [];
512 
522  protected $referenceIndexUpdater;
523 
524  // Various
525 
532  public $checkValue_currentRecord = [];
533 
539  protected $disableDeleteClause = false;
540 
544  protected $checkModifyAccessListHookObjects;
545 
552  protected $outerMostInstance;
553 
559  protected static $recordsToClearCacheFor = [];
560 
567  protected static $recordPidsForDeletedRecords = [];
568 
574  protected $runtimeCache;
575 
581  protected $cachePrefixNestedElementCalls = 'core-datahandler-nestedElementCalls-';
582 
588  public function __construct(‪ReferenceIndexUpdater $referenceIndexUpdater = null)
589  {
590  $this->runtimeCache = $this->‪getRuntimeCache();
591  $this->pagePermissionAssembler = GeneralUtility::makeInstance(PagePermissionAssembler::class, ‪$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']);
592  if ($referenceIndexUpdater === null) {
593  // Create ReferenceIndexUpdater object. This should only happen on outer most instance,
594  // sub instances should receive the reference index updater from a parent.
595  $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
596  }
597  $this->referenceIndexUpdater = $referenceIndexUpdater;
598  }
599 
603  public function setControl(array $control)
604  {
605  $this->control = $control;
606  }
607 
617  public function start($data, $cmd, $altUserObject = null)
618  {
619  // Initializing BE_USER
620  $this->‪BE_USER = is_object($altUserObject) ? $altUserObject : ‪$GLOBALS['BE_USER'];
621  $this->‪userid = (int)($this->‪BE_USER->user['uid'] ?? 0);
622  $this->‪admin = $this->‪BE_USER->user['admin'] ?? false;
623 
624  // set correlation id for each new set of data or commands
626  md5(‪StringUtility::getUniqueId(self::class))
627  );
628 
629  // Get default values from user TSconfig
630  ‪$tcaDefaultOverride = $this->‪BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
631  if (is_array(‪$tcaDefaultOverride)) {
633  }
634 
635  // generates the excludelist, based on TCA/exclude-flag and non_exclude_fields for the user:
636  if (!$this->‪admin) {
637  $this->excludedTablesAndFields = array_flip($this->‪getExcludeListArray());
638  }
639  // Setting the data and cmd arrays
640  if (is_array($data)) {
641  reset($data);
642  $this->datamap = $data;
643  }
644  if (is_array($cmd)) {
645  reset($cmd);
646  $this->cmdmap = $cmd;
647  }
648  }
649 
657  public function ‪setMirror($mirror)
658  {
659  if (!is_array($mirror)) {
660  return;
661  }
662 
663  foreach ($mirror as $table => $uid_array) {
664  if (!isset($this->datamap[$table])) {
665  continue;
666  }
667 
668  foreach ($uid_array as $id => $uidList) {
669  if (!isset($this->datamap[$table][$id])) {
670  continue;
671  }
672 
673  $theIdsInArray = GeneralUtility::trimExplode(',', $uidList, true);
674  foreach ($theIdsInArray as $copyToUid) {
675  $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id];
676  }
677  }
678  }
679  }
680 
687  public function ‪setDefaultsFromUserTS($userTS)
688  {
689  if (!is_array($userTS)) {
690  return;
691  }
692 
693  foreach ($userTS as $k => $v) {
694  $k = mb_substr($k, 0, -1);
695  if (!$k || !is_array($v) || !isset(‪$GLOBALS['TCA'][$k])) {
696  continue;
697  }
698 
699  if (is_array($this->defaultValues[$k] ?? false)) {
700  $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v);
701  } else {
702  $this->defaultValues[$k] = $v;
703  }
704  }
705  }
706 
718  protected function ‪applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray): array
719  {
720  // First set TCAdefaults respecting the given PageID
721  $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null;
722  // Re-apply $this->defaultValues settings
723  $this->‪setDefaultsFromUserTS($tcaDefaults);
724  $cleanFieldArray = $this->‪newFieldArray($table);
725  if (isset($prepopulatedFieldArray['pid'])) {
726  $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid'];
727  }
728  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? null;
729  if ($sortColumn !== null && isset($prepopulatedFieldArray[$sortColumn])) {
730  $cleanFieldArray[$sortColumn] = $prepopulatedFieldArray[$sortColumn];
731  }
732  return $cleanFieldArray;
733  }
734 
735  /*********************************************
736  *
737  * HOOKS
738  *
739  *********************************************/
754  public function ‪hook_processDatamap_afterDatabaseOperations(&$hookObjectsArr, &$status, &$table, &$id, &$fieldArray)
755  {
756  // Process hook directly:
757  if (!isset($this->remapStackRecords[$table][$id])) {
758  foreach ($hookObjectsArr as $hookObj) {
759  if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) {
760  $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);
761  }
762  }
763  } else {
764  $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [
765  'status' => $status,
766  'fieldArray' => $fieldArray,
767  'hookObjectsArr' => $hookObjectsArr,
768  ];
769  }
770  }
771 
779  protected function ‪getCheckModifyAccessListHookObjects()
780  {
781  if (!isset($this->checkModifyAccessListHookObjects)) {
782  $this->checkModifyAccessListHookObjects = [];
783  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) {
784  $hookObject = GeneralUtility::makeInstance($className);
785  if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) {
786  throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472);
787  }
788  $this->checkModifyAccessListHookObjects[] = $hookObject;
789  }
790  }
791  return $this->checkModifyAccessListHookObjects;
792  }
793 
794  /*********************************************
795  *
796  * PROCESSING DATA
797  *
798  *********************************************/
805  public function ‪process_datamap()
806  {
807  $this->‪controlActiveElements();
808 
809  // Keep versionized(!) relations here locally:
810  $registerDBList = [];
812  $this->datamap = $this->‪unsetElementsToBeDeleted($this->datamap);
813  // Editing frozen:
814  if ($this->‪BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
815  $this->‪log('sys_workspace', $this->‪BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen');
816  return false;
817  }
818  // First prepare user defined objects (if any) for hooks which extend this function:
819  $hookObjectsArr = [];
820  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) {
821  $hookObject = GeneralUtility::makeInstance($className);
822  if (method_exists($hookObject, 'processDatamap_beforeStart')) {
823  $hookObject->processDatamap_beforeStart($this);
824  }
825  $hookObjectsArr[] = $hookObject;
826  }
827  // Pre-process data-map and synchronize localization states
828  $this->datamap = GeneralUtility::makeInstance(SlugEnricher::class)->enrichDataMap($this->datamap);
829  $this->datamap = DataMapProcessor::instance($this->datamap, $this->‪BE_USER, $this->referenceIndexUpdater)->process();
830  // 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.
831  $orderOfTables = [];
832  // Set pages first.
833  if (isset($this->datamap['pages'])) {
834  $orderOfTables[] = 'pages';
835  }
836  $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap)));
837  // Process the tables...
838  foreach ($orderOfTables as $table) {
839  // Check if
840  // - table is set in $GLOBALS['TCA'],
841  // - table is NOT readOnly
842  // - the table is set with content in the data-array (if not, there's nothing to process...)
843  // - permissions for tableaccess OK
844  $modifyAccessList = $this->‪checkModifyAccessList($table);
845  if (!$modifyAccessList) {
846  $this->‪log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', 1, ['table' => $table]);
847  }
848  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->‪tableReadOnly($table) || !is_array($this->datamap[$table]) || !$modifyAccessList) {
849  continue;
850  }
851 
852  if ($this->reverseOrder) {
853  $this->datamap[$table] = array_reverse($this->datamap[$table], true);
854  }
855  // For each record from the table, do:
856  // $id is the record uid, may be a string if new records...
857  // $incomingFieldArray is the array of fields
858  foreach ($this->datamap[$table] as $id => $incomingFieldArray) {
859  if (!is_array($incomingFieldArray)) {
860  continue;
861  }
862  $theRealPid = null;
863 
864  // Hook: processDatamap_preProcessFieldArray
865  foreach ($hookObjectsArr as $hookObj) {
866  if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) {
867  $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this);
868  }
869  }
870  // ******************************
871  // Checking access to the record
872  // ******************************
873  $createNewVersion = false;
874  $old_pid_value = '';
875  // Is it a new record? (Then Id is a string)
877  // Get a fieldArray with tca default values
878  $fieldArray = $this->‪newFieldArray($table);
879  // A pid must be set for new records.
880  if (isset($incomingFieldArray['pid'])) {
881  $pid_value = $incomingFieldArray['pid'];
882  // Checking and finding numerical pid, it may be a string-reference to another value
883  $canProceed = true;
884  // If a NEW... id
885  if (str_contains($pid_value, 'NEW')) {
886  if ($pid_value[0] === '-') {
887  $negFlag = -1;
888  $pid_value = substr($pid_value, 1);
889  } else {
890  $negFlag = 1;
891  }
892  // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record.
893  if (isset($this->substNEWwithIDs[$pid_value])) {
894  if ($negFlag === 1) {
895  $old_pid_value = $this->substNEWwithIDs[$pid_value];
896  }
897  $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]);
898  } else {
899  $canProceed = false;
900  }
901  }
902  $pid_value = (int)$pid_value;
903  if ($canProceed) {
904  $fieldArray = $this->‪resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray);
905  }
906  }
907  $theRealPid = $fieldArray['pid'];
908  // Checks if records can be inserted on this $pid.
909  // If this is a page translation, the check needs to be done for the l10n_parent record
910  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
911  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
912  if ($table === 'pages'
913  && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0
914  && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0
915  ) {
916  $recordAccess = $this->‪checkRecordInsertAccess($table, $incomingFieldArray[$transOrigPointerField]);
917  } else {
918  $recordAccess = $this->‪checkRecordInsertAccess($table, $theRealPid);
919  }
920  if ($recordAccess) {
921  $incomingFieldArray = $this->‪addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray, $theRealPid);
922  $recordAccess = $this->‪BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
923  if (!$recordAccess) {
924  $this->‪log($table, 0, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed [{reason}]', -1, ['reason' => $this->‪BE_USER->errorMsg]);
925  } elseif (!$this->bypassWorkspaceRestrictions && !$this->‪BE_USER->workspaceAllowsLiveEditingInTable($table)) {
926  // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
927  // So, if no live records were allowed in the current workspace, we have to create a new version of this record
928  if (BackendUtility::isTableWorkspaceEnabled($table)) {
929  $createNewVersion = true;
930  } else {
931  $recordAccess = false;
932  $this->‪log(
933  $table,
934  0,
935  SystemLogDatabaseAction::VERSIONIZE,
936  0,
937  SystemLogErrorClassification::USER_ERROR,
938  'Attempt to insert version record "{table}:{uid}" to this workspace failed. "Live" edit permissions of records from tables without versioning required',
939  -1,
940  [
941  'table' => $table,
942  'uid' => $id,
943  ]
944  );
945  }
946  }
947  }
948  // Yes new record, change $record_status to 'insert'
949  $status = 'new';
950  } else {
951  // Nope... $id is a number
952  $id = (int)$id;
953  $fieldArray = [];
954  $recordAccess = $this->‪checkRecordUpdateAccess($table, $id, $incomingFieldArray, $hookObjectsArr);
955  if (!$recordAccess) {
956  if ($this->enableLogging) {
957  $propArr = $this->‪getRecordProperties($table, $id);
958  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record "{title}" ({table}:{uid}) without permission or non-existing page', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => $id], $propArr['event_pid']);
959  }
960  continue;
961  }
962  // Next check of the record permissions (internals)
963  $recordAccess = $this->‪BE_USER->recordEditAccessInternals($table, $id);
964  if (!$recordAccess) {
965  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'recordEditAccessInternals() check failed [{reason}]', -1, ['reason' => $this->‪BE_USER->errorMsg]);
966  } else {
967  // Here we fetch the PID of the record that we point to...
968  $tempdata = $this->‪recordInfo($table, $id);
969  $theRealPid = $tempdata['pid'] ?? null;
970  // Use the new id of the versionized record we're trying to write to:
971  // (This record is a child record of a parent and has already been versionized.)
972  if (!empty($this->autoVersionIdMap[$table][$id])) {
973  // For the reason that creating a new version of this record, automatically
974  // created related child records (e.g. "IRRE"), update the accordant field:
975  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
976  // Use the new id of the copied/versionized record:
977  $id = $this->autoVersionIdMap[$table][$id];
978  $recordAccess = true;
979  } elseif (!$this->bypassWorkspaceRestrictions && $tempdata && ($errorCode = $this->‪workspaceCannotEditRecord($table, $tempdata))) {
980  $recordAccess = false;
981  // Versioning is required and it must be offline version!
982  // Check if there already is a workspace version
983  $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->‪BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
984  if ($workspaceVersion) {
985  $id = $workspaceVersion['uid'];
986  $recordAccess = true;
987  } elseif ($this->‪workspaceAllowAutoCreation($table, $id, $theRealPid)) {
988  // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
989  $this->pagetreeNeedsRefresh = true;
990 
991  $tce = GeneralUtility::makeInstance(self::class, $this->referenceIndexUpdater);
992  $tce->enableLogging = ‪$this->enableLogging;
993  // Setting up command for creating a new version of the record:
994  $cmd = [];
995  $cmd[$table][$id]['version'] = [
996  'action' => 'new',
997  // Default is to create a version of the individual records
998  'label' => 'Auto-created for WS #' . $this->‪BE_USER->workspace,
999  ];
1000  $tce->start([], $cmd, $this->‪BE_USER);
1001  $tce->process_cmdmap();
1002  $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
1003  // If copying was successful, share the new uids (also of related children):
1004  if (!empty($tce->copyMappingArray[$table][$id])) {
1005  foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
1006  foreach ($origIdArray as $origId => $newId) {
1007  $this->autoVersionIdMap[$origTable][$origId] = $newId;
1008  }
1009  }
1010  // Update registerDBList, that holds the copied relations to child records:
1011  $registerDBList = array_merge($registerDBList, $tce->registerDBList);
1012  // For the reason that creating a new version of this record, automatically
1013  // created related child records (e.g. "IRRE"), update the accordant field:
1014  $this->‪getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
1015  // Use the new id of the copied/versionized record:
1016  $id = $this->autoVersionIdMap[$table][$id];
1017  $recordAccess = true;
1018  } else {
1019  $this->‪log(
1020  $table,
1021  $id,
1022  SystemLogDatabaseAction::VERSIONIZE,
1023  0,
1024  SystemLogErrorClassification::USER_ERROR,
1025  'Attempt to version record "{table}:{uid}" failed [{reason}]',
1026  -1,
1027  [
1028  'reason' => $errorCode,
1029  'table' => $table,
1030  'uid' => $id,
1031  ]
1032  );
1033  }
1034  } else {
1035  $this->‪log(
1036  $table,
1037  $id,
1038  SystemLogDatabaseAction::VERSIONIZE,
1039  0,
1040  SystemLogErrorClassification::USER_ERROR,
1041  'Attempt to version record "{table}:{uid}" failed [{reason}]. "Live" edit permissions of records from tables without versioning required',
1042  -1,
1043  [
1044  'reason' => $errorCode,
1045  'table' => $table,
1046  'uid' => $id,
1047  ]
1048  );
1049  }
1050  }
1051  }
1052  // The default is 'update'
1053  $status = 'update';
1054  }
1055  // If access was granted above, proceed to create or update record:
1056  if (!$recordAccess) {
1057  continue;
1058  }
1059 
1060  // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
1061  [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
1062  if ($status === 'new') {
1063  // Apply TCAdefaults from pageTS
1064  $fieldArray = $this->‪applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
1065  // Apply page permissions as well
1066  if ($table === 'pages') {
1067  $fieldArray = $this->pagePermissionAssembler->applyDefaults(
1068  $fieldArray,
1069  (int)$tscPID,
1070  (int)$this->‪userid,
1071  (int)$this->‪BE_USER->firstMainGroup
1072  );
1073  }
1074  // Ensure that the default values, that are stored in the $fieldArray (built from internal default values)
1075  // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and
1076  // all default values are also checked for validity
1077  // This allows to set TCAdefaults (for example) without having to use FormEngine to have the fields available first.
1078  $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray);
1079  }
1080  // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1081  $fieldArray = $this->‪fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1082  // NOTICE! All manipulation beyond this point bypasses both "excludeFields" AND possible "MM" relations to field!
1083  // Forcing some values unto field array:
1084  // NOTICE: This overriding is potentially dangerous; permissions per field is not checked!!!
1085  $fieldArray = $this->‪overrideFieldArray($table, $fieldArray);
1086  // Setting system fields
1087  if ($status === 'new') {
1088  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
1089  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
1090  }
1091  }
1092  // Set stage to "Editing" to make sure we restart the workflow
1093  if (BackendUtility::isTableWorkspaceEnabled($table)) {
1094  $fieldArray['t3ver_stage'] = 0;
1095  }
1096  if ($status !== 'new') {
1097  // Removing fields which are equal to the current value:
1098  $fieldArray = $this->‪compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1099  }
1100  if ((‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) && !empty($fieldArray)) {
1101  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
1102  }
1103  // Hook: processDatamap_postProcessFieldArray
1104  foreach ($hookObjectsArr as $hookObj) {
1105  if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1106  $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1107  }
1108  }
1109  // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1110  // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1111  if (is_array($fieldArray)) {
1112  if ($status === 'new') {
1113  if ($table === 'pages') {
1114  // for new pages always a refresh is needed
1115  $this->pagetreeNeedsRefresh = true;
1116  }
1117 
1118  // This creates a version of the record, instead of adding it to the live workspace
1119  if ($createNewVersion) {
1120  // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1121  $this->pagetreeNeedsRefresh = true;
1122  $fieldArray['pid'] = $theRealPid;
1123  $fieldArray['t3ver_oid'] = 0;
1124  // Setting state for version (so it can know it is currently a new version...)
1125  $fieldArray['t3ver_state'] = VersionState::NEW_PLACEHOLDER->value;
1126  $fieldArray['t3ver_wsid'] = $this->‪BE_USER->workspace;
1127  $this->‪insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1128  // Hold auto-versionized ids of placeholders
1129  $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1130  } else {
1131  $this->‪insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1132  }
1133  } else {
1134  if ($table === 'pages') {
1135  // Only a certain number of fields needs to be checked for updates,
1136  // fields with unchanged values are already removed here.
1137  $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1138  if (!empty($fieldsToCheck)) {
1139  $this->pagetreeNeedsRefresh = true;
1140  }
1141  }
1142  $this->‪updateDB($table, $id, $fieldArray);
1143  }
1144  }
1145  // Hook: processDatamap_afterDatabaseOperations
1146  // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1147  // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1148  $this->‪hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1149  }
1150  }
1151  // Process the stack of relations to remap/correct
1152  $this->‪processRemapStack();
1153  $this->‪dbAnalysisStoreExec();
1154  // Hook: processDatamap_afterAllOperations
1155  // Note: When this hook gets called, all operations on the submitted data have been finished.
1156  foreach ($hookObjectsArr as $hookObj) {
1157  if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1158  $hookObj->processDatamap_afterAllOperations($this);
1159  }
1160  }
1161 
1162  if ($this->‪isOuterMostInstance()) {
1163  $this->referenceIndexUpdater->update();
1164  $this->‪processClearCacheQueue();
1166  }
1167  }
1168 
1180  protected function ‪resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1181  {
1182  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1183  // Points to a page on which to insert the element, possibly in the top of the page
1184  if ($pid >= 0) {
1185  // Ensure that the "pid" is not a translated page ID, but the default page ID
1186  $pid = $this->‪getDefaultLanguagePageId($pid);
1187  // The numerical pid is inserted in the data array
1188  $fieldArray['pid'] = $pid;
1189  // If this table is sorted we better find the top sorting number
1190  if ($sortColumn) {
1191  $fieldArray[$sortColumn] = $this->‪getSortNumber($table, 0, $pid);
1192  }
1193  } elseif ($sortColumn) {
1194  // Points to another record before itself
1195  // If this table is sorted we better find the top sorting number
1196  // Because $pid is < 0, getSortNumber() returns an array
1197  $sortingInfo = $this->‪getSortNumber($table, 0, $pid);
1198  $fieldArray['pid'] = $sortingInfo['pid'];
1199  $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1200  } else {
1201  // Here we fetch the PID of the record that we point to
1202  ‪$record = $this->‪recordInfo($table, abs($pid));
1203  // Ensure that the "pid" is not a translated page ID, but the default page ID
1204  $fieldArray['pid'] = $this->‪getDefaultLanguagePageId(‪$record['pid']);
1205  }
1206  return $fieldArray;
1207  }
1208 
1223  public function ‪fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $realPid, $status, $tscPID)
1224  {
1225  // Initialize:
1226  $originalLanguageRecord = null;
1227  $originalLanguage_diffStorage = null;
1228  $diffStorageFlag = false;
1229  $isNewRecord = str_contains((string)$id, 'NEW');
1230  // Setting 'currentRecord' and 'checkValueRecord':
1231  if ($isNewRecord) {
1232  // Must have the 'current' array - not the values after processing below...
1233  $checkValueRecord = $fieldArray;
1234  // IF $incomingFieldArray is an array, overlay it.
1235  // 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...
1236  if (is_array($incomingFieldArray) && is_array($checkValueRecord)) {
1237  ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
1238  }
1239  $currentRecord = $checkValueRecord;
1240  } else {
1241  $id = (int)$id;
1242  // We must use the current values as basis for this!
1243  $currentRecord = ($checkValueRecord = $this->‪recordInfo($table, $id));
1244  }
1245 
1246  // Get original language record if available:
1247  if (is_array($currentRecord)
1248  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false)
1249  && !empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField'])
1250  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] ?? 0) > 0
1251  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? false)
1252  && (int)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] ?? 0) > 0
1253  ) {
1254  $originalLanguageRecord = $this->‪recordInfo($table, $currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]);
1255  BackendUtility::workspaceOL($table, $originalLanguageRecord);
1256  $originalLanguage_diffStorage = json_decode(
1257  (string)($currentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] ?? ''),
1258  true
1259  );
1260  }
1261 
1262  $this->checkValue_currentRecord = $checkValueRecord;
1263  // In the following all incoming value-fields are tested:
1264  // - Are the user allowed to change the field?
1265  // - Is the field uid/pid (which are already set)
1266  // - perms-fields for pages-table, then do special things...
1267  // - If the field is nothing of the above and the field is configured in TCA, the fieldvalues are evaluated by ->checkValue
1268  // If everything is OK, the field is entered into $fieldArray[]
1269  foreach ($incomingFieldArray as $field => $fieldValue) {
1270  if (isset($this->excludedTablesAndFields[$table . '-' . $field]) || (bool)($this->data_disableFields[$table][$id][$field] ?? false)) {
1271  continue;
1272  }
1273 
1274  // The field must be editable.
1275  // Checking if a value for language can be changed:
1276  if ((‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false)
1277  && (string)‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] === (string)$field
1278  && !$this->‪BE_USER->checkLanguageAccess($fieldValue)
1279  ) {
1280  continue;
1281  }
1282 
1283  switch ($field) {
1284  case 'uid':
1285  case 'pid':
1286  // Nothing happens, already set
1287  break;
1288  case 'perms_userid':
1289  case 'perms_groupid':
1290  case 'perms_user':
1291  case 'perms_group':
1292  case 'perms_everybody':
1293  // Permissions can be edited by the owner or the administrator
1294  if ($table === 'pages' && ($this->‪admin || $status === 'new' || $this->‪pageInfo((int)$id, 'perms_userid') == $this->‪userid)) {
1295  $value = (int)$fieldValue;
1296  switch ($field) {
1297  case 'perms_userid':
1298  case 'perms_groupid':
1299  $fieldArray[$field] = $value;
1300  break;
1301  default:
1302  if ($value >= 0 && $value < (2 ** 5)) {
1303  $fieldArray[$field] = $value;
1304  }
1305  }
1306  }
1307  break;
1308  case 't3ver_oid':
1309  case 't3ver_wsid':
1310  case 't3ver_state':
1311  case 't3ver_stage':
1312  break;
1313  case 'l10n_state':
1314  $fieldArray[$field] = $fieldValue;
1315  break;
1316  default:
1317  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
1318  // Evaluating the value
1319  $res = $this->‪checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray);
1320  if (array_key_exists('value', $res)) {
1321  $fieldArray[$field] = $res['value'];
1322  }
1323  // Add the value of the original record to the diff-storage content:
1324  if (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false) {
1325  $originalLanguage_diffStorage[$field] = (string)($originalLanguageRecord[$field] ?? '');
1326  $diffStorageFlag = true;
1327  }
1328  } elseif (isset(‪$GLOBALS['TCA'][$table]['ctrl']['origUid']) && ‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] === $field) {
1329  // Allow value for original UID to pass by...
1330  $fieldArray[$field] = $fieldValue;
1331  }
1332  }
1333  }
1334 
1335  // Dealing with a page translation, setting "sorting", "pid", "perms_*" to the same values as the original record
1336  if ($table === 'pages' && is_array($originalLanguageRecord)) {
1337  $fieldArray['sorting'] = $originalLanguageRecord['sorting'];
1338  $fieldArray['perms_userid'] = $originalLanguageRecord['perms_userid'];
1339  $fieldArray['perms_groupid'] = $originalLanguageRecord['perms_groupid'];
1340  $fieldArray['perms_user'] = $originalLanguageRecord['perms_user'];
1341  $fieldArray['perms_group'] = $originalLanguageRecord['perms_group'];
1342  $fieldArray['perms_everybody'] = $originalLanguageRecord['perms_everybody'];
1343  }
1344 
1345  // Add diff-storage information
1346  if ($diffStorageFlag
1347  && (
1348  !array_key_exists(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
1349  || ($isNewRecord && $originalLanguageRecord !== null)
1350  )
1351  ) {
1352  // If the field is set it would probably be because of an undo-operation - in which case we should not
1353  // update the field of course. On the other hand, e.g. for record localization, we need to update the field.
1354  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = json_encode($originalLanguage_diffStorage);
1355  }
1356  return $fieldArray;
1357  }
1358 
1359  /*********************************************
1360  *
1361  * Evaluation of input values
1362  *
1363  ********************************************/
1380  public function ‪checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = [])
1381  {
1382  $curValueRec = null;
1383  // Result array
1384  $res = [];
1385 
1386  // Processing special case of field pages.doktype
1387  if ($table === 'pages' && $field === 'doktype') {
1388  // If the user may not use this specific doktype, we issue a warning
1389  if (!($this->‪admin || ‪GeneralUtility::inList($this->‪BE_USER->groupData['pagetypes_select'], $value))) {
1390  if ($this->enableLogging) {
1391  $propArr = $this->‪getRecordProperties($table, $id);
1392  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot change the "doktype" of page "{title}" to the desired value', 1, ['title' => $propArr['header']], $propArr['event_pid']);
1393  }
1394  return $res;
1395  }
1396  if ($status === 'update') {
1397  // This checks 1) if we should check for disallowed tables and 2) if there are records from disallowed tables on the current page
1398  $onlyAllowedTables = GeneralUtility::makeInstance(PageDoktypeRegistry::class)->doesDoktypeOnlyAllowSpecifiedRecordTypes((int)$value);
1399  if ($onlyAllowedTables) {
1400  // use the real page id (default language)
1401  $recordId = $this->‪getDefaultLanguagePageId((int)$id);
1402  $theWrongTables = $this->‪doesPageHaveUnallowedTables($recordId, (int)$value);
1403  if ($theWrongTables !== []) {
1404  if ($this->enableLogging) {
1405  $propArr = $this->‪getRecordProperties($table, $id);
1406  $this->‪log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, '"doktype" of page "{title}" could not be changed because the page contains records from disallowed tables; {disallowedTables}', 2, ['title' => $propArr['header'], 'disallowedTables' => implode(', ', $theWrongTables)], $propArr['event_pid']);
1407  }
1408  return $res;
1409  }
1410  }
1411  }
1412  }
1413 
1414  $curValue = null;
1415  if ((int)$id !== 0) {
1416  // Get current value:
1417  $curValueRec = $this->‪recordInfo($table, (int)$id);
1418  // isset() won't work here, since values can be NULL
1419  if ($curValueRec !== null && array_key_exists($field, $curValueRec)) {
1420  $curValue = $curValueRec[$field];
1421  }
1422  }
1423 
1424  if ($table === 'be_users'
1425  && ($field === 'admin' || $field === 'password')
1426  && $status === 'update'
1427  ) {
1428  // Do not allow a non system maintainer admin to change admin flag and password of system maintainers
1429  $systemMaintainers = array_map(intval(...), ‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
1430  // False if current user is not in system maintainer list or if switch to user mode is active
1431  $isCurrentUserSystemMaintainer = $this->‪BE_USER->isSystemMaintainer();
1432  $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true);
1433  if ($field === 'admin') {
1434  $isFieldChanged = (int)$curValueRec[$field] !== (int)$value;
1435  } else {
1436  $isFieldChanged = $curValueRec[$field] !== $value;
1437  }
1438  if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList && $isFieldChanged) {
1439  $value = $curValueRec[$field];
1440  $this->‪log(
1441  $table,
1442  (int)$id,
1443  SystemLogDatabaseAction::UPDATE,
1444  0,
1445  SystemLogErrorClassification::SECURITY_NOTICE,
1446  'Only system maintainers can change the admin flag and password of other system maintainers. The value has not been updated'
1447  );
1448  }
1449  }
1450 
1451  // Getting config for the field
1452  $tcaFieldConf = $this->‪resolveFieldConfigurationAndRespectColumnsOverrides($table, $field);
1453 
1454  // Create $recFID only for those types that need it
1455  if ($tcaFieldConf['type'] === 'flex') {
1456  $recFID = $table . ':' . $id . ':' . $field;
1457  } else {
1458  $recFID = '';
1459  }
1460 
1461  // Perform processing:
1462  $res = $this->‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
1463  return $res;
1464  }
1465 
1475  protected function ‪resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field): array
1476  {
1477  $tcaFieldConf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
1478  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1479  $columnsOverridesConfigOfField = ‪$GLOBALS['TCA'][$table]['types'][$recordType]['columnsOverrides'][$field]['config'] ?? null;
1480  if ($columnsOverridesConfigOfField) {
1481  ArrayUtility::mergeRecursiveWithOverrule($tcaFieldConf, $columnsOverridesConfigOfField);
1482  }
1483  return $tcaFieldConf;
1484  }
1485 
1506  public function ‪checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, array $additionalData = null)
1507  {
1508  // Convert to NULL value if defined in TCA
1509  if ($value === null && ($tcaFieldConf['nullable'] ?? false)) {
1510  $res = ['value' => null];
1511  return $res;
1512  }
1513 
1514  // This is either a normal field or a FlexForm field.
1515  // Used to enrich the (potential) error log with contextual information.
1516  $checkField = $recFID !== '' ? explode(':', $recFID)[2] : $field;
1517 
1518  $res = (array)match ((string)$tcaFieldConf['type']) {
1519  'category' => $this->‪checkValueForCategory($res, (string)$value, $tcaFieldConf, (string)$table, $id, (string)$status, (string)$field),
1520  'check' => $this->‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field),
1521  'color' => $this->‪checkValueForColor((string)$value, $tcaFieldConf),
1522  'datetime' => $this->‪checkValueForDatetime($value, $tcaFieldConf),
1523  'email' => $this->‪checkValueForEmail((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $checkField),
1524  'flex' => $field ? $this->‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field) : [],
1525  'inline' => $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [],
1526  'file' => $this->‪checkValueForFile($res, (string)$value, $tcaFieldConf, $table, $id, $field, $additionalData),
1527  'input' => $this->‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field),
1528  'language' => $this->‪checkValueForLanguage((int)$value, $table, $field),
1529  'link' => $this->‪checkValueForLink((string)$value, $tcaFieldConf, $table, $id, $checkField),
1530  'number' => $this->‪checkValueForNumber($value, $tcaFieldConf),
1531  'password' => $this->‪checkValueForPassword((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $additionalData['incomingFieldArray'] ?? []),
1532  'radio' => $this->‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field),
1533  'slug' => $this->‪checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []),
1534  'text' => $this->‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field),
1535  'group', 'folder', 'select' => $this->‪checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field),
1536  'json' => $this->‪checkValueForJson($value, $tcaFieldConf),
1537  'uuid' => $this->‪checkValueForUuid((string)$value, $tcaFieldConf),
1538  'passthrough', 'imageManipulation', 'user' => ['value' => $value],
1539  default => [],
1540  };
1541 
1542  $res = $this->‪checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field);
1543  return $res;
1544  }
1545 
1564  protected function ‪checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field)
1565  {
1566  $relevantFieldNames = [
1567  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null,
1568  ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null,
1569  ];
1570 
1571  if (
1572  // in case field is empty
1573  empty($field)
1574  // in case the field is not relevant
1575  || !in_array($field, $relevantFieldNames)
1576  // in case the 'value' index has been unset already
1577  || !array_key_exists('value', $res)
1578  // in case it's not a NEW-identifier
1579  || !str_contains($value, 'NEW')
1580  ) {
1581  return $res;
1582  }
1583 
1584  $valueArray = [$value];
1585  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1586  $this->‪addNewValuesToRemapStackChildIds($valueArray);
1587  $this->remapStack[] = [
1588  'args' => [$valueArray, $tcaFieldConf, $id, $table, $field],
1589  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
1590  'field' => $field,
1591  ];
1592  unset($res['value']);
1593 
1594  return $res;
1595  }
1596 
1607  protected function ‪checkValueForText($value, $tcaFieldConf, $table, $realPid, $field)
1608  {
1609  $richtextEnabled = (bool)($tcaFieldConf['enableRichtext'] ?? false);
1610 
1611  // Reset value to empty string, if less than "min" characters.
1612  $min = $tcaFieldConf['min'] ?? 0;
1613  if (!$richtextEnabled && $min > 0 && mb_strlen((string)$value) < $min) {
1614  $value = '';
1615  }
1616 
1617  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1618  $valueArray = [];
1619  } elseif (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
1620  $cacheId = $this->‪getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1621  $evalCodesArray = $this->runtimeCache->get($cacheId);
1622  if (!is_array($evalCodesArray)) {
1623  $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1624  $this->runtimeCache->set($cacheId, $evalCodesArray);
1625  }
1626  $valueArray = $this->‪checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '');
1627  } else {
1628  $valueArray = ['value' => $value];
1629  }
1630 
1631  // Handle richtext transformations
1632  if ($this->dontProcessTransformations) {
1633  return $valueArray;
1634  }
1635  // Keep null as value
1636  if ($value === null) {
1637  return $valueArray;
1638  }
1639  if ($richtextEnabled) {
1640  $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1641  $richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
1642  $richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
1643  $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class);
1644  $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []);
1645  }
1646 
1647  return $valueArray;
1648  }
1649 
1661  protected function ‪checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field)
1662  {
1663  // Secures the string-length to be less than max.
1664  if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) {
1665  $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
1666  }
1667 
1668  // Reset value to empty string, if less than "min" characters.
1669  $min = $tcaFieldConf['min'] ?? 0;
1670  if ($min > 0 && mb_strlen((string)$value) < $min) {
1671  $value = '';
1672  }
1673 
1674  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
1675  $res = [];
1676  } elseif (empty($tcaFieldConf['eval'])) {
1677  $res = ['value' => $value];
1678  } else {
1679  // Process evaluation settings:
1680  $cacheId = $this->‪getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1681  $evalCodesArray = $this->runtimeCache->get($cacheId);
1682  if (!is_array($evalCodesArray)) {
1683  $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1684  $this->runtimeCache->set($cacheId, $evalCodesArray);
1685  }
1686 
1687  $res = $this->‪checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table, $id);
1688 
1689  // Process UNIQUE settings:
1690  // 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
1691  if ($field && !empty($res['value'])) {
1692  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1693  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id, $realPid);
1694  }
1695  if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1696  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id);
1697  }
1698  }
1699  }
1700 
1701  return $res;
1702  }
1703 
1710  protected function ‪checkValueForNumber(mixed $value, array $tcaFieldConf): array
1711  {
1712  $format = $tcaFieldConf['format'] ?? 'integer';
1713  if ($format !== 'integer' && $format !== 'decimal') {
1714  // Early return if format is not valid
1715  return [];
1716  }
1717 
1718  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
1719  return [];
1720  }
1721 
1722  if ($format === 'decimal') {
1723  // @todo Make precision configurable
1724  $precision = 2;
1725  $value = preg_replace('/[^0-9,\\.-]/', '', $value);
1726  $negative = substr($value, 0, 1) === '-';
1727  $value = strtr($value, [',' => '.', '-' => '']);
1728  if (!str_contains($value, '.')) {
1729  $value .= '.0';
1730  }
1731  $valueArray = explode('.', $value);
1732  $dec = array_pop($valueArray);
1733  $value = implode('', $valueArray) . '.' . $dec;
1734  if ($negative) {
1735  $value *= -1;
1736  }
1737  $result['value'] = number_format((float)$value, $precision, '.', '');
1738  } else {
1739  $result['value'] = (int)$value;
1740  }
1741 
1742  // Checking range of value:
1743  if (is_array($tcaFieldConf['range'] ?? false)) {
1744  if (isset($tcaFieldConf['range']['upper']) && ceil($result['value']) > (int)$tcaFieldConf['range']['upper']) {
1745  $result['value'] = (int)$tcaFieldConf['range']['upper'];
1746  }
1747  if (isset($tcaFieldConf['range']['lower']) && floor($result['value']) < (int)$tcaFieldConf['range']['lower']) {
1748  $result['value'] = (int)$tcaFieldConf['range']['lower'];
1749  }
1750  }
1751 
1752  return $result;
1753  }
1754 
1762  protected function ‪checkValueForColor(string $value, array $tcaFieldConf): array
1763  {
1764  // Always trim the value
1765  $value = trim($value);
1766 
1767  // Secures the string-length to be <= 7.
1768  $value = mb_substr($value, 0, 7, 'utf-8');
1769 
1770  // Early return if required validation fails
1771  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1772  return [];
1773  }
1774 
1775  return [
1776  'value' => $value,
1777  ];
1778  }
1779 
1791  protected function ‪checkValueForEmail(
1792  string $value,
1793  array $tcaFieldConf,
1794  string $table,
1795  int|string $id,
1796  int $realPid,
1797  string $field
1798  ): array {
1799  // Always trim the value
1800  $value = trim($value);
1801 
1802  // Early return if required validation fails
1803  // Note: The "required" check is evaluated but does not yet lead to an error, see
1804  // the comment in the DataHandler::validateValueForRequired() for more information.
1805  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1806  return [];
1807  }
1808 
1809  if ($value !== '' && !GeneralUtility::validEmail($value)) {
1810  // A non-empty value is given, which however is no valid email. Log this and unset the value afterwards.
1811  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{email}" is not a valid e-mail address for the field "{field}" of the table "{table}"', -1, ['email' => $value, 'field' => $field, 'table' => $table]);
1812  $value = '';
1813  }
1814 
1815  $res = [
1816  'value' => $value,
1817  ];
1818 
1819  // Early return if no evaluation is configured
1820  if (!isset($tcaFieldConf['eval'])) {
1821  return $res;
1822  }
1823  $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1824 
1825  // Process UNIQUE settings:
1826  // 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
1827  if ($field && !empty($res['value'])) {
1828  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1829  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id, $realPid);
1830  }
1831  if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1832  $res['value'] = $this->‪getUnique($table, $field, $res['value'], $id);
1833  }
1834  }
1835 
1836  return $res;
1837  }
1838 
1850  protected function ‪checkValueForPassword(
1851  string $value,
1852  array $tcaFieldConf,
1853  string $table,
1854  int|string $id,
1855  int $realPid,
1856  array $incomingFieldArray = []
1857  ): array {
1858  // Always trim the value
1859  $value = trim($value);
1860 
1861  // Early return if required validation fails
1862  // Note: The "required" check is evaluated but does not yet lead to an error, see
1863  // the comment in the DataHandler::validateValueForRequired() for more information.
1864  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
1865  return [];
1866  }
1867 
1868  // Early return, if password hashing is disabled and the table is not fe_users or be_users
1869  if (!($tcaFieldConf['hashed'] ?? true) && !in_array($table, ['fe_users', 'be_users'], true)) {
1870  return [
1871  'value' => $value,
1872  ];
1873  }
1874 
1875  // An incoming value is either the salted password if the user did not change existing password
1876  // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
1877  // The strategy is to see if a salt instance can be created from the incoming value. If so,
1878  // no new password was submitted and we keep the value. If no salting instance can be created,
1879  // incoming value must be a new plain text value that needs to be hashed.
1880  $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
1881  $mode = $table === 'fe_users' ? 'FE' : 'BE';
1882  $isNewUser = str_contains((string)$id, 'NEW');
1883  $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
1884 
1885  try {
1886  $hashFactory->get($value, $mode);
1887  } catch (InvalidPasswordHashException $e) {
1888  // We got no salted password instance, incoming value must be a new plaintext password
1889  // Validate new password against password policy for field
1890  $passwordPolicy = $tcaFieldConf['passwordPolicy'] ?? '';
1891  $passwordPolicyValidator = GeneralUtility::makeInstance(
1892  PasswordPolicyValidator::class,
1894  is_string($passwordPolicy) ? $passwordPolicy : ''
1895  );
1896 
1897  $contextData = new ContextData(
1898  loginMode: $mode,
1899  newUsername: $incomingFieldArray['username'] ?? '',
1900  newUserFirstName: $incomingFieldArray['first_name'] ?? '',
1901  newUserLastName: $incomingFieldArray['last_name'] ?? '',
1902  newUserFullName: $incomingFieldArray['realName'] ?? '',
1903  );
1904  $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch(
1906  $contextData,
1907  $incomingFieldArray,
1908  self::class
1909  )
1910  );
1911  $contextData = $event->getContextData();
1912 
1913  $isValidPassword = $passwordPolicyValidator->isValidPassword($value, $contextData);
1914  if (!$isValidPassword) {
1915  $message = $this->‪getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:dataHandler.passwordNotSaved');
1916  $this->‪log(
1917  $table,
1918  (int)$id,
1919  SystemLogDatabaseAction::UPDATE,
1920  0,
1921  SystemLogErrorClassification::WARNING,
1922  $message . implode('. ', $passwordPolicyValidator->getValidationErrors()),
1923  -1,
1924  [
1925  'table' => $table,
1926  'uid' => (string)$id,
1927  ],
1928  $realPid
1929  );
1930 
1931  // Password not valid for existing user. Stopping here, password won't be changed
1932  if (!$isNewUser) {
1933  return [];
1934  }
1935  // Password not valid for new user. To prevent empty passwords in the database, we set a random password.
1936  $value = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
1937  }
1938 
1939  // Get an instance of the current configured salted password strategy and hash the value
1940  $value = $newHashInstance->getHashedPassword($value);
1941  }
1942 
1943  return [
1944  'value' => $value,
1945  ];
1946  }
1962  protected function ‪checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
1963  {
1964  $workspaceId = $this->‪BE_USER->workspace;
1965  $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
1966  $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray ?? []);
1967  // Generate a value if there is none, otherwise ensure that all characters are cleaned up
1968  if ($value === '') {
1969  $value = $helper->generate($fullRecord, $realPid);
1970  } else {
1971  $value = $helper->sanitize($value);
1972  }
1973 
1974  // Return directly in case no evaluations are defined
1975  if (empty($tcaFieldConf['eval'])) {
1976  return ['value' => $value];
1977  }
1978 
1979  $state = ‪RecordStateFactory::forName($table)
1980  ->fromArray($fullRecord, $realPid, $id);
1981  $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1982  if (in_array('unique', $evalCodesArray, true)) {
1983  $value = $helper->buildSlugForUniqueInTable($value, $state);
1984  }
1985  if (in_array('uniqueInSite', $evalCodesArray, true)) {
1986  $value = $helper->buildSlugForUniqueInSite($value, $state);
1987  }
1988  if (in_array('uniqueInPid', $evalCodesArray, true)) {
1989  $value = $helper->buildSlugForUniqueInPid($value, $state);
1990  }
1991 
1992  return ['value' => $value];
1993  }
1994 
2005  protected function ‪checkValueForLanguage(int $value, string $table, string $field): array
2006  {
2007  // If given table is localizable and the given field is the defined
2008  // languageField, check if the selected language is allowed for the user.
2009  // Note: Usually this method should never be reached, in case the language value is
2010  // not valid, since recordEditAccessInternals checks for proper permission beforehand.
2011  if (BackendUtility::isTableLocalizable($table)
2012  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') === $field
2013  && !$this->‪BE_USER->checkLanguageAccess($value)
2014  ) {
2015  return [];
2016  }
2017 
2018  // @todo Should we also check if the language is allowed for the current site - if record has site context?
2019 
2020  return ['value' => $value];
2021  }
2022 
2033  protected function ‪checkValueForLink(string $value, array $tcaFieldConf, string $table, int|string $id, string $field): array
2034  {
2035  // Always trim the value
2036  $value = trim($value);
2037 
2038  // Early return if required validation fails
2039  // Note: The "required" check is evaluated but does not yet lead to an error, see
2040  // the comment in the DataHandler::validateValueForRequired() for more information.
2041  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
2042  return [];
2043  }
2044 
2045  // Early return if an empty allow list is defined for the link types
2046  if (is_array($tcaFieldConf['allowedTypes'] ?? false) && $tcaFieldConf['allowedTypes'] === []) {
2047  return [];
2048  }
2049 
2050  if ($value !== '') {
2051  // Extract the actual link from the link definition for further evaluation
2052  $linkParameter = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode($value)['url'];
2053  if ($linkParameter === '') {
2054  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link definition for the field "{field}" of the table "{table}"', -1, ['link' => $value, 'field' => $field, 'table' => $table]);
2055  $value = '';
2056  } else {
2057  // Try to resolve the actual link type and compare with the allow list
2058  try {
2059  $linkData = GeneralUtility::makeInstance(LinkService::class)->resolve($linkParameter);
2060  $linkType = $linkData['type'] ?? '';
2061  $linkIdentifier = $linkData['identifier'] ?? '';
2062  if (is_array($tcaFieldConf['allowedTypes'] ?? false)
2063  && ($tcaFieldConf['allowedTypes'][0] ?? '') !== '*'
2064  && !in_array($linkType, $tcaFieldConf['allowedTypes'], true)
2065  && ($linkType !== 'record' || !in_array($linkIdentifier, $tcaFieldConf['allowedTypes'], true))
2066  ) {
2067  $message = $linkIdentifier !== ''
2068  ? 'Link type "record" with identifier "{type}" is not allowed for the field "{field}" of the table "{table}"'
2069  : 'Link type "{type}" is not allowed for the field "{field}" of the table "{table}"';
2070  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, $message, -1, ['type' => $linkIdentifier ?: $linkType, 'field' => $field, 'table' => $table]);
2071  $value = '';
2072  }
2073  } catch (UnknownLinkHandlerException $e) {
2074  $this->‪log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link for the field "{field}" of the table "{table}"', -1, ['link' => $value, 'field' => $field, 'table' => $table]);
2075  $value = '';
2076  }
2077  }
2078  }
2079 
2080  return ['value' => $value];
2081  }
2082 
2094  protected function ‪checkValueForCategory(
2095  array $result,
2096  string $value,
2097  array $tcaFieldConf,
2098  string $table,
2099  $id,
2100  string $status,
2101  string $field
2102  ): array {
2103  // Exploded comma-separated values and remove duplicates
2104  $valueArray = array_unique(GeneralUtility::trimExplode(',', $value, true));
2105  // If an exclusive key is found, discard all others:
2106  if ($tcaFieldConf['exclusiveKeys'] ?? false) {
2107  $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
2108  foreach ($valueArray as $index => $key) {
2109  if (in_array($key, $exclusiveKeys, true)) {
2110  $valueArray = [$index => $key];
2111  break;
2112  }
2113  }
2114  }
2115  $unsetResult = false;
2116  if (str_contains($value, 'NEW')) {
2117  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2118  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2119  $this->remapStack[] = [
2120  'func' => 'checkValue_category_processDBdata',
2121  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field],
2122  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2123  'field' => $field,
2124  ];
2125  $unsetResult = true;
2126  } else {
2127  $valueArray = $this->‪checkValue_category_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2128  }
2129  if ($unsetResult) {
2130  unset($result['value']);
2131  } else {
2132  $newVal = implode(',', $this->‪checkValue_checkMax($tcaFieldConf, $valueArray));
2133  $result['value'] = $newVal !== '' ? $newVal : 0;
2134  }
2135  return $result;
2136  }
2137 
2144  protected function ‪checkValueForDatetime(mixed $value, array $tcaFieldConf): array
2145  {
2146  $format = $tcaFieldConf['format'] ?? 'datetime';
2147  if (!in_array($format, ['datetime', 'date', 'time', 'timesec'], true)) {
2148  // Early return if format is not valid
2149  return [];
2150  }
2151 
2152  // Handle native date/time fields
2153  $isNativeDateTimeField = false;
2154  $nativeDateTimeFieldFormat = '';
2155  $nativeDateTimeFieldEmptyValue = '';
2156  $nativeDateTimeFieldResetValue = '';
2157  $nativeDateTimeType = $tcaFieldConf['dbType'] ?? '';
2158  if (in_array($nativeDateTimeType, ‪QueryHelper::getDateTimeTypes(), true)) {
2159  $isNativeDateTimeField = true;
2160  $dateTimeFormats = ‪QueryHelper::getDateTimeFormats();
2161  $nativeDateTimeFieldFormat = $dateTimeFormats[$nativeDateTimeType]['format'];
2162  $nativeDateTimeFieldEmptyValue = $dateTimeFormats[$nativeDateTimeType]['empty'];
2163  $nativeDateTimeFieldResetValue = $dateTimeFormats[$nativeDateTimeType]['reset'];
2164  if (empty($value)) {
2165  $value = null;
2166  } else {
2167  // Convert the date/time into a timestamp for the sake of the checks
2168  // We expect the ISO 8601 $value to contain a UTC timezone specifier.
2169  // We explicitly fallback to UTC if no timezone specifier is given (e.g. for copy operations).
2170  $dateTime = new \DateTime((string)$value, new \DateTimeZone('UTC'));
2171  // The timestamp (UTC) returned by getTimestamp() will be converted to
2172  // a local time string by gmdate() later.
2173  $value = $value === $nativeDateTimeFieldEmptyValue ? null : $dateTime->getTimestamp();
2174  }
2175  }
2176 
2177  if (!$this->‪validateValueForRequired($tcaFieldConf, (string)$value)) {
2178  return [];
2179  }
2180 
2181  if ((string)$value !== '' && !‪MathUtility::canBeInterpretedAsInteger((string)$value)) {
2182  if (($format === 'time' || $format === 'timesec')) {
2183  $value = (new \DateTime((string)$value))->getTimestamp();
2184  } else {
2185  // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2186  // For instance "1999-11-11T11:11:11Z"
2187  // Since the user actually specifies the time in the server's local time, we need to mangle this
2188  // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2189  // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2190  // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2191  // TZ difference.
2192  try {
2193  // Make the date from JS a timestamp
2194  $value = (new \DateTime((string)$value))->getTimestamp();
2195  } catch (\‪Exception) {
2196  // set the default timezone value to achieve the value of 0 as a result
2197  $value = (int)date('Z', 0);
2198  }
2199 
2200  // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2201  $value -= (int)date('Z', $value);
2202  }
2203  }
2204 
2205  // Skip range validation, if the default value equals 0 and the input value is 0, "0" or an empty string.
2206  // This is needed for timestamp date fields with ['range']['lower'] set.
2207  $skipRangeValidation =
2208  isset($tcaFieldConf['default'], $value)
2209  && (int)$tcaFieldConf['default'] === 0
2210  && ($value === '' || $value === '0' || $value === 0);
2211 
2212  // Checking range of value:
2213  if (!$skipRangeValidation && is_array($tcaFieldConf['range'] ?? null)) {
2214  if (isset($tcaFieldConf['range']['upper']) && ceil($value) > (int)$tcaFieldConf['range']['upper']) {
2215  $value = (int)$tcaFieldConf['range']['upper'];
2216  }
2217  if (isset($tcaFieldConf['range']['lower']) && floor($value) < (int)$tcaFieldConf['range']['lower']) {
2218  $value = (int)$tcaFieldConf['range']['lower'];
2219  }
2220  }
2221 
2222  // Handle native date/time fields
2223  if ($isNativeDateTimeField) {
2224  if ($tcaFieldConf['nullable'] ?? false) {
2225  // Convert the timestamp back to a date/time if not null
2226  $value = $value !== null ? gmdate($nativeDateTimeFieldFormat, $value) : null;
2227  } else {
2228  // Convert the timestamp back to a date/time
2229  $value = $value !== null ? gmdate($nativeDateTimeFieldFormat, $value) : $nativeDateTimeFieldResetValue;
2230  }
2231  } else {
2232  // Ensure value is always an int if no native field is used
2233  $value = (int)$value;
2234  }
2235 
2236  $res['value'] = $value;
2237  return $res;
2238  }
2239 
2252  protected function ‪checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field)
2253  {
2254  $items = $tcaFieldConf['items'] ?? null;
2255  if (!empty($tcaFieldConf['itemsProcFunc'])) {
2256  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
2257  $items = $processingService->getProcessingItems(
2258  $table,
2259  $realPid,
2260  $field,
2261  $this->checkValue_currentRecord,
2262  $tcaFieldConf,
2263  $tcaFieldConf['items']
2264  );
2265  }
2266 
2267  $itemC = 0;
2268  if ($items !== null) {
2269  $itemC = count($items);
2270  }
2271  if (!$itemC) {
2272  $itemC = 1;
2273  }
2274  $maxV = (2 ** $itemC) - 1;
2275  if ($value < 0) {
2276  // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error.
2277  $value = 0;
2278  }
2279  if ($value > $maxV) {
2280  // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic,
2281  // @todo: changing list of items, then it may happen that a value is transformed and vanished checkboxes
2282  // @todo: are permanently removed from the value.
2283  // @todo: Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a
2284  // @todo: error message to the user - dynamic item sets via itemProcFunc on check would be a bad idea anyway.
2285  $value = $value & $maxV;
2286  }
2287  if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) {
2288  $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
2289  $otherRecordsWithSameValue = [];
2290  $maxCheckedRecords = 0;
2291  // @todo These checks do not consider the language of the current record (if available).
2292  if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) {
2293  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value, $realPid);
2294  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid'];
2295  }
2296  if (in_array('maximumRecordsChecked', $evalCodesArray, true)) {
2297  $otherRecordsWithSameValue = $this->‪getRecordsWithSameValue($table, $id, $field, $value);
2298  $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked'];
2299  }
2300 
2301  // there are more than enough records with value "1" in the DB
2302  // if so, set this value to "0" again
2303  if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) {
2304  $value = 0;
2305  $this->‪log(
2306  $table,
2307  $id,
2308  SystemLogDatabaseAction::CHECK,
2309  0,
2310  SystemLogErrorClassification::USER_ERROR,
2311  'Could not activate checkbox for field "{field}". A total of {max} record(s) can have this checkbox activated. Uncheck other records first in order to activate the checkbox of this record',
2312  -1,
2313  ['field' => $field, 'max' => $maxCheckedRecords]
2314  );
2315  }
2316  }
2317  $res['value'] = $value;
2318  return $res;
2319  }
2320 
2333  protected function ‪checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $pid, $field)
2334  {
2335  if (is_array($tcaFieldConf['items'])) {
2336  foreach ($tcaFieldConf['items'] as $set) {
2337  if ((string)$set['value'] === (string)$value) {
2338  $res['value'] = $value;
2339  break;
2340  }
2341  }
2342  }
2343 
2344  // if no value was found and an itemsProcFunc is defined, check that for the value
2345  if (!empty($tcaFieldConf['itemsProcFunc']) && empty($res['value'])) {
2346  $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
2347  $processedItems = $processingService->getProcessingItems(
2348  $table,
2349  $pid,
2350  $field,
2351  $this->checkValue_currentRecord,
2352  $tcaFieldConf,
2353  $tcaFieldConf['items']
2354  );
2355 
2356  foreach ($processedItems as $set) {
2357  if ((string)$set['value'] === (string)$value) {
2358  $res['value'] = $value;
2359  break;
2360  }
2361  }
2362  }
2363 
2364  return $res;
2365  }
2366 
2374  protected function ‪checkValueForJson(array|string $value, array $tcaFieldConf): array
2375  {
2376  if (is_string($value)) {
2377  if ($value === '') {
2378  $value = [];
2379  } else {
2380  try {
2381  $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
2382  if ($value === null) {
2383  // Unset value as it could not be decoded
2384  return [];
2385  }
2386  } catch (\JsonException) {
2387  // Unset value as it is invalid
2388  return [];
2389  }
2390  }
2391  }
2392 
2393  if (!$this->‪validateValueForRequired($tcaFieldConf, $value)) {
2394  // Unset value as it is required
2395  return [];
2396  }
2397 
2398  return [
2399  'value' => $value,
2400  ];
2401  }
2402 
2415  protected function ‪checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field)
2416  {
2417  // Detecting if value sent is an array and if so, implode it around a comma:
2418  if (is_array($value)) {
2419  $value = implode(',', $value);
2420  } else {
2421  $value = (string)$value;
2422  }
2423 
2424  // When values are sent as group or select they come as comma-separated values which are exploded by this function:
2425  $valueArray = $this->‪checkValue_group_select_explodeSelectGroupValue($value);
2426  // If multiple is not set, remove duplicates:
2427  if (!($tcaFieldConf['multiple'] ?? false)) {
2428  $valueArray = array_unique($valueArray);
2429  }
2430  // If an exclusive key is found, discard all others:
2431  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) {
2432  $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
2433  foreach ($valueArray as $index => $key) {
2434  if (in_array($key, $exclusiveKeys, true)) {
2435  $valueArray = [$index => $key];
2436  break;
2437  }
2438  }
2439  }
2440  // 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?)
2441  // 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!!
2442  $valueArray = $this->‪applyFiltersToValues($tcaFieldConf, $valueArray);
2443  // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed!
2444  if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) {
2445  $preCount = count($valueArray);
2446  foreach ($valueArray as $index => $key) {
2447  if (!$this->‪BE_USER->checkAuthMode($table, $field, $key)) {
2448  unset($valueArray[$index]);
2449  }
2450  }
2451  // 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.
2452  if ($preCount && empty($valueArray)) {
2453  return [];
2454  }
2455  }
2456  // For select types which has a foreign table attached:
2457  $unsetResult = false;
2458  if ($tcaFieldConf['type'] === 'group' || ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['foreign_table'] ?? false))) {
2459  // check, if there is a NEW... id in the value, that should be substituted later
2460  if (str_contains($value, 'NEW')) {
2461  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2462  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2463  $this->remapStack[] = [
2464  'func' => 'checkValue_group_select_processDBdata',
2465  'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field],
2466  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5],
2467  'field' => $field,
2468  ];
2469  $unsetResult = true;
2470  } else {
2471  $valueArray = $this->‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field);
2472  }
2473  }
2474  if (!$unsetResult) {
2475  $newVal = $this->‪checkValue_checkMax($tcaFieldConf, $valueArray);
2476  $res['value'] = $this->‪castReferenceValue(implode(',', $newVal), $tcaFieldConf, str_contains($value, 'NEW'));
2477  } else {
2478  unset($res['value']);
2479  }
2480  return $res;
2481  }
2482 
2492  protected function ‪checkValueForUuid(string $value, array $tcaFieldConf): array
2493  {
2494  if (Uuid::isValid($value)) {
2495  return ['value' => $value];
2496  }
2497 
2498  if ($tcaFieldConf['required'] ?? true) {
2499  return ['value' => (string)match ((int)($tcaFieldConf['version'] ?? 0)) {
2500  6 => Uuid::v6(),
2501  7 => Uuid::v7(),
2502  default => Uuid::v4()
2503  }];
2504  }
2505  // Unset invalid uuid - in case a field value is not required
2506  return [];
2507  }
2508 
2515  protected function ‪applyFiltersToValues(array $tcaFieldConfiguration, array $values)
2516  {
2517  if (!is_array($tcaFieldConfiguration['filter'] ?? null)) {
2518  return $values;
2519  }
2520  foreach ($tcaFieldConfiguration['filter'] as $filter) {
2521  if (empty($filter['userFunc'])) {
2522  continue;
2523  }
2524  $parameters = $filter['parameters'] ?? [];
2525  if (!is_array($parameters)) {
2526  $parameters = [];
2527  }
2528  $parameters['values'] = $values;
2529  $parameters['tcaFieldConfig'] = $tcaFieldConfiguration;
2530  $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this);
2531  if (!is_array($values)) {
2532  throw new \RuntimeException('Expected userFunc filter "' . $filter['userFunc'] . '" to return an array. Got ' . gettype($values) . '.', 1336051942);
2533  }
2534  }
2535  return $values;
2536  }
2537 
2554  protected function ‪checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $field)
2555  {
2556  if (!is_array($value)) {
2557  $res['value'] = $value;
2558  return $res;
2559  }
2560 
2561  // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied.
2562  // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord
2563  // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new
2564  // records we do know the expected PID, so we send that with this special parameter. Only active when larger than zero.
2565  $row = $this->checkValue_currentRecord;
2566  if ($status === 'new') {
2567  $row['pid'] = $realPid;
2568  }
2569 
2570  // Get data structure. The methods may throw various exceptions, with some of them being
2571  // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
2572  // and substitute with a dummy DS.
2573  try {
2574  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
2575  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
2576  ['config' => $tcaFieldConf],
2577  $table,
2578  $field,
2579  $row
2580  );
2581  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
2582  } catch (‪InvalidIdentifierException) {
2583  $dataStructureArray = ['sheets' => ['sDEF' => []]];
2584  }
2585 
2586  // Get current value array:
2587  $currentValueArray = (string)$curValue !== '' ? ‪GeneralUtility::xml2array($curValue) : [];
2588  if (!is_array($currentValueArray)) {
2589  $currentValueArray = [];
2590  }
2591  // Remove all old meta for languages...
2592  // Evaluation of input values:
2593  $value['data'] = $this->‪checkValue_flex_procInData($value['data'] ?? [], $currentValueArray['data'] ?? [], $dataStructureArray, [$table, $id, $curValue, $status, $realPid, $recFID, $tscPID]);
2594  // Create XML from input value:
2595  $xmlValue = $this->‪checkValue_flexArray2Xml($value);
2596 
2597  // 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
2598  // (provided that the current value was already stored IN the charset that the new value is converted to).
2599  $xmlAsArray = ‪GeneralUtility::xml2array($xmlValue);
2600 
2601  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) {
2602  $hookObject = GeneralUtility::makeInstance($className);
2603  if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) {
2604  $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $xmlAsArray);
2605  }
2606  }
2607 
2608  ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $xmlAsArray);
2609  $xmlValue = $this->‪checkValue_flexArray2Xml($currentValueArray);
2610 
2611  $xmlAsArray = ‪GeneralUtility::xml2array($xmlValue);
2612  $xmlAsArray = $this->‪sortAndDeleteFlexSectionContainerElements($xmlAsArray, $dataStructureArray);
2613  $xmlValue = $this->‪checkValue_flexArray2Xml($xmlAsArray);
2614 
2615  $res['value'] = $xmlValue;
2616  return $res;
2617  }
2618 
2626  public function ‪checkValue_flexArray2Xml($array): string
2627  {
2628  $flexObj = GeneralUtility::makeInstance(FlexFormTools::class);
2629  return $flexObj->flexArray2Xml($array);
2630  }
2631 
2637  private function ‪sortAndDeleteFlexSectionContainerElements(array $valueArray, array $dataStructure): array
2638  {
2639  foreach (($dataStructure['sheets'] ?? []) as $dataStructureSheetName => $dataStructureSheetDefinition) {
2640  if (!isset($dataStructureSheetDefinition['ROOT']['el']) || !is_array($dataStructureSheetDefinition['ROOT']['el'])) {
2641  continue;
2642  }
2643  $dataStructureFields = $dataStructureSheetDefinition['ROOT']['el'];
2644  foreach ($dataStructureFields as $dataStructureFieldName => $dataStructureFieldDefinition) {
2645  if (isset($dataStructureFieldDefinition['type']) && $dataStructureFieldDefinition['type'] === 'array'
2646  && isset($dataStructureFieldDefinition['section']) && (string)$dataStructureFieldDefinition['section'] === '1'
2647  ) {
2648  // Found a possible section within flex form data structure definition
2649  if (!is_array($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] ?? false)) {
2650  // No containers in data
2651  continue;
2652  }
2653  $newElements = [];
2654  $containerCounter = 0;
2655  foreach ($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] as $sectionKey => $sectionValues) {
2656  // Remove to-delete containers
2657  $action = $sectionValues['_ACTION'] ?? '';
2658  if ($action === 'DELETE') {
2659  continue;
2660  }
2661  if (($sectionValues['_ACTION'] ?? '') === '') {
2662  $sectionValues['_ACTION'] = $containerCounter;
2663  }
2664  $newElements[$sectionKey] = $sectionValues;
2665  $containerCounter++;
2666  }
2667  // Resort by action key
2668  uasort($newElements, function ($a, $b) {
2669  return (int)$a['_ACTION'] - (int)$b['_ACTION'];
2670  });
2671  foreach ($newElements as &$element) {
2672  // Do not store action key
2673  unset($element['_ACTION']);
2674  }
2675  $valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] = $newElements;
2676  }
2677  }
2678  }
2679  return $valueArray;
2680  }
2681 
2694  public function ‪checkValue_inline($res, $value, $tcaFieldConf, $PP, $field, array $additionalData = null)
2695  {
2696  [$table, $id, , $status] = $PP;
2697  $this->‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData);
2698  }
2699 
2715  public function ‪checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, array $additionalData = null)
2716  {
2717  if (!$tcaFieldConf['foreign_table']) {
2718  // Fatal error, inline fields should always have a foreign_table defined
2719  return false;
2720  }
2721  // When values are sent they come as comma-separated values which are exploded by this function:
2722  $valueArray = GeneralUtility::trimExplode(',', $value);
2723  // Remove duplicates: (should not be needed)
2724  $valueArray = array_unique($valueArray);
2725  // Example for received data:
2726  // $value = 45,NEW4555fdf59d154,12,123
2727  // We need to decide whether we use the stack or can save the relation directly.
2728  if (!empty($value) && (str_contains($value, 'NEW') || !‪MathUtility::canBeInterpretedAsInteger($id))) {
2729  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2730  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2731  $this->remapStack[] = [
2732  'func' => 'checkValue_inline_processDBdata',
2733  'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData],
2734  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2735  'additionalData' => $additionalData,
2736  'field' => $field,
2737  ];
2738  unset($res['value']);
2739  } elseif ($value || ‪MathUtility::canBeInterpretedAsInteger($id)) {
2740  $res['value'] = $this->‪checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2741  }
2742  return $res;
2743  }
2744 
2748  public function ‪checkValueForFile(
2749  array $res,
2750  string $value,
2751  array $tcaFieldConf,
2752  string $table,
2753  int|string $id,
2754  string $field,
2755  ?array $additionalData = null
2756  ): array {
2757  $valueArray = array_unique(GeneralUtility::trimExplode(',', $value));
2758  if ($value !== '' && (str_contains($value, 'NEW') || !‪MathUtility::canBeInterpretedAsInteger($id))) {
2759  $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2760  $this->‪addNewValuesToRemapStackChildIds($valueArray);
2761  $this->remapStack[] = [
2762  'func' => 'checkValue_file_processDBdata',
2763  'args' => [$valueArray, $tcaFieldConf, $id, $table],
2764  'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
2765  'additionalData' => $additionalData,
2766  'field' => $field,
2767  ];
2768  unset($res['value']);
2769  } elseif ($value !== '' || ‪MathUtility::canBeInterpretedAsInteger($id)) {
2770  $res['value'] = $this->‪checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table);
2771  }
2772  return $res;
2773  }
2774 
2784  public function ‪checkValue_checkMax($tcaFieldConf, $valueArray)
2785  {
2786  // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function
2787  // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check
2788  // before? Probably because we could not evaluate the validity of record uids etc... Hmm...
2789  // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first,
2790  // if the field is actual used regarding the CType.
2791  $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999;
2792  return array_slice($valueArray, 0, $maxitems);
2793  }
2794 
2795  /*********************************************
2796  *
2797  * Helper functions for evaluation functions.
2798  *
2799  ********************************************/
2812  public function ‪getUnique($table, $field, $value, $id, $newPid = 0)
2813  {
2814  if (!is_array(‪$GLOBALS['TCA'][$table]) || !is_array(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
2815  // Field is not configured in TCA
2816  return $value;
2817  }
2818 
2819  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude') {
2820  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
2821  $l10nParent = (int)$this->checkValue_currentRecord[$transOrigPointerField];
2822  if ($l10nParent > 0) {
2823  // Current record is a translation and l10n_mode "exclude" just copies the value from source language
2824  return $value;
2825  }
2826  }
2827 
2828  $newValue = $originalValue = $value;
2829  $queryBuilder = $this->‪getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid);
2830  // For as long as records with the test-value existing, try again (with incremented numbers appended)
2831  $statement = $queryBuilder->prepare();
2832  $result = $statement->executeQuery();
2833  if ($result->fetchOne()) {
2834  for ($counter = 0; $counter <= 100; $counter++) {
2835  $result->free();
2836  $newValue = $value . $counter;
2837  $statement->bindValue(1, $newValue);
2838  $result = $statement->executeQuery();
2839  if (!$result->fetchOne()) {
2840  break;
2841  }
2842  }
2843  $result->free();
2844  }
2845 
2846  if ($originalValue !== $newValue) {
2847  $this->‪log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::WARNING, 'The value of the field "{field}" has been changed from "{originalValue}" to "{newValue}" as it is required to be unique', 1, ['field' => $field, 'originalValue' => $originalValue, 'newValue' => $newValue], $newPid);
2848  }
2849 
2850  return $newValue;
2851  }
2852 
2863  protected function ‪getUniqueCountStatement(
2864  string $value,
2865  string $table,
2866  string $field,
2867  int ‪$uid,
2868  int $pid
2869  ) {
2870  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
2871  $this->‪addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2872  $queryBuilder
2873  ->count('uid')
2874  ->from($table)
2875  ->where(
2876  $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)),
2877  $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter(‪$uid, ‪Connection::PARAM_INT))
2878  );
2879  // ignore translations of current record if field is configured with l10n_mode = "exclude"
2880  if ((‪$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2881  && (‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2882  && (‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') !== '') {
2883  $queryBuilder
2884  ->andWhere(
2885  $queryBuilder->expr()->or(
2886  // records without l10n_parent must be taken into account (in any language)
2887  $queryBuilder->expr()->eq(
2888  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2889  $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT)
2890  ),
2891  // translations of other records must be taken into account
2892  $queryBuilder->expr()->neq(
2893  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2894  $queryBuilder->createPositionalParameter(‪$uid, ‪Connection::PARAM_INT)
2895  )
2896  )
2897  );
2898  }
2899  if ($pid !== 0) {
2900  $queryBuilder->andWhere(
2901  $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, ‪Connection::PARAM_INT))
2902  );
2903  } else {
2904  // pid>=0 for versioning
2905  $queryBuilder->andWhere(
2906  $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, ‪Connection::PARAM_INT))
2907  );
2908  }
2909  return $queryBuilder;
2910  }
2911 
2924  public function ‪getRecordsWithSameValue($tableName, ‪$uid, $fieldName, $value, $pageId = 0)
2925  {
2926  $result = [];
2927  if (empty(‪$GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2928  return $result;
2929  }
2930 
2931  ‪$uid = (int)‪$uid;
2932  $pageId = (int)$pageId;
2933 
2934  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
2935  $queryBuilder->getRestrictions()
2936  ->removeAll()
2937  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2938  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->‪BE_USER->workspace));
2939 
2940  $queryBuilder->select('*')
2941  ->from($tableName)
2942  ->where(
2943  $queryBuilder->expr()->eq(
2944  $fieldName,
2945  $queryBuilder->createNamedParameter($value)
2946  ),
2947  $queryBuilder->expr()->neq(
2948  'uid',
2949  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
2950  )
2951  );
2952 
2953  if ($pageId) {
2954  $queryBuilder->andWhere(
2955  $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ‪Connection::PARAM_INT))
2956  );
2957  }
2958 
2959  $result = $queryBuilder->executeQuery()->fetchAllAssociative();
2960 
2961  return $result;
2962  }
2963 
2971  public function ‪checkValue_text_Eval($value, $evalArray, $is_in)
2972  {
2973  $res = [];
2975  $set = true;
2976  foreach ($evalArray as $func) {
2977  switch ($func) {
2978  case 'trim':
2979  $value = trim((string)$value);
2980  break;
2981  default:
2982  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2983  if (class_exists($func)) {
2984  $evalObj = GeneralUtility::makeInstance($func);
2985  if (method_exists($evalObj, 'evaluateFieldValue')) {
2986  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2987  }
2988  }
2989  }
2990  }
2991  }
2992  if ($set) {
2993  $res['value'] = $value;
2994  }
2995  return $res;
2996  }
2997 
3009  public function ‪checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array
3010  {
3011  $res = [];
3012  $set = true;
3013  foreach ($evalArray as $func) {
3014  switch ($func) {
3015  case 'year':
3016  $value = (int)$value;
3017  break;
3018  case 'md5':
3019  if (strlen($value) !== 32) {
3020  $set = false;
3021  }
3022  break;
3023  case 'trim':
3024  $value = trim($value);
3025  break;
3026  case 'upper':
3027  $value = mb_strtoupper($value, 'utf-8');
3028  break;
3029  case 'lower':
3030  $value = mb_strtolower($value, 'utf-8');
3031  break;
3032  case 'is_in':
3033  $c = mb_strlen($value);
3034  if ($c) {
3035  $newVal = '';
3036  for ($a = 0; $a < $c; $a++) {
3037  $char = mb_substr($value, $a, 1);
3038  if (str_contains($is_in, $char)) {
3039  $newVal .= $char;
3040  }
3041  }
3042  $value = $newVal;
3043  }
3044  break;
3045  case 'nospace':
3046  $value = str_replace(' ', '', $value);
3047  break;
3048  case 'alpha':
3049  $value = preg_replace('/[^a-zA-Z]/', '', $value);
3050  break;
3051  case 'num':
3052  $value = preg_replace('/[^0-9]/', '', $value);
3053  break;
3054  case 'alphanum':
3055  $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
3056  break;
3057  case 'alphanum_x':
3058  $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
3059  break;
3060  case 'domainname':
3061  if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
3062  $value = (string)idn_to_ascii($value);
3063  }
3064  break;
3065  default:
3066  if (isset(‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
3067  if (class_exists($func)) {
3068  $evalObj = GeneralUtility::makeInstance($func);
3069  if (method_exists($evalObj, 'evaluateFieldValue')) {
3070  $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
3071  }
3072  }
3073  }
3074  }
3075  }
3076  if ($set) {
3077  $res['value'] = $value;
3078  }
3079  return $res;
3080  }
3081 
3092  protected function ‪validateValueForRequired(array $tcaFieldConfig, mixed $value): bool
3093  {
3094  if (!isset($tcaFieldConfig['required']) || !$tcaFieldConfig['required']) {
3095  return true;
3096  }
3097 
3098  return !empty($value) || $value === '0';
3099  }
3100 
3113  public function ‪checkValue_category_processDBdata(
3114  array $valueArray,
3115  array $tcaFieldConf,
3116  $id,
3117  string $status,
3118  string $table,
3119  string $field
3120  ): array {
3121  $newRelations = implode(',', $valueArray);
3122  $relationHandler = $this->‪createRelationHandlerInstance();
3123  $relationHandler->start($newRelations, $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
3124  if ($tcaFieldConf['MM'] ?? false) {
3125  $relationHandler->convertItemArray();
3126  if ($status === 'update') {
3127  $relationHandleForOldRelations = $this->‪createRelationHandlerInstance();
3128  $relationHandleForOldRelations->start('', $tcaFieldConf['foreign_table'], $tcaFieldConf['MM'], $id, $table, $tcaFieldConf);
3129  $oldRelations = implode(',', $relationHandleForOldRelations->getValueArray());
3130  $relationHandler->writeMM($tcaFieldConf['MM'], $id);
3131  if ($oldRelations !== $newRelations) {
3132  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = $oldRelations;
3133  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = $newRelations;
3134  } else {
3135  $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = '';
3136  $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = '';
3137  }
3138  } else {
3139  $this->dbAnalysisStore[] = [$relationHandler, $tcaFieldConf['MM'], $id, '', $table];
3140  }
3141  $valueArray = $relationHandler->countItems();
3142  } else {
3143  $valueArray = $relationHandler->getValueArray();
3144  }
3145  return $valueArray;
3146  }
3161  public function ‪checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
3162  {
3163  $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table'];
3164  $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
3165  $newRelations = implode(',', $valueArray);
3166  $dbAnalysis = $this->‪createRelationHandlerInstance();
3167  $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
3168  $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
3169  if ($tcaFieldConf['MM'] ?? false) {
3170  // convert submitted items to use version ids instead of live ids
3171  // (only required for MM relations in a workspace context)
3172  $dbAnalysis->convertItemArray();
3173  if ($status === 'update') {
3174  $oldRelations_dbAnalysis = $this->‪createRelationHandlerInstance();
3175  $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
3176  // Db analysis with $id will initialize with the existing relations
3177  $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
3178  $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
3179  $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, $prep);
3180  if ($oldRelations != $newRelations) {
3181  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
3182  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
3183  } else {
3184  $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
3185  $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
3186  }
3187  } else {
3188  $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
3189  }
3190  $valueArray = $dbAnalysis->countItems();
3191  } else {
3192  $valueArray = $dbAnalysis->getValueArray($prep);
3193  }
3194  // 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.
3195  return $valueArray;
3196  }
3197 
3206  {
3207  $valueArray = GeneralUtility::trimExplode(',', $value, true);
3208  foreach ($valueArray as &$newVal) {
3209  $temp = explode('|', $newVal, 2);
3210  $newVal = str_replace(['|', ','], '', rawurldecode($temp[0]));
3211  }
3212  unset($newVal);
3213  return $valueArray;
3214  }
3215 
3230  public function ‪checkValue_flex_procInData($dataPart, $dataPart_current, $dataStructure, $pParams, $callBackFunc = '', array $workspaceOptions = [])
3231  {
3232  if (is_array($dataPart)) {
3233  foreach ($dataPart as $sKey => $sheetDef) {
3234  if (isset($dataStructure['sheets'][$sKey]) && is_array($dataStructure['sheets'][$sKey]) && is_array($sheetDef)) {
3235  foreach ($sheetDef as $lKey => $lData) {
3237  $dataPart[$sKey][$lKey],
3238  $dataPart_current[$sKey][$lKey] ?? null,
3239  $dataStructure['sheets'][$sKey]['ROOT']['el'] ?? null,
3240  $pParams,
3241  $callBackFunc,
3242  $sKey . '/' . $lKey . '/',
3243  $workspaceOptions
3244  );
3245  }
3246  }
3247  }
3248  }
3249  return $dataPart;
3250  }
3251 
3265  public function ‪checkValue_flex_procInData_travDS(&$dataValues, $dataValues_current, $DSelements, $pParams, $callBackFunc, $structurePath, array $workspaceOptions = [])
3266  {
3267  if (!is_array($DSelements)) {
3268  return;
3269  }
3270 
3271  // For each DS element:
3272  foreach ($DSelements as $key => $dsConf) {
3273  // Array/Section:
3274  if (isset($DSelements[$key]['type']) && $DSelements[$key]['type'] === 'array') {
3275  if (!is_array($dataValues[$key]['el'] ?? null)) {
3276  continue;
3277  }
3278 
3279  if ($DSelements[$key]['section']) {
3280  foreach ($dataValues[$key]['el'] as $ik => $el) {
3281  if (!is_array($el)) {
3282  continue;
3283  }
3284 
3285  if (!is_array($dataValues_current[$key]['el'] ?? false)) {
3286  $dataValues_current[$key]['el'] = [];
3287  }
3288  $theKey = key($el);
3289  if (!is_array($dataValues[$key]['el'][$ik][$theKey]['el'] ?? false)) {
3290  continue;
3291  }
3292 
3294  $dataValues[$key]['el'][$ik][$theKey]['el'],
3295  $dataValues_current[$key]['el'][$ik][$theKey]['el'] ?? [],
3296  $DSelements[$key]['el'][$theKey]['el'] ?? [],
3297  $pParams,
3298  $callBackFunc,
3299  $structurePath . $key . '/el/' . $ik . '/' . $theKey . '/el/',
3300  $workspaceOptions
3301  );
3302  }
3303  } else {
3304  if (!isset($dataValues[$key]['el'])) {
3305  $dataValues[$key]['el'] = [];
3306  }
3307  $this->‪checkValue_flex_procInData_travDS($dataValues[$key]['el'], $dataValues_current[$key]['el'], $DSelements[$key]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/', $workspaceOptions);
3308  }
3309  } else {
3310  $fieldConfiguration = $dsConf['config'] ?? null;
3311  // init with value from config for passthrough fields
3312  if (!empty($fieldConfiguration['type']) && $fieldConfiguration['type'] === 'passthrough') {
3313  if (!empty($dataValues_current[$key]['vDEF'])) {
3314  // If there is existing value, keep it
3315  $dataValues[$key]['vDEF'] = $dataValues_current[$key]['vDEF'];
3316  } elseif (
3317  !empty($fieldConfiguration['default'])
3318  && isset($pParams[1])
3320  ) {
3321  // If is new record and a default is specified for field, use it.
3322  $dataValues[$key]['vDEF'] = $fieldConfiguration['default'];
3323  }
3324  }
3325  if (!is_array($fieldConfiguration) || !isset($dataValues[$key]) || !is_array($dataValues[$key])) {
3326  continue;
3327  }
3328 
3329  foreach ($dataValues[$key] as $vKey => $data) {
3330  if ($callBackFunc) {
3331  if (is_object($this->callBackObj)) {
3332  $res = $this->callBackObj->{$callBackFunc}(
3333  $pParams,
3334  $fieldConfiguration,
3335  $dataValues[$key][$vKey] ?? null,
3336  $dataValues_current[$key][$vKey] ?? null,
3337  $structurePath . $key . '/' . $vKey . '/',
3338  $workspaceOptions
3339  );
3340  } else {
3341  $res = $this->{$callBackFunc}(
3342  $pParams,
3343  $fieldConfiguration,
3344  $dataValues[$key][$vKey] ?? null,
3345  $dataValues_current[$key][$vKey] ?? null,
3346  $structurePath . $key . '/' . $vKey . '/',
3347  $workspaceOptions
3348  );
3349  }
3350  } else {
3351  // Default
3352  [$CVtable, $CVid, $CVcurValue, $CVstatus, $CVrealPid, $CVrecFID, $CVtscPID] = $pParams;
3353 
3354  $additionalData = [
3355  'flexFormId' => $CVrecFID,
3356  'flexFormPath' => trim(rtrim($structurePath, '/') . '/' . $key . '/' . $vKey, '/'),
3357  ];
3358 
3359  $res = $this->‪checkValue_SW(
3360  [],
3361  $dataValues[$key][$vKey] ?? null,
3362  $fieldConfiguration,
3363  $CVtable,
3364  $CVid,
3365  $dataValues_current[$key][$vKey] ?? null,
3366  $CVstatus,
3367  $CVrealPid,
3368  $CVrecFID,
3369  '',
3370  $CVtscPID,
3371  $additionalData
3372  );
3373  }
3374  // Adding the value:
3375  if (isset($res['value'])) {
3376  $dataValues[$key][$vKey] = $res['value'];
3377  }
3378  }
3379  }
3380  }
3381  }
3382 
3394  protected function checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field)
3395  {
3396  $foreignTable = $tcaFieldConf['foreign_table'];
3397  $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
3398  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3399  $dbAnalysis = $this->createRelationHandlerInstance();
3400  $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf);
3401  // IRRE with a pointer field (database normalization):
3402  if ($tcaFieldConf['foreign_field'] ?? false) {
3403  // update record in intermediate table (sorting & pointer uid to parent record)
3404  $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0);
3405  $newValue = $dbAnalysis->countItems(false);
3406  } elseif ($this->getRelationFieldType($tcaFieldConf) === 'mm') {
3407  // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here
3408  $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field);
3409  $newValue = $valueArray[0];
3410  } else {
3411  $valueArray = $dbAnalysis->getValueArray();
3412  // Checking that the number of items is correct:
3413  $valueArray = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
3414  $newValue = $this->castReferenceValue(implode(',', $valueArray), $tcaFieldConf, ($status === 'new'));
3415  }
3416  return $newValue;
3417  }
3418 
3422  protected function checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table): mixed
3423  {
3424  $valueArray = GeneralUtility::makeInstance(FileExtensionFilter::class)->filter(
3425  $valueArray,
3426  (string)($tcaFieldConf['allowed'] ?? ''),
3427  (string)($tcaFieldConf['disallowed'] ?? ''),
3428  $this
3429  );
3430 
3431  $dbAnalysis = $this->createRelationHandlerInstance();
3432  $dbAnalysis->start(implode(',', $valueArray), $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
3433  $dbAnalysis->writeForeignField($tcaFieldConf, $id);
3434  return $dbAnalysis->countItems(false);
3435  }
3436 
3437  /*********************************************
3438  *
3439  * PROCESSING COMMANDS
3440  *
3441  ********************************************/
3448  public function process_cmdmap()
3449  {
3450  // Editing frozen:
3451  if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
3452  $this->log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen');
3453  return false;
3454  }
3455  // Hook initialization:
3456  $hookObjectsArr = [];
3457  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
3458  $hookObj = GeneralUtility::makeInstance($className);
3459  if (method_exists($hookObj, 'processCmdmap_beforeStart')) {
3460  $hookObj->processCmdmap_beforeStart($this);
3461  }
3462  $hookObjectsArr[] = $hookObj;
3463  }
3464  $pasteDatamap = [];
3465  // Traverse command map:
3466  foreach ($this->cmdmap as $table => $_) {
3467  // Check if the table may be modified!
3468  $modifyAccessList = $this->checkModifyAccessList($table);
3469  if (!$modifyAccessList) {
3470  $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', 1, ['table' => $table]);
3471  }
3472  // Check basic permissions and circumstances:
3473  if (!isset(‪$GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->cmdmap[$table]) || !$modifyAccessList) {
3474  continue;
3475  }
3476 
3477  // Traverse the command map:
3478  foreach ($this->cmdmap[$table] as $id => $incomingCmdArray) {
3479  if (!is_array($incomingCmdArray)) {
3480  continue;
3481  }
3482 
3483  if ($table === 'pages') {
3484  // for commands on pages do a pagetree-refresh
3485  $this->pagetreeNeedsRefresh = true;
3486  }
3487 
3488  foreach ($incomingCmdArray as $command => $value) {
3489  $pasteUpdate = false;
3490  if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') {
3491  // Extended paste command: $command is set to "move" or "copy"
3492  // $value['update'] holds field/value pairs which should be updated after copy/move operation
3493  // $value['target'] holds original $value (target of move/copy)
3494  $pasteUpdate = $value['update'];
3495  $value = $value['target'];
3496  }
3497  foreach ($hookObjectsArr as $hookObj) {
3498  if (method_exists($hookObj, 'processCmdmap_preProcess')) {
3499  $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate);
3500  }
3501  }
3502  // Init copyMapping array:
3503  // Must clear this array before call from here to those functions:
3504  // Contains mapping information between new and old id numbers.
3505  $this->copyMappingArray = [];
3506  // process the command
3507  $commandIsProcessed = false;
3508  foreach ($hookObjectsArr as $hookObj) {
3509  if (method_exists($hookObj, 'processCmdmap')) {
3510  $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate);
3511  }
3512  }
3513  // Only execute default commands if a hook hasn't been processed the command already
3514  if (!$commandIsProcessed) {
3515  $procId = $id;
3516  $backupUseTransOrigPointerField = $this->useTransOrigPointerField;
3517  // Branch, based on command
3518  switch ($command) {
3519  case 'move':
3520  $this->moveRecord($table, (int)$id, $value);
3521  break;
3522  case 'copy':
3523  $target = $value['target'] ?? $value;
3524  $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false);
3525  if ($table === 'pages') {
3526  $this->copyPages((int)$id, $target);
3527  } else {
3528  $this->copyRecord($table, (int)$id, $target, true, [], '', 0, $ignoreLocalization);
3529  }
3530  $procId = $this->copyMappingArray[$table][$id] ?? null;
3531  break;
3532  case 'localize':
3533  $this->useTransOrigPointerField = true;
3534  $this->localize($table, (int)$id, $value);
3535  break;
3536  case 'copyToLanguage':
3537  $this->useTransOrigPointerField = false;
3538  $this->localize($table, (int)$id, $value);
3539  break;
3540  case 'inlineLocalizeSynchronize':
3541  $this->inlineLocalizeSynchronize($table, (int)$id, $value);
3542  break;
3543  case 'delete':
3544  $this->deleteAction($table, (int)$id);
3545  break;
3546  case 'undelete':
3547  $this->undeleteRecord((string)$table, (int)$id);
3548  break;
3549  }
3550  $this->useTransOrigPointerField = $backupUseTransOrigPointerField;
3551  if (is_array($pasteUpdate) && $procId > 0) {
3552  $pasteDatamap[$table][$procId] = $pasteUpdate;
3553  }
3554  }
3555  foreach ($hookObjectsArr as $hookObj) {
3556  if (method_exists($hookObj, 'processCmdmap_postProcess')) {
3557  $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap);
3558  }
3559  }
3560  // Merging the copy-array info together for remapping purposes.
3561  ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray);
3562  }
3563  }
3564  }
3565  $copyTCE = $this->getLocalTCE();
3566  $copyTCE->start($pasteDatamap, [], $this->BE_USER);
3567  $copyTCE->process_datamap();
3568  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3569  unset($copyTCE);
3570 
3571  // Finally, before exit, check if there are ID references to remap.
3572  // This might be the case if versioning or copying has taken place!
3573  $this->remapListedDBRecords();
3574  $this->processRemapStack();
3575  foreach ($hookObjectsArr as $hookObj) {
3576  if (method_exists($hookObj, 'processCmdmap_afterFinish')) {
3577  $hookObj->processCmdmap_afterFinish($this);
3578  }
3579  }
3580  if ($this->isOuterMostInstance()) {
3581  $this->referenceIndexUpdater->update();
3582  $this->processClearCacheQueue();
3583  $this->resetNestedElementCalls();
3584  }
3585  }
3586 
3587  /*********************************************
3588  *
3589  * Cmd: Copying
3590  *
3591  ********************************************/
3606  public function copyRecord($table, ‪$uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '', $language = 0, $ignoreLocalization = false)
3607  {
3608  ‪$uid = ($origUid = (int)‪$uid);
3609  // Only copy if the table is defined in $GLOBALS['TCA'], a uid is given and the record wasn't copied before:
3610  if (empty(‪$GLOBALS['TCA'][$table]) || ‪$uid === 0) {
3611  return null;
3612  }
3613  if ($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($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" which does not exist or you do not have permission to read', -1, ['table' => $table, 'uid' => ‪$uid]);
3623  return null;
3624  }
3625 
3626  // 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...
3627  $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, ‪$uid, $destPid);
3628 
3629  // Check if table is allowed on destination page
3630  if (!$this->isTableAllowedForThisPage($tscPID, $table)) {
3631  $this->log($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record "{table}:{uid}" on a page ({pid}) that can\'t store record type', -1, ['table' => $table, 'uid' => ‪$uid, 'pid' => $tscPID]);
3632  return null;
3633  }
3634 
3635  $fullLanguageCheckNeeded = $table !== 'pages';
3636  // Used to check language and general editing rights
3637  if (!$ignoreLocalization && ($language <= 0 || !$this->BE_USER->checkLanguageAccess($language)) && !$this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, false, $fullLanguageCheckNeeded)) {
3638  $this->log($table, ‪$uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" without having permissions to do so [{reason}]', -1, ['table' => $table, 'uid' => ‪$uid, 'reason' => $this->BE_USER->errorMsg]);
3639  return null;
3640  }
3641 
3642  $data = [];
3643  $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));
3644  BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace);
3645  $row = BackendUtility::purgeComputedPropertiesFromRecord($row);
3646 
3647  // Initializing:
3648  $theNewID = ‪StringUtility::getUniqueId('NEW');
3649  $enableField = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] ?? '';
3650  $headerField = ‪$GLOBALS['TCA'][$table]['ctrl']['label'];
3651  // Getting "copy-after" fields if applicable:
3652  $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields((string)$table, (int)abs($destPid)) : [];
3653  // Page TSconfig related:
3654  $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3655  $tE = $this->getTableEntries($table, $TSConfig);
3656  // Traverse ALL fields of the selected record:
3657  foreach ($row as $field => $value) {
3658  if (!in_array($field, $nonFields, true)) {
3659  // Get TCA configuration for the field:
3660  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3661  // Preparation/Processing of the value:
3662  // "pid" is hardcoded of course:
3663  // isset() won't work here, since values can be NULL in each of the arrays
3664  // except setDefaultOnCopyArray, since we exploded that from a string
3665  if ($field === 'pid') {
3666  $value = $destPid;
3667  } elseif (array_key_exists($field, $overrideValues)) {
3668  // Override value...
3669  $value = $overrideValues[$field];
3670  } elseif (array_key_exists($field, $copyAfterFields)) {
3671  // Copy-after value if available:
3672  $value = $copyAfterFields[$field];
3673  } else {
3674  // Hide at copy may override:
3675  if ($first && $field == $enableField
3676  && (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3677  && !$this->neverHideAtCopy
3678  && !($tE['disableHideAtCopy'] ?? false)
3679  ) {
3680  $value = 1;
3681  }
3682  // Prepend label on copy:
3683  if ($first && $field == $headerField
3684  && (‪$GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3685  && !($tE['disablePrependAtCopy'] ?? false)
3686  ) {
3687  $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0);
3688  }
3689  // Processing based on the TCA config field type (files, references, flexforms...)
3690  $value = $this->copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $tscPID, $language);
3691  }
3692  // Add value to array.
3693  $data[$table][$theNewID][$field] = $value;
3694  }
3695  }
3696  // Overriding values:
3697  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3698  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3699  }
3700  // Setting original UID:
3701  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3702  $data[$table][$theNewID][‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = ‪$uid;
3703  }
3704  // Do the copy by simply submitting the array through DataHandler:
3705  $copyTCE = $this->getLocalTCE();
3706  $copyTCE->start($data, [], $this->BE_USER);
3707  $copyTCE->process_datamap();
3708  // Getting the new UID:
3709  $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null;
3710  if ($theNewSQLID) {
3711  $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3712  // Keep automatically versionized record information:
3713  if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3714  $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3715  }
3716  }
3717  $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3718  unset($copyTCE);
3719  if (!$ignoreLocalization && $language == 0) {
3720  //repointing the new translation records to the parent record we just created
3721  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
3722  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3723  }
3724  // This value is evaluated in DataMapItem->getType() so it is very important
3725  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3726  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3727  }
3728  $this->copyL10nOverlayRecords($table, ‪$uid, $destPid, $first, $overrideValues, $excludeFields);
3729  }
3730 
3731  return $theNewSQLID;
3732  }
3733 
3742  public function copyPages(‪$uid, $destPid)
3743  {
3744  // Initialize:
3745  ‪$uid = (int)‪$uid;
3746  $destPid = (int)$destPid;
3747 
3748  $copyTablesAlongWithPage = $this->getAllowedTablesToCopyWhenCopyingAPage();
3749  // Begin to copy pages if we're allowed to:
3750  if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3751  // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3752  // This method also copies the localizations of a page
3753  $theNewRootID = $this->copySpecificPage(‪$uid, $destPid, $copyTablesAlongWithPage, true);
3754  // If we're going to copy recursively
3755  if ($theNewRootID && $this->copyTree) {
3756  // Get ALL subpages to copy (read-permissions are respected!):
3757  $CPtable = $this->int_pageTreeInfo([], ‪$uid, (int)$this->copyTree, $theNewRootID);
3758  // Now copying the subpages:
3759  foreach ($CPtable as $thePageUid => $thePagePid) {
3760  $newPid = $this->copyMappingArray['pages'][$thePagePid] ?? null;
3761  if (isset($newPid)) {
3762  $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3763  } else {
3764  $this->log('pages', ‪$uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3765  break;
3766  }
3767  }
3768  }
3769  } else {
3770  $this->log('pages', ‪$uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page {uid} without permission to this table', -1, ['uid' => ‪$uid]);
3771  }
3772  }
3773 
3781  protected function getAllowedTablesToCopyWhenCopyingAPage(): array
3782  {
3783  // Finding list of tables to copy.
3784  // These are the tables, the user may modify
3785  $copyTablesArray = $this->admin ? $this->compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3786  // If not all tables are allowed then make a list of allowed tables.
3787  // That is the tables that figure in both allowed tables AND the copyTable-list
3788  if (!str_contains($this->copyWhichTables, '*')) {
3789  $definedTablesToCopy = GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3790  // Pages are always allowed
3791  $definedTablesToCopy[] = 'pages';
3792  $definedTablesToCopy = array_flip($definedTablesToCopy);
3793  foreach ($copyTablesArray as $k => $table) {
3794  if (!$table || !isset($definedTablesToCopy[$table])) {
3795  unset($copyTablesArray[$k]);
3796  }
3797  }
3798  }
3799  $copyTablesArray = array_unique($copyTablesArray);
3800  return $copyTablesArray;
3801  }
3812  public function copySpecificPage(‪$uid, $destPid, $copyTablesArray, $first = false)
3813  {
3814  // Copy the page itself:
3815  $theNewRootID = $this->copyRecord('pages', ‪$uid, $destPid, $first);
3816  $currentWorkspaceId = (int)$this->BE_USER->workspace;
3817  // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3818  ‪if ($theNewRootID) {
3819  foreach ($copyTablesArray as $table) {
3820  // All records under the page is copied.
3821  if ($table && is_array(‪$GLOBALS['TCA'][$table]) && $table !== 'pages') {
3822  ‪$fields = ['uid'];
3823  $languageField = null;
3824  $transOrigPointerField = null;
3825  $translationSourceField = null;
3826  if (BackendUtility::isTableLocalizable($table)) {
3827  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
3828  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3829  ‪$fields[] = $languageField;
3830  ‪$fields[] = $transOrigPointerField;
3831  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3832  $translationSourceField = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3833  ‪$fields[] = $translationSourceField;
3834  }
3835  }
3836  $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3837  if ($isTableWorkspaceEnabled) {
3838  ‪$fields[] = 't3ver_oid';
3839  ‪$fields[] = 't3ver_state';
3840  ‪$fields[] = 't3ver_wsid';
3841  }
3842  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3843  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3844  $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3845  $queryBuilder
3846  ->select(...‪$fields)
3847  ->from($table)
3848  ->where(
3849  $queryBuilder->expr()->eq(
3850  'pid',
3851  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
3852  )
3853  );
3854  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3855  $queryBuilder->orderBy(‪$GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3856  }
3857  $queryBuilder->addOrderBy('uid');
3858  try {
3859  $result = $queryBuilder->executeQuery();
3860  $rows = [];
3861  $movedLiveIds = [];
3862  $movedLiveRecords = [];
3863  while ($row = $result->fetchAssociative()) {
3864  if ($isTableWorkspaceEnabled && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) {
3865  $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3866  }
3867  $rows[(int)$row['uid']] = $row;
3868  }
3869  // Resolve placeholders of workspace versions
3870  if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3871  // If a record was moved within the page, the PlainDataResolver needs the moved record
3872  // but not the original live version, otherwise the moved record is not considered at all.
3873  // For this reason, we find the live ids, where there was also a moved record in the SQL
3874  // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3875  // see changeContentSortingAndCopyDraftPage test
3876  foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3877  if (isset($rows[$liveId])) {
3878  $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3879  unset($rows[$liveId]);
3880  }
3881  }
3882  $rows = array_reverse(
3883  $this->resolveVersionedRecords(
3884  $table,
3885  implode(',', ‪$fields),
3886  ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '',
3887  array_keys($rows)
3888  ),
3889  true
3890  );
3891  foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3892  $rows[$movePlaceHolderId] = $liveRecord;
3893  }
3894  }
3895  if (is_array($rows)) {
3896  $languageSourceMap = [];
3897  $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3898  $doRemap = false;
3899  foreach ($rows as $row) {
3900  // Skip localized records that will be processed in
3901  // copyL10nOverlayRecords() on copying the default language record
3902  $transOrigPointer = $row[$transOrigPointerField] ?? 0;
3903  if (!empty($languageField)
3904  && $row[$languageField] > 0
3905  && $transOrigPointer > 0
3906  && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))
3907  ) {
3908  continue;
3909  }
3910  // Copying each of the underlying records...
3911  $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3912  if ($translationSourceField) {
3913  $languageSourceMap[$row['uid']] = $newUid;
3914  if ($row[$languageField] > 0) {
3915  $doRemap = true;
3916  }
3917  }
3918  }
3919  if ($doRemap) {
3920  //remap is needed for records in non-default language records in the "free mode"
3921  $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3922  }
3923  }
3924  } catch (DBALException $e) {
3925  $databaseErrorMessage = $e->getPrevious()->getMessage();
3926  $this->log($table, ‪$uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: {reason}', -1, ['reason' => $databaseErrorMessage]);
3927  }
3928  }
3929  }
3930  $this->processRemapStack();
3931  return $theNewRootID;
3932  }
3933  return null;
3934  }
3935 
3952  public function copyRecord_raw($table, ‪$uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3953  {
3954  ‪$uid = (int)‪$uid;
3955  // Stop any actions if the record is marked to be deleted:
3956  // (this can occur if IRRE elements are versionized and child elements are removed)
3957  if ($this->isElementToBeDeleted($table, ‪$uid)) {
3958  return null;
3959  }
3960  // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3961  if (!‪$GLOBALS['TCA'][$table] || !‪$uid || $this->isRecordCopied($table, ‪$uid)) {
3962  return null;
3963  }
3964 
3965  // Fetch record with permission check
3966  $row = $this->recordInfoWithPermissionCheck($table, ‪$uid, ‪Permission::PAGE_SHOW);
3967 
3968  // This checks if the record can be selected which is all that a copy action requires.
3969  if ($row === false) {
3970  $this->log(
3971  $table,
3972  ‪$uid,
3973  SystemLogDatabaseAction::INSERT,
3974  0,
3975  SystemLogErrorClassification::USER_ERROR,
3976  'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3977  );
3978  return null;
3979  }
3980 
3981  // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3982  $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3983 
3984  // Merge in override array.
3985  $row = array_merge($row, $overrideArray);
3986  // Traverse ALL fields of the selected record:
3987  foreach ($row as $field => $value) {
3989  if (!in_array($field, $nonFields, true)) {
3990  // Get TCA configuration for the field:
3991  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3992  if (is_array($conf)) {
3993  // Processing based on the TCA config field type (files, references, flexforms...)
3994  $value = $this->copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3995  }
3996  // Add value to array.
3997  $row[$field] = $value;
3998  }
3999  }
4000  $row['pid'] = $pid;
4001  // Setting original UID:
4002  if (‪$GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
4003  $row[‪$GLOBALS['TCA'][$table]['ctrl']['origUid']] = ‪$uid;
4004  }
4005  // Do the copy by internal function
4006  $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid);
4007 
4008  // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
4009  // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
4010  // that refers e.g. to a tt_content record is marked as deleted. The tt_content record then needs a reference index update.
4011  // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
4012  if (!empty($workspaceOptions)) {
4013  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
4014  }
4015 
4016  if ($theNewSQLID) {
4017  $this->dbAnalysisStoreExec();
4018  $this->dbAnalysisStore = [];
4019  return $this->copyMappingArray[$table][‪$uid] = $theNewSQLID;
4020  }
4021  return null;
4022  }
4023 
4034  public function insertNewCopyVersion($table, $fieldArray, $realPid)
4035  {
4036  $id = ‪StringUtility::getUniqueId('NEW');
4037  // $fieldArray is set as current record.
4038  // 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...
4039  $this->checkValue_currentRecord = $fieldArray;
4040  // Makes sure that transformations aren't processed on the copy.
4041  $backupDontProcessTransformations = $this->dontProcessTransformations;
4042  $this->dontProcessTransformations = true;
4043  // Traverse record and input-process each value:
4044  foreach ($fieldArray as $field => $fieldValue) {
4045  if (isset(‪$GLOBALS['TCA'][$table]['columns'][$field])) {
4046  // Evaluating the value.
4047  $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
4048  if (isset($res['value'])) {
4049  $fieldArray[$field] = $res['value'];
4050  }
4051  }
4052  }
4053  // System fields being set:
4054  if (‪$GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
4055  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['crdate']] = ‪$GLOBALS['EXEC_TIME'];
4056  }
4057  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
4058  $fieldArray[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
4059  }
4060  // Finally, insert record:
4061  $this->insertDB($table, $id, $fieldArray, BackendUtility::isTableWorkspaceEnabled($table));
4062  // Resets dontProcessTransformations to the previous state.
4063  $this->dontProcessTransformations = $backupDontProcessTransformations;
4064  // Return new id:
4065  return $this->substNEWwithIDs[$id] ?? null;
4066  }
4067 
4084  public function copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
4085  {
4086  $relationFieldType = $this->getRelationFieldType($conf);
4087  // Get the localization mode for the current (parent) record (keep|select):
4088  // Register if there are references to take care of or MM is used on an inline field (no change to value):
4089  if ($this->isReferenceField($conf) || $relationFieldType === 'mm') {
4090  $value = $this->copyRecord_processManyToMany($table, ‪$uid, $field, $value, $conf, $language);
4091  } elseif ($relationFieldType !== false) {
4092  $value = $this->copyRecord_processRelation($table, ‪$uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
4093  }
4094  // 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())
4095  if (isset($conf['type']) && $conf['type'] === 'flex') {
4096  // Get current value array:
4097  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
4098  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
4099  ['config' => $conf],
4100  $table,
4101  $field,
4102  $row
4103  );
4104  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
4105  $currentValue = is_string($value) ? ‪GeneralUtility::xml2array($value) : null;
4106  // Traversing the XML structure, processing files:
4107  if (is_array($currentValue)) {
4108  $currentValue['data'] = $this->checkValue_flex_procInData($currentValue['data'] ?? [], [], $dataStructureArray, [$table, ‪$uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
4109  // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
4110  $value = $currentValue;
4111  }
4112  }
4113  return $value;
4114  }
4115 
4127  protected function copyRecord_processManyToMany($table, ‪$uid, $field, $value, $conf, $language)
4128  {
4129  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
4130  $allowedTablesArray = GeneralUtility::trimExplode(',', $allowedTables, true);
4131  $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
4132  $mmTable = !empty($conf['MM']) ? $conf['MM'] : '';
4133 
4134  $dbAnalysis = $this->createRelationHandlerInstance();
4135  $dbAnalysis->start($value, $allowedTables, $mmTable, ‪$uid, $table, $conf);
4136  $purgeItems = false;
4137 
4138  // Check if referenced records of select or group fields should also be localized in general.
4139  // A further check is done in the loop below for each table name.
4140  if ($language > 0 && $mmTable === '' && !empty($conf['localizeReferencesAtParentLocalization'])) {
4141  // Check whether allowed tables can be localized.
4142  $localizeTables = [];
4143  foreach ($allowedTablesArray as $allowedTable) {
4144  $localizeTables[$allowedTable] = BackendUtility::isTableLocalizable($allowedTable);
4145  }
4146 
4147  foreach ($dbAnalysis->itemArray as $index => $item) {
4148  // No action required, if referenced tables cannot be localized (current value will be used).
4149  if (empty($localizeTables[$item['table']])) {
4150  continue;
4151  }
4152 
4153  // Since select or group fields can reference many records, check whether there's already a localization.
4154  $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
4155  if ($recordLocalization) {
4156  $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
4157  } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . $language) === false) {
4158  $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], $item['id'], $language);
4159  }
4160  }
4161  $purgeItems = true;
4162  }
4163 
4164  if ($purgeItems || $mmTable !== '') {
4165  $dbAnalysis->purgeItemArray();
4166  $value = implode(',', $dbAnalysis->getValueArray($prependName));
4167  }
4168  // Setting the value in this array will notify the remapListedDBRecords() function that this field MAY need references to be corrected.
4169  if ($value) {
4170  $this->registerDBList[$table][‪$uid][$field] = $value;
4171  }
4172 
4173  return $value;
4174  }
4175 
4189  protected function copyRecord_processRelation(
4190  $table,
4191  ‪$uid,
4192  $field,
4193  $value,
4194  $row,
4195  $conf,
4196  $realDestPid,
4197  $language,
4198  array $workspaceOptions
4199  ) {
4200  // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
4201  $dbAnalysis = $this->createRelationHandlerInstance();
4202  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
4203  // Walk through the items, copy them and remember the new id:
4204  foreach ($dbAnalysis->itemArray as $k => $v) {
4205  $newId = null;
4206  // If language is set and differs from original record, this isn't a copy action but a localization of our parent/ancestor:
4207  if ($language > 0 && BackendUtility::isTableLocalizable($table) && $language != $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]) {
4208  // Children should be localized when the parent gets localized the first time, just do it:
4209  $newId = $this->localize($v['table'], $v['id'], $language);
4210  } else {
4211  if (!‪MathUtility::canBeInterpretedAsInteger($realDestPid)) {
4212  $newId = $this->copyRecord($v['table'], $v['id'], -(int)($v['id']));
4213  // If the destination page id is a NEW string, keep it on the same page
4214  } elseif ($this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($v['table'])) {
4215  // A filled $workspaceOptions indicated that this call
4216  // has it's origin in previous versionizeRecord() processing
4217  if (!empty($workspaceOptions)) {
4218  // Versions use live default id, thus the "new"
4219  // id is the original live default child record
4220  $newId = $v['id'];
4221  $this->versionizeRecord(
4222  $v['table'],
4223  $v['id'],
4224  $workspaceOptions['label'] ?? 'Auto-created for WS #' . $this->BE_USER->workspace,
4225  $workspaceOptions['delete'] ?? false
4226  );
4227  // Otherwise just use plain copyRecord() to create placeholders etc.
4228  } else {
4229  // If a record has been copied already during this request,
4230  // prevent superfluous duplication and use the existing copy
4231  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
4232  $newId = $this->copyMappingArray[$v['table']][$v['id']];
4233  } else {
4234  $newId = $this->copyRecord($v['table'], $v['id'], $realDestPid);
4235  }
4236  }
4237  } elseif ($this->BE_USER->workspace > 0 && !BackendUtility::isTableWorkspaceEnabled($v['table'])) {
4238  // We are in workspace context creating a new parent version and have a child table
4239  // that is not workspace aware. We don't do anything with this child.
4240  continue;
4241  } else {
4242  // If a record has been copied already during this request,
4243  // prevent superfluous duplication and use the existing copy
4244  if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
4245  $newId = $this->copyMappingArray[$v['table']][$v['id']];
4246  } else {
4247  $newId = $this->copyRecord_raw($v['table'], $v['id'], $realDestPid, [], $workspaceOptions);
4248  }
4249  }
4250  }
4251  // If the current field is set on a page record, update the pid of related child records:
4252  if ($table === 'pages') {
4253  $this->registerDBPids[$v['table']][$v['id']] = ‪$uid;
4254  } elseif (isset($this->registerDBPids[$table][‪$uid])) {
4255  $this->registerDBPids[$v['table']][$v['id']] = $this->registerDBPids[$table][‪$uid];
4256  }
4257  $dbAnalysis->itemArray[$k]['id'] = $newId;
4258  }
4259  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4260  $value = implode(',', $dbAnalysis->getValueArray());
4261  $this->registerDBList[$table][‪$uid][$field] = $value;
4262 
4263  return $value;
4264  }
4265 
4280  public function copyRecord_flexFormCallBack($pParams, $dsConf, $dataValue, $_1, $_2, $workspaceOptions)
4281  {
4282  // Extract parameters:
4283  [$table, ‪$uid, $field, $realDestPid] = $pParams;
4284  // If references are set for this field, set flag so they can be corrected later (in ->remapListedDBRecords())
4285  if (($this->isReferenceField($dsConf) || $this->getRelationFieldType($dsConf) !== false) && (string)$dataValue !== '') {
4286  $dataValue = $this->copyRecord_procBasedOnFieldType($table, ‪$uid, $field, $dataValue, [], $dsConf, $realDestPid, 0, $workspaceOptions);
4287  $this->registerDBList[$table][‪$uid][$field] = 'FlexForm_reference';
4288  }
4289  // Return
4290  return ['value' => $dataValue];
4291  }
4292 
4304  public function copyL10nOverlayRecords($table, ‪$uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '')
4305  {
4306  // There's no need to perform this for tables that are not localizable
4307  if (!BackendUtility::isTableLocalizable($table)) {
4308  return;
4309  }
4310 
4311  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
4312  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4313 
4314  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4315  $queryBuilder->getRestrictions()
4316  ->removeAll()
4317  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4318  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
4319 
4320  $queryBuilder->select('*')
4321  ->from($table)
4322  ->where(
4323  $queryBuilder->expr()->eq(
4324  $transOrigPointerField,
4325  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT, ':pointer')
4326  )
4327  );
4328 
4329  // Never copy the actual placeholders around, as the newly copied records are
4330  // always created as new record / new placeholder pairs
4331  if (BackendUtility::isTableWorkspaceEnabled($table)) {
4332  $queryBuilder->andWhere(
4333  $queryBuilder->expr()->neq(
4334  't3ver_state',
4335  VersionState::DELETE_PLACEHOLDER->value
4336  )
4337  );
4338  }
4339 
4340  // If $destPid is < 0, get the pid of the record with uid equal to abs($destPid)
4341  $tscPID = BackendUtility::getTSconfig_pidValue($table, ‪$uid, $destPid) ?? 0;
4342  // Get the localized records to be copied
4343  $l10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4344  if (is_array($l10nRecords)) {
4345  $localizedDestPids = [];
4346  // If $destPid < 0, then it is the uid of the original language record we are inserting after
4347  if ($destPid < 0) {
4348  // Get the localized records of the record we are inserting after
4349  $queryBuilder->setParameter('pointer', abs($destPid), ‪Connection::PARAM_INT);
4350  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4351  // Index the localized record uids by language
4352  if (is_array($destL10nRecords)) {
4353  foreach ($destL10nRecords as ‪$record) {
4354  $localizedDestPids[‪$record[$languageField]] = -‪$record['uid'];
4355  }
4356  }
4357  }
4358  $languageSourceMap = [
4359  ‪$uid => $overrideValues[$transOrigPointerField],
4360  ];
4361  // Copy the localized records after the corresponding localizations of the destination record
4362  foreach ($l10nRecords as ‪$record) {
4363  $localizedDestPid = (int)($localizedDestPids[‪$record[$languageField]] ?? 0);
4364  if ($localizedDestPid < 0) {
4365  $newUid = $this->copyRecord($table, ‪$record['uid'], $localizedDestPid, $first, $overrideValues, $excludeFields, ‪$record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4366  } else {
4367  $newUid = $this->copyRecord($table, ‪$record['uid'], $destPid < 0 ? $tscPID : $destPid, $first, $overrideValues, $excludeFields, ‪$record[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
4368  }
4369  $languageSourceMap[‪$record['uid']] = $newUid;
4370  }
4371  $this->copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap);
4372  }
4373  }
4374 
4382  protected function copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap)
4383  {
4384  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4385  return;
4386  }
4387  $translationSourceFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'];
4388  $translationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
4389 
4390  //We can avoid running these update queries by sorting the $l10nRecords by languageSource dependency (in copyL10nOverlayRecords)
4391  //and first copy records depending on default record (and map the field).
4392  foreach ($l10nRecords as ‪$record) {
4393  $oldSourceUid = ‪$record[$translationSourceFieldName];
4394  if ($oldSourceUid <= 0 && ‪$record[$translationParentFieldName] > 0) {
4395  //BC fix - in connected mode 'translationSource' field should not be 0
4396  $oldSourceUid = ‪$record[$translationParentFieldName];
4397  }
4398  if ($oldSourceUid > 0) {
4399  if (empty($languageSourceMap[$oldSourceUid])) {
4400  // we don't have mapping information available e.g when copyRecord returned null
4401  continue;
4402  }
4403  $newFieldValue = $languageSourceMap[$oldSourceUid];
4404  $updateFields = [
4405  $translationSourceFieldName => $newFieldValue,
4406  ];
4407  if (isset($languageSourceMap[‪$record['uid']])) {
4408  GeneralUtility::makeInstance(ConnectionPool::class)
4409  ->getConnectionForTable($table)
4410  ->update($table, $updateFields, ['uid' => (int)$languageSourceMap[‪$record['uid']]]);
4411  if ($this->BE_USER->workspace > 0) {
4412  GeneralUtility::makeInstance(ConnectionPool::class)
4413  ->getConnectionForTable($table)
4414  ->update($table, $updateFields, ['t3ver_oid' => (int)$languageSourceMap[‪$record['uid']], 't3ver_wsid' => $this->BE_USER->workspace]);
4415  }
4416  }
4417  }
4418  }
4419  }
4420 
4421  /*********************************************
4422  *
4423  * Cmd: Moving, Localizing
4424  *
4425  ********************************************/
4434  public function moveRecord($table, ‪$uid, $destPid)
4435  {
4436  if (!‪$GLOBALS['TCA'][$table]) {
4437  return;
4438  }
4439 
4440  // In case the record to be moved turns out to be an offline version,
4441  // we have to find the live version and work on that one.
4442  if ($lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, ‪$uid, 'uid')) {
4443  ‪$uid = $lookForLiveVersion['uid'];
4444  }
4445  // Initialize:
4446  $destPid = (int)$destPid;
4447  // Get this before we change the pid (for logging)
4448  $propArr = $this->getRecordProperties($table, ‪$uid);
4449  $moveRec = $this->getRecordProperties($table, ‪$uid, true);
4450  // This is the actual pid of the moving to destination
4451  $resolvedPid = $this->resolvePid($table, $destPid);
4452  // 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.
4453  // If the record is a page, then there are two options: If the page is moved within itself,
4454  // (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.
4455  if ($table !== 'pages' || $resolvedPid == $moveRec['pid']) {
4456  // Edit rights for the record...
4457  $mayMoveAccess = $this->checkRecordUpdateAccess($table, ‪$uid);
4458  } else {
4459  $mayMoveAccess = $this->doesRecordExist($table, ‪$uid, ‪Permission::PAGE_DELETE);
4460  }
4461  // Finding out, if the record may be moved TO another place. Here we check insert-rights (non-pages = edit, pages = new),
4462  // unless the pages are moved on the same pid, then edit-rights are checked
4463  if ($table !== 'pages' || $resolvedPid != $moveRec['pid']) {
4464  // Insert rights for the record...
4465  $mayInsertAccess = $this->checkRecordInsertAccess($table, $resolvedPid, SystemLogDatabaseAction::MOVE);
4466  } else {
4467  $mayInsertAccess = $this->checkRecordUpdateAccess($table, ‪$uid);
4468  }
4469  // Checking if there is anything else disallowing moving the record by checking if editing is allowed
4470  $fullLanguageCheckNeeded = $table !== 'pages';
4471  $mayEditAccess = $this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, false, $fullLanguageCheckNeeded);
4472  // If moving is allowed, begin the processing:
4473  if (!$mayEditAccess) {
4474  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to do so [{reason}]', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'reason' => $this->BE_USER->errorMsg], $propArr['event_pid']);
4475  return;
4476  }
4477 
4478  if (!$mayMoveAccess) {
4479  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to do so', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4480  return;
4481  }
4482 
4483  if (!$mayInsertAccess) {
4484  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) without having permissions to insert', 14, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4485  return;
4486  }
4487 
4488  $recordWasMoved = false;
4489  // Move the record via a hook, used e.g. for versioning
4490  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4491  $hookObj = GeneralUtility::makeInstance($className);
4492  if (method_exists($hookObj, 'moveRecord')) {
4493  $hookObj->moveRecord($table, ‪$uid, $destPid, $propArr, $moveRec, $resolvedPid, $recordWasMoved, $this);
4494  }
4495  }
4496  // Move the record if a hook hasn't moved it yet
4497  if (!$recordWasMoved) {
4498  $this->moveRecord_raw($table, ‪$uid, $destPid);
4499  }
4500  }
4501 
4512  public function moveRecord_raw($table, ‪$uid, $destPid)
4513  {
4514  $sortColumn = ‪$GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
4515  $origDestPid = $destPid;
4516  // This is the actual pid of the moving to destination
4517  $resolvedPid = $this->resolvePid($table, $destPid);
4518  // Checking if the pid is negative, but no sorting row is defined. In that case, find the correct pid.
4519  // Basically this check make the error message 4-13 meaning less... But you can always remove this check if you
4520  // prefer the error instead of a no-good action (which is to move the record to its own page...)
4521  if (($destPid < 0 && !$sortColumn) || $destPid >= 0) {
4522  $destPid = $resolvedPid;
4523  }
4524  // Get this before we change the pid (for logging)
4525  $propArr = $this->getRecordProperties($table, ‪$uid);
4526  $moveRec = $this->getRecordProperties($table, ‪$uid, true);
4527  // Prepare user defined objects (if any) for hooks which extend this function:
4528  $hookObjectsArr = [];
4529  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4530  $hookObjectsArr[] = GeneralUtility::makeInstance($className);
4531  }
4532  // Timestamp field:
4533  $updateFields = [];
4534  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
4535  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
4536  }
4537 
4538  // Check if this is a translation of a page, if so then it just needs to be kept "sorting" in sync
4539  // Usually called from moveL10nOverlayRecords()
4540  if ($table === 'pages') {
4541  $defaultLanguagePageUid = $this->getDefaultLanguagePageId((int)‪$uid);
4542  // In workspaces, the default language page may have been moved to a different pid than the
4543  // default language page record of live workspace. In this case, localized pages need to be
4544  // moved to the pid of the workspace move record.
4545  $defaultLanguagePageWorkspaceOverlay = BackendUtility::getWorkspaceVersionOfRecord((int)$this->BE_USER->workspace, 'pages', $defaultLanguagePageUid, 'uid');
4546  if (is_array($defaultLanguagePageWorkspaceOverlay)) {
4547  $defaultLanguagePageUid = (int)$defaultLanguagePageWorkspaceOverlay['uid'];
4548  }
4549  if ($defaultLanguagePageUid !== (int)‪$uid) {
4550  // If the default language page has been moved, localized pages need to be moved to
4551  // that pid and sorting, too.
4552  $originalTranslationRecord = $this->recordInfo($table, $defaultLanguagePageUid);
4553  $updateFields[$sortColumn] = $originalTranslationRecord[$sortColumn];
4554  $destPid = $originalTranslationRecord['pid'];
4555  }
4556  }
4557 
4558  // Insert as first element on page (where uid = $destPid)
4559  if ($destPid >= 0) {
4560  if ($table !== 'pages' || $this->destNotInsideSelf($destPid, ‪$uid)) {
4561  // Clear cache before moving
4562  [$parentUid] = BackendUtility::getTSCpid($table, ‪$uid, '');
4563  $this->registerRecordIdForPageCacheClearing($table, ‪$uid, $parentUid);
4564  // Setting PID
4565  $updateFields['pid'] = $destPid;
4566  // Table is sorted by 'sortby'
4567  if ($sortColumn && !isset($updateFields[$sortColumn])) {
4568  $sortNumber = $this->getSortNumber($table, ‪$uid, $destPid);
4569  $updateFields[$sortColumn] = $sortNumber;
4570  }
4571  // Check for child records that have also to be moved
4572  $this->moveRecord_procFields($table, ‪$uid, $destPid);
4573  // Create query for update:
4574  GeneralUtility::makeInstance(ConnectionPool::class)
4575  ->getConnectionForTable($table)
4576  ->update($table, $updateFields, ['uid' => (int)‪$uid]);
4577  // Check for the localizations of that element
4578  $this->moveL10nOverlayRecords($table, ‪$uid, $destPid, $destPid);
4579  // Call post processing hooks:
4580  foreach ($hookObjectsArr as $hookObj) {
4581  if (method_exists($hookObj, 'moveRecord_firstElementPostProcess')) {
4582  $hookObj->moveRecord_firstElementPostProcess($table, ‪$uid, $destPid, $moveRec, $updateFields, $this);
4583  }
4584  }
4585 
4586  $this->getRecordHistoryStore()->moveRecord($table, ‪$uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4587  if ($this->enableLogging) {
4588  // Logging...
4589  $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4590  if ($destPid != $propArr['pid']) {
4591  // Logged to old page
4592  $newPropArr = $this->getRecordProperties($table, ‪$uid);
4593  $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4594  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) to page "{pageTitle}" ({pid})', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $newpagePropArr['header'], 'pid' => $newPropArr['pid']], $propArr['pid']);
4595  // Logged to new page
4596  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) from page "{pageTitle}" ({pid}))', 3, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4597  } else {
4598  // Logged to new page
4599  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) on page "{pageTitle}" ({pid})', 4, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4600  }
4601  }
4602  // Clear cache after moving
4603  $this->registerRecordIdForPageCacheClearing($table, ‪$uid);
4604  $this->fixUniqueInPid($table, ‪$uid);
4605  $this->fixUniqueInSite($table, (int)‪$uid);
4606  if ($table === 'pages') {
4607  $this->fixUniqueInSiteForSubpages((int)‪$uid);
4608  }
4609  } elseif ($this->enableLogging) {
4610  $destPropArr = $this->getRecordProperties('pages', $destPid);
4611  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page "{title}" ({uid}) to inside of its own rootline (at page "{pageTitle}" ({pid}))', 10, ['title' => $propArr['header'], 'uid' => ‪$uid, 'pageTitle' => $destPropArr['header'], 'pid' => $destPid], $propArr['pid']);
4612  }
4613  } elseif ($sortColumn) {
4614  // Put after another record
4615  // Table is being sorted
4616  // Save the position to which the original record is requested to be moved
4617  $originalRecordDestinationPid = $destPid;
4618  $sortInfo = $this->getSortNumber($table, ‪$uid, $destPid);
4619  // If not an array, there was an error (which is already logged)
4620  if (is_array($sortInfo)) {
4621  // Setting the destPid to the new pid of the record.
4622  $destPid = $sortInfo['pid'];
4623  if ($table !== 'pages' || $this->destNotInsideSelf($destPid, ‪$uid)) {
4624  // clear cache before moving
4625  $this->registerRecordIdForPageCacheClearing($table, ‪$uid);
4626  // We now update the pid and sortnumber (if not set for page translations)
4627  $updateFields['pid'] = $destPid;
4628  if (!isset($updateFields[$sortColumn])) {
4629  $updateFields[$sortColumn] = $sortInfo['sortNumber'];
4630  }
4631  // Check for child records that have also to be moved
4632  $this->moveRecord_procFields($table, ‪$uid, $destPid);
4633  // Create query for update:
4634  GeneralUtility::makeInstance(ConnectionPool::class)
4635  ->getConnectionForTable($table)
4636  ->update($table, $updateFields, ['uid' => (int)‪$uid]);
4637  // Check for the localizations of that element
4638  $this->moveL10nOverlayRecords($table, ‪$uid, $destPid, $originalRecordDestinationPid);
4639  // Call post processing hooks:
4640  foreach ($hookObjectsArr as $hookObj) {
4641  if (method_exists($hookObj, 'moveRecord_afterAnotherElementPostProcess')) {
4642  $hookObj->moveRecord_afterAnotherElementPostProcess($table, ‪$uid, $destPid, $origDestPid, $moveRec, $updateFields, $this);
4643  }
4644  }
4645  $this->getRecordHistoryStore()->moveRecord($table, ‪$uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4646  if ($this->enableLogging) {
4647  // Logging...
4648  $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4649  if ($destPid != $propArr['pid']) {
4650  // Logged to old page
4651  $newPropArr = $this->getRecordProperties($table, ‪$uid);
4652  $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4653  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) to page "{pageTitle}" ({pid})', 2, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $newpagePropArr['header'], 'pid' => $newPropArr['pid']], $propArr['pid']);
4654  // Logged to old page
4655  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) from page "{pageTitle}" ({pid})', 3, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4656  } else {
4657  // Logged to old page
4658  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record "{title}" ({table}:{uid}) on page "{pageTitle}" ({pid})', 4, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid, 'pageTitle' => $oldpagePropArr['header'], 'pid' => $propArr['pid']], $destPid);
4659  }
4660  }
4661  // Clear cache after moving
4662  $this->registerRecordIdForPageCacheClearing($table, ‪$uid);
4663  $this->fixUniqueInPid($table, ‪$uid);
4664  $this->fixUniqueInSite($table, (int)‪$uid);
4665  if ($table === 'pages') {
4666  $this->fixUniqueInSiteForSubpages((int)‪$uid);
4667  }
4668  } elseif ($this->enableLogging) {
4669  $destPropArr = $this->getRecordProperties('pages', $destPid);
4670  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page "{title}" ({uid}) to inside of its own rootline (at page "{pageTitle}" [{pid}])', 10, ['title' => $propArr['header'], 'uid' => ‪$uid, 'pageTitle' => $destPropArr['header'], 'pid' => $destPid], $propArr['pid']);
4671  }
4672  } else {
4673  $this->log($table, ‪$uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "{title}" ({table}:{uid}) to after another record, although the table has no sorting row', 13, ['title' => $propArr['header'], 'table' => $table, 'uid' => ‪$uid], $propArr['event_pid']);
4674  }
4675  }
4676  }
4677 
4687  public function moveRecord_procFields($table, ‪$uid, $destPid)
4688  {
4689  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
4690  if (is_array($row) && (int)$destPid !== (int)$row['pid']) {
4691  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
4692  foreach ($row as $field => $value) {
4693  $this->moveRecord_procBasedOnFieldType($table, ‪$uid, $destPid, $value, $conf[$field]['config'] ?? []);
4694  }
4695  }
4696  }
4697 
4708  public function moveRecord_procBasedOnFieldType($table, ‪$uid, $destPid, $value, $conf): void
4709  {
4710  if (($conf['behaviour']['disableMovingChildrenWithParent'] ?? false)
4711  || !in_array($this->getRelationFieldType($conf), ['list', 'field'], true)
4712  ) {
4713  return;
4714  }
4715 
4716  if ($table === 'pages') {
4717  // If the relations are related to a page record, make sure they reside at that page and not at its parent
4718  $destPid = ‪$uid;
4719  }
4720 
4721  $dbAnalysis = $this->createRelationHandlerInstance();
4722  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
4723 
4724  // Moving records to a positive destination will insert each
4725  // record at the beginning, thus the order is reversed here:
4726  foreach (array_reverse($dbAnalysis->itemArray) as $item) {
4727  $this->moveRecord($item['table'], $item['id'], $destPid);
4728  }
4729  }
4730 
4740  public function moveL10nOverlayRecords($table, ‪$uid, $destPid, $originalRecordDestinationPid)
4741  {
4742  // There's no need to perform this for non-localizable tables
4743  if (!BackendUtility::isTableLocalizable($table)) {
4744  return;
4745  }
4746 
4747  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4748  $queryBuilder->getRestrictions()
4749  ->removeAll()
4750  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4751  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace));
4752 
4753  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
4754  $transOrigPointerField = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4755  $l10nRecords = $queryBuilder->select('*')
4756  ->from($table)
4757  ->where(
4758  $queryBuilder->expr()->eq(
4759  $transOrigPointerField,
4760  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT, ':pointer')
4761  )
4762  )
4763  ->executeQuery()
4764  ->fetchAllAssociative();
4765 
4766  if (is_array($l10nRecords)) {
4767  $localizedDestPids = [];
4768  // If $$originalRecordDestinationPid < 0, then it is the uid of the original language record we are inserting after
4769  if ($originalRecordDestinationPid < 0) {
4770  // Get the localized records of the record we are inserting after
4771  $queryBuilder->setParameter('pointer', abs($originalRecordDestinationPid), ‪Connection::PARAM_INT);
4772  $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
4773  // Index the localized record uids by language
4774  if (is_array($destL10nRecords)) {
4775  foreach ($destL10nRecords as ‪$record) {
4776  $localizedDestPids[‪$record[$languageField]] = -‪$record['uid'];
4777  }
4778  }
4779  }
4780  // Move the localized records after the corresponding localizations of the destination record
4781  foreach ($l10nRecords as ‪$record) {
4782  $localizedDestPid = (int)($localizedDestPids[‪$record[$languageField]] ?? 0);
4783  if ($localizedDestPid < 0) {
4784  $this->moveRecord($table, ‪$record['uid'], $localizedDestPid);
4785  } else {
4786  $this->moveRecord($table, ‪$record['uid'], $destPid);
4787  }
4788  }
4789  }
4790  }
4791 
4801  public function localize($table, ‪$uid, $language)
4802  {
4803  $newId = false;
4804  ‪$uid = (int)‪$uid;
4805  if (!‪$GLOBALS['TCA'][$table] || !‪$uid || $this->isNestedElementCallRegistered($table, ‪$uid, 'localize-' . (string)$language) !== false) {
4806  return false;
4807  }
4808 
4809  $this->registerNestedElementCall($table, ‪$uid, 'localize-' . (string)$language);
4810  if (empty(‪$GLOBALS['TCA'][$table]['ctrl']['languageField']) || empty(‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4811  $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]);
4812  return false;
4813  }
4814 
4815  if (!$this->doesRecordExist($table, ‪$uid, ‪Permission::PAGE_SHOW)) {
4816  $this->log($table, ‪$uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} without permission', -1, ['table' => $table, 'uid' => (int)‪$uid]);
4817  return false;
4818  }
4819 
4820  // Getting workspace overlay if possible - this will localize versions in workspace if any
4821  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
4822  if (!is_array($row)) {
4823  $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]);
4824  return false;
4825  }
4826 
4827  [$pageId] = BackendUtility::getTSCpid($table, ‪$uid, '');
4828  // Try to fetch the site language from the pages' associated site
4829  $siteLanguage = $this->getSiteLanguageForPage((int)$pageId, (int)$language);
4830  if ($siteLanguage === null) {
4831  $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]);
4832  return false;
4833  }
4834 
4835  // Make sure that records which are translated from another language than the default language have a correct
4836  // localization source set themselves, before translating them to another language.
4837  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4838  && $row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4839  $localizationParentRecord = BackendUtility::getRecord(
4840  $table,
4841  $row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4842  );
4843  if ((int)$localizationParentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4844  $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']]);
4845  return false;
4846  }
4847  }
4848 
4849  // Default language records must never have a localization parent as they are the origin of any translation.
4850  if ((int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4851  && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4852  $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']]);
4853  return false;
4854  }
4855 
4856  $recordLocalizations = BackendUtility::getRecordLocalization($table, ‪$uid, $language, 'AND pid=' . (int)$row['pid']);
4857 
4858  if (!empty($recordLocalizations)) {
4859  $this->log(
4860  $table,
4861  ‪$uid,
4862  SystemLogDatabaseAction::LOCALIZE,
4863  0,
4864  SystemLogErrorClassification::USER_ERROR,
4865  'Localization failed: There already are localizations ({localizations}) for language {language} of the "{table}" record {uid}',
4866  -1,
4867  [
4868  'localizations' => implode(', ', array_column($recordLocalizations, 'uid')),
4869  'language' => $language,
4870  'table' => $table,
4871  'uid' => ‪$uid,
4872  ]
4873  );
4874  return false;
4875  }
4876 
4877  // Initialize:
4878  $overrideValues = [];
4879  // Set override values:
4880  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] = (int)$language;
4881  // If the translated record is a default language record, set it's uid as localization parent of the new record.
4882  // If translating from any other language, no override is needed; we just can copy the localization parent of
4883  // the original record (which is pointing to the correspondent default language record) to the new record.
4884  // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4885  // For pages, there is no "copy/free mode".
4886  if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4887  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = ‪$uid;
4888  } elseif (!$this->useTransOrigPointerField) {
4889  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4890  }
4891  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4892  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = ‪$uid;
4893  }
4894  // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4895  if (isset(‪$GLOBALS['TCA'][$table]['ctrl']['type'])) {
4896  // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4897  $overrideValues[‪$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[‪$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4898  }
4899  // Set exclude Fields:
4900  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4901  $translateToMsg = '';
4902  // Check if we are just prefixing:
4903  if (isset($fCfg['l10n_mode'], $fCfg['config']['type'])
4904  && $fCfg['l10n_mode'] === 'prefixLangTitle'
4905  && (
4906  $fCfg['config']['type'] === 'text'
4907  || $fCfg['config']['type'] === 'input'
4908  || $fCfg['config']['type'] === 'email'
4909  || $fCfg['config']['type'] === 'link'
4910  )
4911  && (string)$row[$fN] !== ''
4912  ) {
4913  $TSConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? [];
4914  $tableEntries = $this->getTableEntries($table, $TSConfig);
4915  if (!empty($TSConfig['translateToMessage']) && !($tableEntries['disablePrependAtCopy'] ?? false)) {
4916  $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']);
4917  $translateToMsg = @sprintf($translateToMsg, $siteLanguage->getTitle());
4918  }
4919 
4920  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4921  $hookObj = GeneralUtility::makeInstance($className);
4922  if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4923  // @todo Deprecate passing an array and pass the full SiteLanguage object instead
4924  $hookObj->processTranslateTo_copyAction(
4925  $row[$fN],
4926  ['uid' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle()],
4927  $this,
4928  $fN
4929  );
4930  }
4931  }
4932  if (!empty($translateToMsg)) {
4933  $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4934  } else {
4935  $overrideValues[$fN] = $row[$fN];
4936  }
4937  }
4938  if (($fCfg['config']['MM'] ?? false) && !empty($fCfg['config']['MM_oppositeUsage'])) {
4939  // We are localizing the 'local' side of an MM relation. (eg. localizing a category).
4940  // In this case, MM relations connected to the default lang record should not be copied,
4941  // so we set an override here to not trigger mm handling of 'items' field for this.
4942  $overrideValues[$fN] = 0;
4943  }
4944  }
4945 
4946  if ($table !== 'pages') {
4947  // Get the uid of record after which this localized record should be inserted
4948  $previousUid = $this->getPreviousLocalizedRecordUid($table, ‪$uid, $row['pid'], $language);
4949  // Execute the copy:
4950  $newId = $this->copyRecord($table, ‪$uid, -$previousUid, true, $overrideValues, '', $language);
4951  } else {
4952  // Create new page which needs to contain the same pid as the original page
4953  $overrideValues['pid'] = $row['pid'];
4954  // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4955  // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4956  if (!empty(‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4957  $hiddenFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4958  $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? ‪$GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4959  // Override by TCA "hideAtCopy" or pageTS "disableHideAtCopy"
4960  // Only for visible pages to get the same behaviour as for copy
4961  if (!$overrideValues[$hiddenFieldName]) {
4962  $TSConfig = BackendUtility::getPagesTSconfig(‪$uid)['TCEMAIN.'] ?? [];
4963  $tableEntries = $this->getTableEntries($table, $TSConfig);
4964  if (
4965  (‪$GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
4966  && !$this->neverHideAtCopy
4967  && !($tableEntries['disableHideAtCopy'] ?? false)
4968  ) {
4969  $overrideValues[$hiddenFieldName] = 1;
4970  }
4971  }
4972  }
4973  $temporaryId = ‪StringUtility::getUniqueId('NEW');
4974  $copyTCE = $this->getLocalTCE();
4975  $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4976  $copyTCE->process_datamap();
4977  // Getting the new UID as if it had been copied:
4978  $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4979  if ($theNewSQLID) {
4980  $this->copyMappingArray[$table][‪$uid] = $theNewSQLID;
4981  $newId = $theNewSQLID;
4982  }
4983  }
4984 
4985  return $newId;
4986  }
4987 
5004  protected function inlineLocalizeSynchronize($table, $id, array $command)
5005  {
5006  $parentRecord = BackendUtility::getRecordWSOL($table, $id);
5007 
5008  // In case the parent record is the default language record, fetch the localization
5009  if (empty($parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
5010  // Fetch the live record
5011  // @todo: this needs to be revisited, as getRecordLocalization() does a WorkspaceRestriction
5012  // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
5013  $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
5014  if (empty($parentRecordLocalization)) {
5015  $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']));
5016  return;
5017  }
5018  $parentRecord = $parentRecordLocalization[0];
5019  $id = $parentRecord['uid'];
5020  // Process overlay for current selected workspace
5021  BackendUtility::workspaceOL($table, $parentRecord);
5022  }
5023 
5024  $field = $command['field'] ?? '';
5025  $language = $command['language'] ?? 0;
5026  $action = $command['action'] ?? '';
5027  $ids = $command['ids'] ?? [];
5028 
5029  if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset(‪$GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
5030  return;
5031  }
5032 
5033  $config = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'];
5034  $foreignTable = $config['foreign_table'];
5035 
5036  $transOrigPointer = (int)$parentRecord[‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
5037  $childTransOrigPointerField = ‪$GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
5038 
5039  if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
5040  return;
5041  }
5042 
5043  $relationFieldType = $this->getRelationFieldType($config);
5044  if ($relationFieldType === false) {
5045  return;
5046  }
5047 
5048  $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
5049 
5050  $removeArray = [];
5051  $mmTable = $relationFieldType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
5052  // Fetch children from original language parent:
5053  $dbAnalysisOriginal = $this->createRelationHandlerInstance();
5054  $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
5055  $elementsOriginal = [];
5056  foreach ($dbAnalysisOriginal->itemArray as $item) {
5057  $elementsOriginal[$item['id']] = $item;
5058  }
5059  unset($dbAnalysisOriginal);
5060  // Fetch children from current localized parent:
5061  $dbAnalysisCurrent = $this->createRelationHandlerInstance();
5062  $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
5063  // Perform synchronization: Possibly removal of already localized records:
5064  if ($action === 'synchronize') {
5065  foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
5066  $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
5067  if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
5068  $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
5069  // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
5070  if (!isset($elementsOriginal[$childTransOrigPointer])) {
5071  unset($dbAnalysisCurrent->itemArray[$index]);
5072  $removeArray[$item['table']][$item['id']]['delete'] = 1;
5073  }
5074  }
5075  }
5076  }
5077  // Perform synchronization/localization: Possibly add unlocalized records for original language:
5078  if ($action === 'localize' || $action === 'synchronize') {
5079  foreach ($elementsOriginal as $originalId => $item) {
5080  if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
5081  continue;
5082  }
5083  $item['id'] = $this->localize($item['table'], $item['id'], $language);
5084 
5085  if (is_int($item['id'])) {
5086  $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
5087  }
5088  $dbAnalysisCurrent->itemArray[] = $item;
5089  }
5090  } elseif (!empty($ids)) {
5091  foreach ($ids as $childId) {
5092  if (!‪MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
5093  continue;
5094  }
5095  $item = $elementsOriginal[$childId];
5096  if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
5097  continue;
5098  }
5099  $item['id'] = $this->localize($item['table'], $item['id'], $language);
5100  if (is_int($item['id'])) {
5101  $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
5102  }
5103  $dbAnalysisCurrent->itemArray[] = $item;
5104  }
5105  }
5106  // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
5107  $value = implode(',', $dbAnalysisCurrent->getValueArray());
5108  $this->registerDBList[$table][$id][$field] = $value;
5109  // Remove child records (if synchronization requested it):
5110  if (is_array($removeArray) && !empty($removeArray)) {
5111  $tce = GeneralUtility::makeInstance(self::class, $this->referenceIndexUpdater);
5112  $tce->enableLogging = $this->enableLogging;
5113  $tce->start([], $removeArray, $this->BE_USER);
5114  $tce->process_cmdmap();
5115  unset($tce);
5116  }
5117  $updateFields = [];
5118  // Handle, reorder and store relations:
5119  if ($relationFieldType === 'list') {
5120  $updateFields = [$field => $value];
5121  } elseif ($relationFieldType === 'field') {
5122  $dbAnalysisCurrent->writeForeignField($config, $id);
5123  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
5124  } elseif ($relationFieldType === 'mm') {
5125  $dbAnalysisCurrent->writeMM($config['MM'], $id);
5126  $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
5127  }
5128  // Update field referencing to child records of localized parent record:
5129  if (!empty($updateFields)) {
5130  $this->updateDB($table, $id, $updateFields);
5131  }
5132  }
5133 
5137  protected function isRecordLocalized(string $table, int ‪$uid, int $language): bool
5138  {
5139  $row = BackendUtility::getRecordWSOL($table, ‪$uid);
5140  $localizations = BackendUtility::getRecordLocalization($table, ‪$uid, $language, 'pid=' . (int)$row['pid']);
5141  return !empty($localizations);
5142  }
5143 
5144  /*********************************************
5145  *
5146  * Cmd: delete
5147  *
5148  ********************************************/
5156  public function deleteAction($table, $id)
5157  {
5158  $recordToDelete = BackendUtility::getRecord($table, $id);
5159 
5160  if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
5161  // When dealing with a workspace record, use discard.
5162  $this->discard($table, null, $recordToDelete);
5163  return;
5164  }
5165 
5166  // Record asked to be deleted was found:
5167  if (is_array($recordToDelete)) {
5168  $recordWasDeleted = false;
5169  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5170  $hookObj = GeneralUtility::makeInstance($className);
5171  if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
5172  $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
5173  }
5174  }
5175  // Delete the record if a hook hasn't deleted it yet
5176  if (!$recordWasDeleted) {
5177  $this->deleteEl($table, $id);
5178  }
5179  }
5180  }
5181 
5192  public function deleteEl($table, ‪$uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5193  {
5194  if ($table === 'pages') {
5195  $this->deletePages(‪$uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
5196  } else {
5197  $this->discardLocalizedWorkspaceVersionsOfRecord((string)$table, (int)‪$uid);
5198  $this->discardWorkspaceVersionsOfRecord($table, ‪$uid);
5199  $this->deleteRecord($table, ‪$uid, $noRecordCheck, $forceHardDelete);
5200  }
5201  }
5202 
5209  protected function discardLocalizedWorkspaceVersionsOfRecord(string $table, int ‪$uid): void
5210  {
5211  if (!BackendUtility::isTableLocalizable($table)
5212  || !BackendUtility::isTableWorkspaceEnabled($table)
5213  || !$this->BE_USER->recordEditAccessInternals($table, ‪$uid)
5214  ) {
5215  return;
5216  }
5217  $liveRecord = BackendUtility::getRecord($table, ‪$uid);
5218  if ((int)($liveRecord['sys_language_uid'] ?? 0) !== 0 || (int)($liveRecord['t3ver_wsid'] ?? 0) !== 0) {
5219  // Don't do anything if we're not deleting a live record in default language
5220  return;
5221  }
5222  $languageField = ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'];
5223  $localizationParentFieldName = ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
5224  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5225  $queryBuilder->getRestrictions()->removeAll();
5226  $queryBuilder = $queryBuilder->select('*')->from($table)
5227  ->where(
5228  // workspace elements
5229  $queryBuilder->expr()->gt('t3ver_wsid', $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
5230  // with sys_language_uid > 0
5231  $queryBuilder->expr()->gt($languageField, $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT)),
5232  // in state 'new'
5233  $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, ‪Connection::PARAM_INT)),
5234  // with "l10n_parent" set to uid of live record
5235  $queryBuilder->expr()->eq($localizationParentFieldName, $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT))
5236  );
5237  $result = $queryBuilder->executeQuery();
5238  while ($row = $result->fetchAssociative()) {
5239  // BE user must be put into this workspace temporarily so stuff like refindex updating
5240  // is properly registered for this workspace when discarding records in there.
5241  $currentUserWorkspace = $this->BE_USER->workspace;
5242  $this->BE_USER->workspace = (int)$row['t3ver_wsid'];
5243  $this->discard($table, null, $row);
5244  // Switch user back to original workspace
5245  $this->BE_USER->workspace = $currentUserWorkspace;
5246  }
5247  }
5248 
5257  protected function discardWorkspaceVersionsOfRecord($table, ‪$uid): void
5258  {
5259  $versions = BackendUtility::selectVersionsOfRecord($table, ‪$uid, '*', null);
5260  if ($versions === null) {
5261  // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
5262  return;
5263  }
5264  foreach ($versions as ‪$record) {
5265  if (‪$record['_CURRENT_VERSION'] ?? false) {
5266  // The live record is included in the result from selectVersionsOfRecord()
5267  // and marked as '_CURRENT_VERSION'. Skip this one.
5268  continue;
5269  }
5270  // BE user must be put into this workspace temporarily so stuff like refindex updating
5271  // is properly registered for this workspace when discarding records in there.
5272  $currentUserWorkspace = $this->BE_USER->workspace;
5273  $this->BE_USER->workspace = (int)‪$record['t3ver_wsid'];
5274  $this->discard($table, null, ‪$record);
5275  // Switch user back to original workspace
5276  $this->BE_USER->workspace = $currentUserWorkspace;
5277  }
5278  }
5279 
5292  public function deleteRecord($table, ‪$uid, $noRecordCheck = false, $forceHardDelete = false)
5293  {
5294  $currentUserWorkspace = (int)$this->BE_USER->workspace;
5295  ‪$uid = (int)‪$uid;
5296  if (!‪$GLOBALS['TCA'][$table] || !‪$uid) {
5297  $this->log($table, ‪$uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions [{reason}]', -1, ['reason' => $this->BE_USER->errorMsg]);
5298  return;
5299  }
5300  // Skip processing already deleted records
5301  if (!$forceHardDelete && $this->hasDeletedRecord($table, ‪$uid)) {
5302  return;
5303  }
5304 
5305  // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
5306  $fullLanguageAccessCheck = true;
5307  if ($table === 'pages') {
5308  // If this is a page translation, the full language access check should not be done
5309  $defaultLanguagePageId = $this->getDefaultLanguagePageId(‪$uid);
5310  if ($defaultLanguagePageId !== ‪$uid) {
5311  $fullLanguageAccessCheck = false;
5312  }
5313  }
5314  $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, ‪$uid, false, $forceHardDelete, $fullLanguageAccessCheck);
5315  if (!$hasEditAccess) {
5316  $this->log($table, ‪$uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
5317  return;
5318  }
5319  if ($table === 'pages') {
5320  $perms = ‪Permission::PAGE_DELETE;
5321  } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5322  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5323  $perms = ‪Permission::PAGE_EDIT;
5324  } else {
5325  $perms = ‪Permission::CONTENT_EDIT;
5326  }
5327  if (!$noRecordCheck && !$this->doesRecordExist($table, ‪$uid, $perms)) {
5328  return;
5329  }
5330 
5331  $recordToDelete = [];
5332  $recordWorkspaceId = 0;
5333  if (BackendUtility::isTableWorkspaceEnabled($table)) {
5334  $recordToDelete = BackendUtility::getRecord($table, ‪$uid);
5335  $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0);
5336  }
5337 
5338  // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
5339  [$parentUid] = BackendUtility::getTSCpid($table, ‪$uid, '');
5340  $this->registerRecordIdForPageCacheClearing($table, ‪$uid, $parentUid);
5341  $deleteField = ‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? false;
5342  $databaseErrorMessage = '';
5343  if ($recordWorkspaceId > 0) {
5344  // If this is a workspace record, use discard
5345  $this->BE_USER->workspace = $recordWorkspaceId;
5346  $this->discard($table, null, $recordToDelete);
5347  // Switch user back to original workspace
5348  $this->BE_USER->workspace = $currentUserWorkspace;
5349  } elseif ($deleteField && !$forceHardDelete) {
5350  $updateFields = [
5351  $deleteField => 1,
5352  ];
5353  if (‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
5354  $updateFields[‪$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = ‪$GLOBALS['EXEC_TIME'];
5355  }
5356  // before deleting this record, check for child records or references
5357  $this->deleteRecord_procFields($table, ‪$uid);
5358  try {
5359  // Delete all l10n records as well
5360  $this->deletedRecords[$table][] = (int)‪$uid;
5361  $this->deleteL10nOverlayRecords($table, ‪$uid);
5362  GeneralUtility::makeInstance(ConnectionPool::class)
5363  ->getConnectionForTable($table)
5364  ->update($table, $updateFields, ['uid' => (int)‪$uid]);
5365  } catch (DBALException $e) {
5366  $databaseErrorMessage = $e->getPrevious()->getMessage();
5367  }
5368  } else {
5369  // Delete the hard way...:
5370  try {
5371  $this->hardDeleteSingleRecord($table, (int)‪$uid);
5372  $this->deletedRecords[$table][] = (int)‪$uid;
5373  $this->deleteL10nOverlayRecords($table, ‪$uid);
5374  } catch (DBALException $e) {
5375  $databaseErrorMessage = $e->getPrevious()->getMessage();
5376  }
5377  }
5378  if ($this->enableLogging) {
5379  $state = SystemLogDatabaseAction::DELETE;
5380  if ($databaseErrorMessage === '') {
5381  if ($forceHardDelete) {
5382  $message = 'Record "{title}" ({table}:{uid}) was deleted unrecoverable from page "{pageTitle}" ({pid})';
5383  } else {
5384  $message = 'Record "{title}" ({table}:{uid}) was deleted from page "{pageTitle}" ({pid})';
5385  }
5386  $propArr = $this->getRecordProperties($table, ‪$uid);
5387  $pagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
5388 
5389  $this->log($table, ‪$uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
5390  'title' => $propArr['header'],
5391  'table' => $table,
5392  'uid' => ‪$uid,
5393  'pageTitle' => $pagePropArr['header'],
5394  'pid' => $propArr['pid'],
5395  ], $propArr['event_pid']);
5396  } else {
5397  $this->log($table, ‪$uid, $state, 0, SystemLogErrorClassification::SYSTEM_ERROR, $databaseErrorMessage);
5398  }
5399  }
5400 
5401  // Add history entry
5402  $this->getRecordHistoryStore()->deleteRecord($table, ‪$uid, $this->correlationId);
5403 
5404  // Update reference index with table/uid on left side (recuid)
5405  $this->updateRefIndex($table, ‪$uid);
5406  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
5407  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, ‪$uid, $currentUserWorkspace);
5408  }
5409 
5419  public function deletePages(‪$uid, $force = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5420  {
5421  ‪$uid = (int)‪$uid;
5422  if (‪$uid === 0) {
5423  $this->log('pages', ‪$uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled', -1, [], 0);
5424  return;
5425  }
5426  // Getting list of pages to delete:
5427  if ($force) {
5428  // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
5429  $pageIdsInBranch = $this->doesBranchExist('', ‪$uid, 0, true);
5430  $res = GeneralUtility::intExplode(',', $pageIdsInBranch . ‪$uid, true);
5431  } else {
5432  $res = $this->canDeletePage(‪$uid);
5433  }
5434  // Perform deletion if not error:
5435  if (is_array($res)) {
5436  foreach ($res as $deleteId) {
5437  $this->deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
5438  }
5439  } else {
5440  $this->log(
5441  'pages',
5442  ‪$uid,
5443  SystemLogDatabaseAction::DELETE,
5444  0,
5445  SystemLogErrorClassification::SYSTEM_ERROR,
5446  $res,
5447  );
5448  }
5449  }
5450 
5460  public function deleteSpecificPage(‪$uid, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5461  {
5462  ‪$uid = (int)‪$uid;
5463  if (!‪$uid) {
5464  // Early void return on invalid uid
5465  return;
5466  }
5467  $forceHardDelete = (bool)$forceHardDelete;
5468 
5469  // Delete either a default language page or a translated page
5470  $pageIdInDefaultLanguage = $this->getDefaultLanguagePageId(‪$uid);
5471  $isPageTranslation = false;
5472  $pageLanguageId = 0;
5473  if ($pageIdInDefaultLanguage !== ‪$uid) {
5474  // For translated pages, translated records in other tables (eg. tt_content) for the
5475  // to-delete translated page have their pid field set to the uid of the default language record,
5476  // NOT the uid of the translated page record.
5477  // If a translated page is deleted, only translations of records in other tables of this language
5478  // should be deleted. The code checks if the to-delete page is a translated page and
5479  // adapts the query for other tables to use the uid of the default language page as pid together
5480  // with the language id of the translated page.
5481  $isPageTranslation = true;
5482  $pageLanguageId = $this->pageInfo(‪$uid, ‪$GLOBALS['TCA']['pages']['ctrl']['languageField']);
5483  }
5484 
5485  if ($deleteRecordsOnPage) {
5486  $tableNames = $this->compileAdminTables();
5487  foreach ($tableNames as $table) {
5488  if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
5489  // Skip pages table. And skip table if not translatable, but a translated page is deleted
5490  continue;
5491  }
5492 
5493  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5494  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5495  $queryBuilder
5496  ->select('uid')
5497  ->from($table)
5498  // order by uid is needed here to process possible live records first - overlays always
5499  // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
5500  // leading to hard to debug issues. This is especially relevant for the
5501  // discardWorkspaceVersionsOfRecord() call below.
5502  ->addOrderBy('uid');
5503 
5504  if ($isPageTranslation) {
5505  // Only delete records in the specified language
5506  $queryBuilder->where(
5507  $queryBuilder->expr()->eq(
5508  'pid',
5509  $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, ‪Connection::PARAM_INT)
5510  ),
5511  $queryBuilder->expr()->eq(
5512  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
5513  $queryBuilder->createNamedParameter($pageLanguageId, ‪Connection::PARAM_INT)
5514  )
5515  );
5516  } else {
5517  // Delete all records on this page
5518  $queryBuilder->where(
5519  $queryBuilder->expr()->eq(
5520  'pid',
5521  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
5522  )
5523  );
5524  }
5525 
5526  $currentUserWorkspace = (int)$this->BE_USER->workspace;
5527  ‪if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5528  // If we are in a workspace, make sure only records of this workspace are deleted.
5529  $queryBuilder->andWhere(
5530  $queryBuilder->expr()->eq(
5531  't3ver_wsid',
5532  $queryBuilder->createNamedParameter($currentUserWorkspace, ‪Connection::PARAM_INT)
5533  )
5534  );
5535  }
5536 
5537  $statement = $queryBuilder->executeQuery();
5538 
5539  while ($row = $statement->fetchAssociative()) {
5540  // Delete any further workspace overlays of the record in question, then delete the record.
5541  $this->discardWorkspaceVersionsOfRecord($table, $row['uid']);
5542  $this->deleteRecord($table, $row['uid'], true, $forceHardDelete);
5543  }
5544  }
5545  }
5546 
5547  // Delete any further workspace overlays of the record in question, then delete the record.
5548  $this->discardWorkspaceVersionsOfRecord('pages', ‪$uid);
5549  $this->deleteRecord('pages', ‪$uid, true, $forceHardDelete);
5550  }
5551 
5559  public function canDeletePage(‪$uid)
5560  {
5561  ‪$uid = (int)‪$uid;
5562  $isTranslatedPage = null;
5563 
5564  // If we may at all delete this page
5565  // If this is a page translation, do the check against the perms_* of the default page
5566  // Because it is currently only deleting the translation
5567  $defaultLanguagePageId = $this->getDefaultLanguagePageId(‪$uid);
5568  if ($defaultLanguagePageId !== ‪$uid) {
5569  if ($this->doesRecordExist('pages', (int)$defaultLanguagePageId, ‪Permission::PAGE_DELETE)) {
5570  $isTranslatedPage = true;
5571  } else {
5572  return 'Attempt to delete page without permissions';
5573  }
5574  } elseif (!$this->doesRecordExist('pages', ‪$uid, ‪Permission::PAGE_DELETE)) {
5575  return 'Attempt to delete page without permissions';
5576  }
5577 
5578  $pageIdsInBranch = $this->doesBranchExist('', ‪$uid, ‪Permission::PAGE_DELETE, true);
5579 
5580  if ($pageIdsInBranch === -1) {
5581  return 'Attempt to delete pages in branch without permissions';
5582  }
5583 
5584  $pagesInBranch = GeneralUtility::intExplode(',', $pageIdsInBranch . ‪$uid, true);
5585 
5586  if ($disallowedTables = $this->checkForRecordsFromDisallowedTables($pagesInBranch)) {
5587  return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
5588  }
5589 
5590  foreach ($pagesInBranch as $pageInBranch) {
5591  if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
5592  return 'Attempt to delete page which has prohibited localizations';
5593  }
5594  }
5595  return $pagesInBranch;
5596  }
5597 
5606  public function cannotDeleteRecord($table, $id)
5607  {
5608  if ($table === 'pages') {
5609  $res = $this->canDeletePage($id);
5610  return is_array($res) ? false : $res;
5611  }
5612  if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5613  // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5614  $perms = ‪Permission::PAGE_EDIT;
5615  } else {
5616  $perms = ‪Permission::CONTENT_EDIT;
5617  }
5618  return $this->doesRecordExist($table, $id, $perms) ? false : 'No permission to delete record';
5619  }
5620 
5630  public function deleteRecord_procFields($table, ‪$uid)
5631  {
5632  $conf = ‪$GLOBALS['TCA'][$table]['columns'];
5633  $row = BackendUtility::getRecord($table, ‪$uid, '*', '', false);
5634  if (empty($row)) {
5635  return;
5636  }
5637  foreach ($row as $field => $value) {
5638  $this->deleteRecord_procBasedOnFieldType($table, ‪$uid, $value, $conf[$field]['config'] ?? []);
5639  }
5640  }
5641 
5653  public function deleteRecord_procBasedOnFieldType($table, ‪$uid, $value, $conf): void
5654  {
5655  if (!isset($conf['type'])) {
5656  return;
5657  }
5658 
5659  if ($conf['type'] === 'inline' || $conf['type'] === 'file') {
5660  if (in_array($this->getRelationFieldType($conf), ['list', 'field'], true)) {
5661  $dbAnalysis = $this->createRelationHandlerInstance();
5662  $dbAnalysis->start($value, $conf['foreign_table'], '', ‪$uid, $table, $conf);
5663  $dbAnalysis->undeleteRecord = true;
5664 
5665  // non type save comparison is intended!
5666  if (!isset($conf['behaviour']['enableCascadingDelete'])
5667  || $conf['behaviour']['enableCascadingDelete'] != false
5668  ) {
5669  // Walk through the items and remove them
5670  foreach ($dbAnalysis->itemArray as $v) {
5671  $this->deleteAction($v['table'], $v['id']);
5672  }
5673  }
5674  }
5675  } elseif ($this->isReferenceField($conf)) {
5676  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5677  $dbAnalysis = $this->createRelationHandlerInstance();
5678  $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', ‪$uid, $table, $conf);
5679  foreach ($dbAnalysis->itemArray as $v) {
5680  $this->updateRefIndex($v['table'], $v['id']);
5681  }
5682  }
5683  }
5684 
5692  public function deleteL10nOverlayRecords($table, ‪$uid)
5693  {
5694  // Check whether table can be localized
5695  if (!BackendUtility::isTableLocalizable($table)) {
5696  return;
5697  }
5698 
5699  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5700  $queryBuilder->getRestrictions()
5701  ->removeAll()
5702  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5703  ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5704 
5705  $queryBuilder->select('*')
5706  ->from($table)
5707  ->where(
5708  $queryBuilder->expr()->eq(
5709  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5710  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
5711  )
5712  );
5713 
5714  $result = $queryBuilder->executeQuery();
5715  while (‪$record = $result->fetchAssociative()) {
5716  // Ignore workspace delete placeholders. Those records have been marked for
5717  // deletion before - deleting them again in a workspace would revert that state.
5718  if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5719  BackendUtility::workspaceOL($table, ‪$record, $this->BE_USER->workspace);
5720  if (VersionState::tryFrom(‪$record['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) {
5721  continue;
5722  }
5723  }
5724  $this->deleteAction($table, (int)(‪$record['t3ver_oid'] ?? 0) > 0 ? (int)‪$record['t3ver_oid'] : (int)‪$record['uid']);
5725  }
5726  }
5727 
5728  /*********************************************
5729  *
5730  * Cmd: undelete / restore
5731  *
5732  ********************************************/
5733 
5744  protected function undeleteRecord(string $table, int ‪$uid): void
5745  {
5746  ‪$record = BackendUtility::getRecord($table, ‪$uid, '*', '', false);
5747  $deleteField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5748  $timestampField = (string)(‪$GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5749 
5750  if (‪$record === null
5751  || $deleteField === ''
5752  || !isset(‪$record[$deleteField])
5753  || (bool)‪$record[$deleteField] === false
5754  || ($timestampField !== '' && !isset(‪$record[$timestampField]))
5755  || (int)$this->BE_USER->workspace > 0
5756  || (BackendUtility::isTableWorkspaceEnabled($table) && (int)(‪$record['t3ver_wsid'] ?? 0) > 0)
5757  ) {
5758  // Return early and silently, if:
5759  // * Record not found
5760  // * Table is not soft-delete aware
5761  // * Record does not have deleted field - db analyzer not up-to-date?
5762  // * Record is not deleted - may eventually happen via recursion with self referencing records?
5763  // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5764  // * User is in a workspace - does not make sense
5765  // * Record is in a workspace - workspace records are not soft-delete aware
5766  return;
5767  }
5768 
5769  $recordPid = (int)(‪$record['pid'] ?? 0);
5770  if ($recordPid > 0) {
5771  // Record is not on root level. Parent page record must exist and must not be deleted itself.
5772  $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5773  if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5774  $this->log(
5775  $table,
5776  ‪$uid,
5777  SystemLogDatabaseAction::DELETE,
5778  0,
5779  SystemLogErrorClassification::USER_ERROR,
5780  'Record "{table}:{uid}" can\'t be restored: The page "{pid}" containing it does not exist or is soft-deleted',
5781  0,
5782  [
5783  'table' => $table,
5784  'uid' => ‪$uid,
5785  'pid' => $recordPid,
5786  ],
5787  $recordPid
5788  );
5789  return;
5790  }
5791  }
5792 
5793  // @todo: When restoring a not-default language record, it should be verified the default language
5794  // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5795 
5796  if (!$this->BE_USER->recordEditAccessInternals($table, ‪$record, false, true)) {
5797  // User misses access permissions to record
5798  $this->log(
5799  $table,
5800  ‪$uid,
5801  SystemLogDatabaseAction::DELETE,
5802  0,
5803  SystemLogErrorClassification::USER_ERROR,
5804  'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions',
5805  0,
5806  [
5807  'table' => $table,
5808  'uid' => ‪$uid,
5809  ],
5810  $recordPid
5811  );
5812  return;
5813  }
5814 
5815  // Restore referenced child records
5816  $this->undeleteRecordRelations($table, ‪$uid, ‪$record);
5817 
5818  // Restore record
5819  $updateFields[$deleteField] = 0;
5820  if ($timestampField !== '') {
5821  $updateFields[$timestampField] = ‪$GLOBALS['EXEC_TIME'];
5822  }
5823  GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table)
5824  ->update(
5825  $table,
5826  $updateFields,
5827  ['uid' => ‪$uid]
5828  );
5829 
5830  if ($this->enableLogging) {
5831  $this->log(
5832  $table,
5833  ‪$uid,
5834  SystemLogDatabaseAction::INSERT,
5835  0,
5836  SystemLogErrorClassification::MESSAGE,
5837  'Record "{table}:{uid}" was restored on page {pid}',
5838  0,
5839  [
5840  'table' => $table,
5841  'uid' => ‪$uid,
5842  'pid' => $recordPid,
5843  ],
5844  $recordPid
5845  );
5846  }
5847 
5848  // Register cache clearing of page, or parent page if a page is restored.
5849  $this->registerRecordIdForPageCacheClearing($table, ‪$uid, $recordPid);
5850  // Add history entry
5851  $this->getRecordHistoryStore()->undeleteRecord($table, ‪$uid, $this->correlationId);
5852  // Update reference index with table/uid on left side (recuid)
5853  $this->updateRefIndex($table, ‪$uid);
5854  // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5855  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, ‪$uid, 0);
5856  }
5857 
5866  protected function undeleteRecordRelations(string $table, int ‪$uid, array ‪$record): void
5867  {
5868  foreach (‪$record as $fieldName => $value) {
5869  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5870  $fieldType = (string)($fieldConfig['type'] ?? '');
5871  if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5872  continue;
5873  }
5874  $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5875  if ($fieldType === 'inline' || $fieldType === 'file') {
5876  // @todo: Inline MM not handled here, and what about group / select?
5877  if (!in_array($this->getRelationFieldType($fieldConfig), ['list', 'field'], true)) {
5878  continue;
5879  }
5880  $relationHandler = $this->createRelationHandlerInstance();
5881  $relationHandler->start($value, $foreignTable, '', ‪$uid, $table, $fieldConfig);
5882  $relationHandler->undeleteRecord = true;
5883  foreach ($relationHandler->itemArray as $reference) {
5884  $this->undeleteRecord($reference['table'], (int)$reference['id']);
5885  }
5886  } elseif ($this->isReferenceField($fieldConfig)) {
5887  $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5888  $relationHandler = $this->createRelationHandlerInstance();
5889  $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', ‪$uid, $table, $fieldConfig);
5890  foreach ($relationHandler->itemArray as $reference) {
5891  // @todo: Unsure if this is ok / enough. Needs coverage.
5892  $this->updateRefIndex($reference['table'], $reference['id']);
5893  }
5894  }
5895  }
5896  }
5897 
5898  /*********************************************
5899  *
5900  * Cmd: Workspace discard & flush
5901  *
5902  ********************************************/
5903 
5917  public function discard(string $table, ?int ‪$uid, array ‪$record = null): void
5918  {
5919  if (‪$uid === null && ‪$record === null) {
5920  throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5921  }
5922 
5923  // Fetch record we are dealing with if not given
5924  if (‪$record === null) {
5925  ‪$record = BackendUtility::getRecord($table, (int)‪$uid);
5926  }
5927  if (!is_array(‪$record)) {
5928  return;
5929  }
5930  ‪$uid = (int)‪$record['uid'];
5931 
5932  // Call hook and return if hook took care of the element
5933  $recordWasDiscarded = false;
5934  foreach (‪$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5935  $hookObj = GeneralUtility::makeInstance($className);
5936  if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5937  $hookObj->processCmdmap_discardAction($table, ‪$uid, ‪$record, $recordWasDiscarded);
5938  }
5939  }
5940 
5941  $userWorkspace = (int)$this->BE_USER->workspace;
5942  ‪if ($recordWasDiscarded
5943  || $userWorkspace === 0
5944  || !BackendUtility::isTableWorkspaceEnabled($table)
5945  || $this->hasDeletedRecord($table, ‪$uid)
5946  ) {
5947  return;
5948  }
5949 
5950  // Gather versioned record
5951  if ((int)‪$record['t3ver_wsid'] === 0) {
5952  ‪$record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, ‪$uid);
5953  }
5954  if (!is_array(‪$record)) {
5955  return;
5956  }
5957  $versionRecord = ‪$record;
5958 
5959  // User access checks
5960  if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5961  $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']]);
5962  return;
5963  }
5964  if ($errorCode = $this->workspaceCannotEditOfflineVersion($table, $versionRecord)) {
5965  $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]);
5966  return;
5967  }
5968  if (!$this->checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5969  $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']]);
5970  return;
5971  }
5972  $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5973  if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5974  $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']]);
5975  return;
5976  }
5977 
5978  // Perform discard operations
5979  $versionState = VersionState::tryFrom($versionRecord['t3ver_state'] ?? 0);
5980  if ($table === 'pages' && $versionState === VersionState::NEW_PLACEHOLDER) {
5981  // When discarding a new page, there can be new sub pages and new records.
5982  // Those need to be discarded, otherwise they'd end up as records without parent page.
5983  $this->discardSubPagesAndRecordsOnPage($versionRecord);
5984  }
5985 
5986  $this->discardLocalizationOverlayRecords($table, $versionRecord);
5987  $this->discardRecordRelations($table, $versionRecord);
5988  $this->discardCsvReferencesToRecord($table, $versionRecord);
5989  $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5990  $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5991  $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5992  $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5993  $this->log(
5994  $table,
5995  (int)$versionRecord['uid'],
5996  SystemLogDatabaseAction::DELETE,
5997  0,
5998  SystemLogErrorClassification::MESSAGE,
5999  'Record {table}:{uid} was deleted unrecoverable from page {pid}',
6000  0,
6001  ['table' => $table, 'uid' => $versionRecord['uid'], 'pid' => $versionRecord['pid']],
6002  (int)$versionRecord['pid']
6003  );
6004  }
6005 
6012  protected function discardSubPagesAndRecordsOnPage(array $page): void
6013  {
6014  $isLocalizedPage = false;
6015  $sysLanguageId = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['languageField']];
6016  $versionState = VersionState::tryFrom($page['t3ver_state'] ?? 0);
6017  if ($sysLanguageId > 0) {
6018  // New or moved localized page.
6019  // Discard records on this page localization, but no sub pages.
6020  // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
6021  // @todo: Discard other page translations that inherit from this?! (l10n_source field)
6022  $isLocalizedPage = true;
6023  $pid = (int)$page[‪$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
6024  } elseif ($versionState === VersionState::NEW_PLACEHOLDER) {
6025  // New default language page.
6026  // Discard any sub pages and all other records of this page, including any page localizations.
6027  // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
6028  // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
6029  $pid = (int)$page['uid'];
6030  } else {
6031  // Moved default language page.
6032  // Discard any sub pages and all other records of this page, including any page localizations.
6033  $pid = (int)$page['t3ver_oid'];
6034  }
6035  $tables = $this->compileAdminTables();
6036  foreach ($tables as $table) {
6037  if (($isLocalizedPage && $table === 'pages')
6038  || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
6039  || !BackendUtility::isTableWorkspaceEnabled($table)
6040  ) {
6041  continue;
6042  }
6043  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6044  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6045  $queryBuilder->select('*')
6046  ->from($table)
6047  ->where(
6048  $queryBuilder->expr()->eq(
6049  'pid',
6050  $queryBuilder->createNamedParameter($pid, ‪Connection::PARAM_INT)
6051  ),
6052  $queryBuilder->expr()->eq(
6053  't3ver_wsid',
6054  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
6055  )
6056  );
6057  if ($isLocalizedPage) {
6058  // Add sys_language_uid = x restriction if discarding a localized page
6059  $queryBuilder->andWhere(
6060  $queryBuilder->expr()->eq(
6061  ‪$GLOBALS['TCA'][$table]['ctrl']['languageField'],
6062  $queryBuilder->createNamedParameter($sysLanguageId, ‪Connection::PARAM_INT)
6063  )
6064  );
6065  }
6066  $statement = $queryBuilder->executeQuery();
6067  while ($row = $statement->fetchAssociative()) {
6068  $this->discard($table, null, $row);
6069  }
6070  }
6071  }
6072 
6079  protected function discardRecordRelations(string $table, array ‪$record): void
6080  {
6081  foreach (‪$record as $field => $value) {
6082  $fieldConfig = ‪$GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
6083  if (!isset($fieldConfig['type'])) {
6084  continue;
6085  }
6086  if ($fieldConfig['type'] === 'inline' || $fieldConfig['type'] === 'file') {
6087  $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
6088  if ($foreignTable === ''
6089  || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
6090  && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
6091  ) {
6092  continue;
6093  }
6094  if (in_array($this->getRelationFieldType($fieldConfig), ['list', 'field'], true)) {
6095  $dbAnalysis = $this->createRelationHandlerInstance();
6096  $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)‪$record['uid'], $table, $fieldConfig);
6097  $dbAnalysis->undeleteRecord = true;
6098  foreach ($dbAnalysis->itemArray as $relationRecord) {
6099  $this->discard($relationRecord['table'], (int)$relationRecord['id']);
6100  }
6101  }
6102  } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
6103  $this->discardMmRelations($table, $fieldConfig, ‪$record);
6104  }
6105  // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
6106  }
6107  }
6108 
6128  protected function discardCsvReferencesToRecord(string $table, array ‪$record): void
6129  {
6130  // @see test workspaces Group Discard createContentAndCreateElementRelationAndDiscardElement
6131  // Records referencing the to-discard record.
6132  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex');
6133  $statement = $queryBuilder->select('tablename', 'recuid', 'field')
6134  ->from('sys_refindex')
6135  ->where(
6136  $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter(‪$record['t3ver_wsid'], ‪Connection::PARAM_INT)),
6137  $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
6138  $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter(‪$record['uid'], ‪Connection::PARAM_INT))
6139  )
6140  ->executeQuery();
6141  while ($row = $statement->fetchAssociative()) {
6142  // For each record referencing the to-discard record, see if it is a CSV group field definition.
6143  // If so, update that record to drop both the possible "uid" and "table_name_uid" variants from the list.
6144  $fieldTca = ‪$GLOBALS['TCA'][$row['tablename']]['columns'][$row['field']]['config'] ?? [];
6145  $groupAllowed = GeneralUtility::trimExplode(',', $fieldTca['allowed'] ?? '', true);
6146  // @todo: "select" may be affected too, but it has no coverage to show this, yet?
6147  if (($fieldTca['type'] ?? '') === 'group'
6148  && empty($fieldTca['MM'])
6149  && (in_array('*', $groupAllowed, true) || in_array($table, $groupAllowed, true))
6150  ) {
6151  // Note it would be possible to a) update multiple records with only one DB call, and b) combine the
6152  // select and update to a single update query by doing the CSV manipulation as string function in sql.
6153  // That's harder to get right though and probably not *that* beneficial performance-wise since we're
6154  // most likely dealing with a very small number of records here anyways. Still, an optimization should
6155  // be considered after we drop TCA 'prepend_tname' handling and always rely only on "table_name_uid"
6156  // variant for CSV storage.
6157 
6158  // Get that record
6159  $recordReferencingDiscardedRecord = BackendUtility::getRecord($row['tablename'], $row['recuid'], $row['field']);
6160  if (!$recordReferencingDiscardedRecord) {
6161  continue;
6162  }
6163  // Drop "uid" and "table_name_uid" from list
6164  $listOfRelatedRecords = GeneralUtility::trimExplode(',', $recordReferencingDiscardedRecord[$row['field']], true);
6165  $listOfRelatedRecordsWithoutDiscardedRecord = array_diff($listOfRelatedRecords, [‪$record['uid'], $table . '_' . ‪$record['uid']]);
6166  if ($listOfRelatedRecords !== $listOfRelatedRecordsWithoutDiscardedRecord) {
6167  // Update record if list changed
6168  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($row['tablename']);
6169  $queryBuilder->update($row['tablename'])
6170  ->set($row['field'], implode(',', $listOfRelatedRecordsWithoutDiscardedRecord))
6171  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($row['recuid'], ‪Connection::PARAM_INT)))
6172  ->executeStatement();
6173  }
6174  }
6175  }
6176  }
6177 
6186  protected function discardMmRelations(string $table, array $fieldConfig, array ‪$record): void
6187  {
6188  $recordUid = (int)‪$record['uid'];
6189  $mmTableName = $fieldConfig['MM'];
6190  // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
6191  $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
6192  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6193  $queryBuilder->delete($mmTableName)->where(
6194  // uid_local = given uid OR uid_foreign = given uid
6195  $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, ‪Connection::PARAM_INT))
6196  );
6197  if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
6198  $queryBuilder->andWhere(
6199  ‪QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, ‪QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $fieldConfig['MM_table_where'])))
6200  );
6201  }
6202  $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
6203  foreach ($mmMatchFields as $fieldName => $fieldValue) {
6204  $queryBuilder->andWhere(
6205  $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue))
6206  );
6207  }
6208  $queryBuilder->executeStatement();
6209 
6210  // refindex treatment for mm relation handling: If the to discard record is foreign side of an mm relation,
6211  // there may be other refindex rows that become obsolete when that record is discarded. See Modify
6212  // addCategoryRelation sys_category-29->tt_content-298. We thus register an update for references
6213  // to this item (right side - ref_table, ref_uid) in reference index updater to catch these.
6214  if ($relationUidFieldName === 'uid_foreign') {
6215  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $recordUid, (int)‪$record['t3ver_wsid']);
6216  }
6217  }
6218 
6225  protected function discardLocalizationOverlayRecords(string $table, array ‪$record): void
6226  {
6227  if (!BackendUtility::isTableLocalizable($table)) {
6228  return;
6229  }
6230  ‪$uid = (int)‪$record['uid'];
6231  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6232  $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6233  $statement = $queryBuilder->select('*')
6234  ->from($table)
6235  ->where(
6236  $queryBuilder->expr()->eq(
6237  ‪$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
6238  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
6239  ),
6240  $queryBuilder->expr()->eq(
6241  't3ver_wsid',
6242  $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, ‪Connection::PARAM_INT)
6243  )
6244  )
6245  ->executeQuery();
6246  while (‪$record = $statement->fetchAssociative()) {
6247  $this->discard($table, null, ‪$record);
6248  }
6249  }
6250 
6251  /*********************************************
6252  *
6253  * Cmd: Versioning
6254  *
6255  ********************************************/
6268  public function versionizeRecord($table, $id, $label, $delete = false)
6269  {
6270  $id = (int)$id;
6271  // Stop any actions if the record is marked to be deleted:
6272  // (this can occur if IRRE elements are versionized and child elements are removed)
6273  if ($this->isElementToBeDeleted($table, $id)) {
6274  return null;
6275  }
6276  if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
6277  $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]);
6278  return null;
6279  }
6280 
6281  // Fetch record with permission check
6282  $row = $this->recordInfoWithPermissionCheck($table, $id, ‪Permission::PAGE_SHOW);
6283 
6284  // This checks if the record can be selected which is all that a copy action requires.
6285  if ($row === false) {
6286  $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]);
6287  return null;
6288  }
6289 
6290  // Record must be online record, otherwise we would create a version of a version
6291  if (($row['t3ver_oid'] ?? 0) > 0) {
6292  $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]);
6293  return null;
6294  }
6295 
6296  if ($delete && $errorCode = $this->cannotDeleteRecord($table, $id)) {
6297  $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]);
6298  return null;
6299  }
6300 
6301  // Set up the values to override when making a raw-copy:
6302  $overrideArray = [
6303  't3ver_oid' => $id,
6304  't3ver_wsid' => $this->BE_USER->workspace,
6305  't3ver_state' => $delete ? VersionState::DELETE_PLACEHOLDER->value : VersionState::DEFAULT_STATE->value,
6306  't3ver_stage' => 0,
6307  ];
6308  if (‪$GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
6309  $overrideArray[‪$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
6310  }
6311  // Checking if the record already has a version in the current workspace of the backend user
6312  $versionRecord = ['uid' => null];
6313  if ($this->BE_USER->workspace !== 0) {
6314  // Look for version already in workspace:
6315  $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
6316  }
6317  // Create new version of the record and return the new uid
6318  if (empty($versionRecord['uid'])) {
6319  // Create raw-copy and return result:
6320  // The information of the label to be used for the workspace record
6321  // as well as the information whether the record shall be removed
6322  // must be forwarded (creating delete placeholders on a workspace are
6323  // done by copying the record and override several fields).
6324  $workspaceOptions = [
6325  'delete' => $delete,
6326  'label' => $label,
6327  ];
6328  return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
6329  }
6330  // Reuse the existing record and return its uid
6331  // (prior to TYPO3 CMS 6.2, an error was thrown here, which
6332  // did not make much sense since the information is available)
6333  return $versionRecord['uid'];
6334  }
6335 
6349  public function versionPublishManyToManyRelations(string $table, array $liveRecord, array $workspaceRecord, int $fromWorkspace): void
6350  {
6351  if (!is_array(‪$GLOBALS['TCA'][$table]['columns'])) {
6352  return;
6353  }
6354  $toDeleteRegistry = [];
6355  $toUpdateRegistry = [];
6356  foreach (‪$GLOBALS['TCA'][$table]['columns'] as $dbFieldName => $dbFieldConfig) {
6357  if (empty($dbFieldConfig['config']['type'])) {
6358  continue;
6359  }
6360  if (!empty($dbFieldConfig['config']['MM']) && $this->isReferenceField($dbFieldConfig['config'])) {
6361  $toDeleteRegistry[] = $dbFieldConfig['config'];
6362  $toUpdateRegistry[] = $dbFieldConfig['config'];
6363  }
6364  if ($dbFieldConfig['config']['type'] === 'flex') {
6365  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6366  // Find possible mm tables attached to live record flex from data structures, mark as to delete
6367  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $liveRecord);
6368  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6369  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6370  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6371  if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6372  $toDeleteRegistry[] = $flexFieldDefinition['config'];
6373  }
6374  }
6375  }
6376  // Find possible mm tables attached to workspace record flex from data structures, mark as to update uid
6377  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $workspaceRecord);
6378  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6379  foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
6380  foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
6381  if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
6382  $toUpdateRegistry[] = $flexFieldDefinition['config'];
6383  }
6384  }
6385  }
6386  }
6387  }
6388 
6389  // Delete mm table relations of live record
6390  foreach ($toDeleteRegistry as $config) {
6391  $uidFieldName = $this->mmRelationIsLocalSide($config) ? 'uid_local' : 'uid_foreign';
6392  $mmTableName = $config['MM'];
6393  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6394  $queryBuilder->delete($mmTableName);
6395  $queryBuilder->where($queryBuilder->expr()->eq(
6396  $uidFieldName,
6397  $queryBuilder->createNamedParameter((int)$liveRecord['uid'], ‪Connection::PARAM_INT)
6398  ));
6399  if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6400  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6401  'tablenames',
6402  $queryBuilder->createNamedParameter($table)
6403  ));
6404  }
6405  $queryBuilder->executeStatement();
6406  }
6407 
6408  // Update mm table relations of workspace record to uid of live record
6409  foreach ($toUpdateRegistry as $config) {
6410  $mmRelationIsLocalSide = $this->mmRelationIsLocalSide($config);
6411  $uidFieldName = $mmRelationIsLocalSide ? 'uid_local' : 'uid_foreign';
6412  $mmTableName = $config['MM'];
6413  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6414  $queryBuilder->update($mmTableName);
6415  $queryBuilder->set($uidFieldName, (int)$liveRecord['uid'], true, ‪Connection::PARAM_INT);
6416  $queryBuilder->where($queryBuilder->expr()->eq(
6417  $uidFieldName,
6418  $queryBuilder->createNamedParameter((int)$workspaceRecord['uid'], ‪Connection::PARAM_INT)
6419  ));
6420  if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6421  $queryBuilder->andWhere($queryBuilder->expr()->eq(
6422  'tablenames',
6423  $queryBuilder->createNamedParameter($table)
6424  ));
6425  }
6426  $queryBuilder->executeStatement();
6427 
6428  if (!$mmRelationIsLocalSide) {
6429  // refindex treatment for mm relation handling: If the to publish record is foreign side of an mm relation, we need
6430  // to instruct refindex updater to update all local side references for the live record the current workspace record
6431  // has on foreign side. See ManyToMany Publish addCategoryRelation, this will create the sys_category-31->tt_content-297 entry.
6432  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$workspaceRecord['uid'], $fromWorkspace, 0);
6433  // Similar, when in mm foreign side and relations are deleted in live during publish, other relations pointing to the
6434  // same local side record may need updates due to different sorting, and the former refindex entry of the live record
6435  // needs updates. See ManyToMany Publish deleteCategoryRelation scenario.
6436  $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$liveRecord['uid'], 0);
6437  }
6438  }
6439  }
6440 
6445  private function flexFieldDefinitionIsMmRelation(array $flexFieldDefinition): bool
6446  {
6447  return ($flexFieldDefinition['type'] ?? '') !== 'array' // is a field, not a section
6448  && is_array($flexFieldDefinition['config'] ?? false) // config array exists
6449  && $this->isReferenceField($flexFieldDefinition['config']) // select, group, category
6450  && !empty($flexFieldDefinition['config']['MM']); // MM exists
6451  }
6452 
6459  private function mmQueryShouldUseTablenamesColumn(array $config): bool
6460  {
6461  if ($this->mmRelationIsLocalSide($config)) {
6462  return false;
6463  }
6464  if ($config['type'] === 'group' && !empty($config['prepend_tname'])) {
6465  // prepend_tname in MM on foreign side forces 'tablenames' column
6466  // @todo: See if we can get rid of prepend_tname in MM altogether?
6467  return true;
6468  }
6469  if ($config['type'] === 'group' && is_string($config['allowed'] ?? false)
6470  && (str_contains($config['allowed'], ',') || $config['allowed'] === '*')
6471  ) {
6472  // 'allowed' with *, or more than one table
6473  // @todo: Neither '*' nor 'multiple tables' make sense for MM on foreign side.
6474  // There is a hint in the docs about this, too. Sanitize in TCA bootstrap?!
6475  return true;
6476  }
6477  $localSideTableName = $config['type'] === 'group' ? $config['allowed'] ?? '' : $config['foreign_table'] ?? '';
6478  $localSideFieldName = $config['MM_opposite_field'] ?? '';
6479  $localSideAllowed = ‪$GLOBALS['TCA'][$localSideTableName]['columns'][$localSideFieldName]['config']['allowed'] ?? '';
6480  // Local side with 'allowed' = '*' or multiple tables forces 'tablenames' column
6481  return $localSideAllowed === '*' || str_contains($localSideAllowed, ',');
6482  }
6483 
6488  private function mmRelationIsLocalSide(array $config): bool
6489  {
6490  return empty($config['MM_opposite_field']);
6491  }
6492 
6493  /*********************************************
6494  *
6495  * Cmd: Helper functions
6496  *
6497  ********************************************/
6498 
6504  protected function getLocalTCE()
6505  {
6506  $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
6507  $copyTCE->copyTree = $this->copyTree;
6508  $copyTCE->enableLogging = $this->enableLogging;
6509  // Transformations should NOT be carried out during copy
6510  $copyTCE->dontProcessTransformations = true;
6511  // make sure the isImporting flag is transferred, so all hooks know if
6512  // the current process is an import process
6513  $copyTCE->isImporting = $this->isImporting;
6514  $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
6515  $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
6516  return $copyTCE;
6517  }
6518 
6523  public function remapListedDBRecords()
6524  {
6525  if (!empty($this->registerDBList)) {
6526  $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6527  foreach ($this->registerDBList as $table => $records) {
6528  foreach ($records as ‪$uid => ‪$fields) {
6529  $newData = [];
6530  $theUidToUpdate = $this->copyMappingArray_merged[$table][‪$uid] ?? null;
6531  $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
6532  foreach (‪$fields as $fieldName => $value) {
6533  $conf = ‪$GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
6534  switch ($conf['type']) {
6535  case 'group':
6536  case 'select':
6537  case 'category':
6538  $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6539  if (is_array($vArray)) {
6540  $newData[$fieldName] = implode(',', $vArray);
6541  }
6542  break;
6543  case 'flex':
6544  if ($value === 'FlexForm_reference') {
6545  // This will fetch the new row for the element
6546  $origRecordRow = $this->recordInfo($table, $theUidToUpdate);
6547  if (is_array($origRecordRow)) {
6548  BackendUtility::workspaceOL($table, $origRecordRow);
6549  // Get current data structure and value array:
6550  $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
6551  ['config' => $conf],
6552  $table,
6553  $fieldName,
6554  $origRecordRow
6555  );
6556  $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6557  $currentValueArray = ‪GeneralUtility::xml2array($origRecordRow[$fieldName]);
6558  // Do recursive processing of the XML data:
6559  $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
6560  // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
6561  if (is_array($currentValueArray['data'])) {
6562  $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray);
6563  }
6564  }
6565  }
6566  break;
6567  case 'inline':
6568  $this->remapListedDBRecords_procInline($conf, $value, ‪$uid, $table);
6569  break;
6570  case 'file':
6571  $this->remapListedDBRecords_procFile($conf, $value, ‪$uid, $table);
6572  break;
6573  default:
6574  $this->logger->debug('Field type should not appear here: {type}', ['type' => $conf['type']]);
6575  }
6576  }
6577  // If any fields were changed, those fields are updated!
6578  if (!empty($newData)) {
6579  $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
6580  }
6581  }
6582  }
6583  }
6584  }
6585 
6597  public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue)
6598  {
6599  // Extract parameters:
6600  [$table, ‪$uid, $field] = $pParams;
6601  // If references are set for this field, set flag so they can be corrected later:
6602  if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
6603  $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, ‪$uid, $table);
6604  if (is_array($vArray)) {
6605  $dataValue = implode(',', $vArray);
6606  }
6607  }
6608  // Return
6609  return ['value' => $dataValue];
6610  }
6611 
6623  public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
6624  {
6625  // Initialize variables
6626  // Will be set TRUE if an upgrade should be done...
6627  $set = false;
6628  // Allowed tables for references.
6629  $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
6630  // Table name to prepend the UID
6631  $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
6632  // Which tables that should possibly not be remapped
6633  $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
6634  // Convert value to list of references:
6635  $dbAnalysis = $this->createRelationHandlerInstance();
6636  $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
6637  $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
6638  // Traverse those references and map IDs:
6639  foreach ($dbAnalysis->itemArray as $k => $v) {
6640  $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
6641  if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
6642  $dbAnalysis->itemArray[$k]['id'] = $mapID;
6643  $set = true;
6644  }
6645  }
6646  if (!empty($conf['MM'])) {
6647  // Purge invalid items (live/version)
6648  $dbAnalysis->purgeItemArray();
6649  if ($dbAnalysis->isPurged()) {
6650  $set = true;
6651  }
6652 
6653  // If record has been versioned/copied in this process, handle invalid relations of the live record
6654  $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
6655  $originalId = 0;
6656  if (!empty($this->copyMappingArray_merged[$table])) {
6657  $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
6658  }
6659  if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
6660  $liveRelations = $this->createRelationHandlerInstance();
6661  $liveRelations->setWorkspaceId(0);
6662  $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
6663  // Purge invalid relations in the live workspace ("0")
6664  $liveRelations->purgeItemArray(0);
6665  if ($liveRelations->isPurged()) {
6666  $liveRelations->writeMM($conf['MM'], $liveId, $prependName);
6667  }
6668  }
6669  }
6670  // If a change has been done, set the new value(s)
6671  if ($set) {
6672  if ($conf['MM'] ?? false) {
6673  $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
6674  } else {
6675  return $dbAnalysis->getValueArray($prependName);
6676  }
6677  }
6678  return null;
6679  }
6680 
6690  public function remapListedDBRecords_procInline($conf, $value, ‪$uid, $table)
6691  {
6692  $theUidToUpdate = $this->copyMappingArray_merged[$table][‪$uid] ?? null;
6693  if ($conf['foreign_table']) {
6694  $relationFieldType = $this->getRelationFieldType($conf);
6695  if ($relationFieldType === 'mm') {
6696  $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6697  } elseif ($relationFieldType !== false) {
6698  $dbAnalysis = $this->createRelationHandlerInstance();
6699  $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf);
6700 
6701  $updatePidForRecords = [];
6702  // Update values for specific versioned records
6703  foreach ($dbAnalysis->itemArray as &$item) {
6704  $updatePidForRecords[$item['table']][] = $item['id'];
6705  $versionedId = $this->getAutoVersionId($item['table'], $item['id']);
6706  if ($versionedId !== null) {
6707  $updatePidForRecords[$item['table']][] = $versionedId;
6708  $item['id'] = $versionedId;
6709  }
6710  }
6711 
6712  // Update child records if using pointer fields ('foreign_field'):
6713  if ($relationFieldType === 'field') {
6714  $dbAnalysis->writeForeignField($conf, ‪$uid, $theUidToUpdate);
6715  }
6716  $thePidToUpdate = null;
6717  // If the current field is set on a page record, update the pid of related child records:
6718  if ($table === 'pages') {
6719  $thePidToUpdate = $theUidToUpdate;
6720  } elseif (isset($this->registerDBPids[$table][‪$uid])) {
6721  $thePidToUpdate = $this->registerDBPids[$table][‪$uid];
6722  $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate] ?? null;
6723  }
6724 
6725  // Update child records if change to pid is required
6726  if ($thePidToUpdate && !empty($updatePidForRecords)) {
6727  // Ensure that only the default language page is used as PID
6728  $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate);
6729  // @todo: this can probably go away
6730  // ensure, only live page ids are used as 'pid' values
6731  $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate);
6732  if ($liveId !== null) {
6733  $thePidToUpdate = $liveId;
6734  }
6735  $updateValues = ['pid' => $thePidToUpdate];
6736  foreach ($updatePidForRecords as $tableName => $uids) {
6737  if (empty($tableName)) {
6738  continue;
6739  }
6740  $conn = GeneralUtility::makeInstance(ConnectionPool::class)
6741  ->getConnectionForTable($tableName);
6742  foreach ($uids as $updateUid) {<