1<?php
2namespace TYPO3\CMS\Workspaces\Hook;
3
4/*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17use Doctrine\DBAL\DBALException;
18use Doctrine\DBAL\Platforms\SQLServerPlatform;
19use TYPO3\CMS\Backend\Utility\BackendUtility;
20use TYPO3\CMS\Core\Cache\CacheManager;
21use TYPO3\CMS\Core\Core\Environment;
22use TYPO3\CMS\Core\Database\Connection;
23use TYPO3\CMS\Core\Database\ConnectionPool;
24use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
25use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
26use TYPO3\CMS\Core\Database\ReferenceIndex;
27use TYPO3\CMS\Core\Database\RelationHandler;
28use TYPO3\CMS\Core\DataHandling\DataHandler;
29use TYPO3\CMS\Core\DataHandling\PlaceholderShadowColumnsResolver;
30use TYPO3\CMS\Core\Localization\LanguageService;
31use TYPO3\CMS\Core\Service\MarkerBasedTemplateService;
32use TYPO3\CMS\Core\Type\Bitmask\Permission;
33use TYPO3\CMS\Core\Utility\ArrayUtility;
34use TYPO3\CMS\Core\Utility\GeneralUtility;
35use TYPO3\CMS\Core\Versioning\VersionState;
36use TYPO3\CMS\Workspaces\DataHandler\CommandMap;
37use TYPO3\CMS\Workspaces\Preview\PreviewUriBuilder;
38use TYPO3\CMS\Workspaces\Service\StagesService;
39use TYPO3\CMS\Workspaces\Service\WorkspaceService;
40
41/**
42 * Contains some parts for staging, versioning and workspaces
43 * to interact with the TYPO3 Core Engine
44 * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
45 */
46class DataHandlerHook
47{
48    /**
49     * For accumulating information about workspace stages raised
50     * on elements so a single mail is sent as notification.
51     * previously called "accumulateForNotifEmail" in DataHandler
52     *
53     * @var array
54     */
55    protected $notificationEmailInfo = [];
56
57    /**
58     * Contains remapped IDs.
59     *
60     * @var array
61     */
62    protected $remappedIds = [];
63
64    /**
65     * @var WorkspaceService
66     */
67    protected $workspaceService;
68
69    /****************************
70     *****  Cmdmap  Hooks  ******
71     ****************************/
72    /**
73     * hook that is called before any cmd of the commandmap is executed
74     *
75     * @param DataHandler $dataHandler reference to the main DataHandler object
76     */
77    public function processCmdmap_beforeStart(DataHandler $dataHandler)
78    {
79        // Reset notification array
80        $this->notificationEmailInfo = [];
81        // Resolve dependencies of version/workspaces actions:
82        $dataHandler->cmdmap = $this->getCommandMap($dataHandler)->process()->get();
83    }
84
85    /**
86     * hook that is called when no prepared command was found
87     *
88     * @param string $command the command to be executed
89     * @param string $table the table of the record
90     * @param int $id the ID of the record
91     * @param mixed $value the value containing the data
92     * @param bool $commandIsProcessed can be set so that other hooks or
93     * @param DataHandler $dataHandler reference to the main DataHandler object
94     */
95    public function processCmdmap($command, $table, $id, $value, &$commandIsProcessed, DataHandler $dataHandler)
96    {
97        // custom command "version"
98        if ($command === 'version') {
99            $commandIsProcessed = true;
100            $action = (string)$value['action'];
101            $comment = !empty($value['comment']) ? $value['comment'] : '';
102            $notificationAlternativeRecipients = isset($value['notificationAlternativeRecipients']) && is_array($value['notificationAlternativeRecipients']) ? $value['notificationAlternativeRecipients'] : [];
103            switch ($action) {
104                case 'new':
105                    $dataHandler->versionizeRecord($table, $id, $value['label']);
106                    break;
107                case 'swap':
108                    $this->version_swap(
109                        $table,
110                        $id,
111                        $value['swapWith'],
112                        (bool)$value['swapIntoWS'],
113                        $dataHandler,
114                        $comment,
115                        true,
116                        $notificationAlternativeRecipients
117                    );
118                    break;
119                case 'clearWSID':
120                    $this->version_clearWSID($table, $id, false, $dataHandler);
121                    break;
122                case 'flush':
123                    $this->version_clearWSID($table, $id, true, $dataHandler);
124                    break;
125                case 'setStage':
126                    $elementIds = GeneralUtility::trimExplode(',', $id, true);
127                    foreach ($elementIds as $elementId) {
128                        $this->version_setStage(
129                            $table,
130                            $elementId,
131                            $value['stageId'],
132                            $comment,
133                            true,
134                            $dataHandler,
135                            $notificationAlternativeRecipients
136                        );
137                    }
138                    break;
139                default:
140                    // Do nothing
141            }
142        }
143    }
144
145    /**
146     * hook that is called AFTER all commands of the commandmap was
147     * executed
148     *
149     * @param DataHandler $dataHandler reference to the main DataHandler object
150     */
151    public function processCmdmap_afterFinish(DataHandler $dataHandler)
152    {
153        // Empty accumulation array:
154        foreach ($this->notificationEmailInfo as $notifItem) {
155            $this->notifyStageChange($notifItem['shared'][0], $notifItem['shared'][1], implode(', ', $notifItem['elements']), 0, $notifItem['shared'][2], $dataHandler, $notifItem['alternativeRecipients']);
156        }
157        // Reset notification array
158        $this->notificationEmailInfo = [];
159        // Reset remapped IDs
160        $this->remappedIds = [];
161
162        $this->flushWorkspaceCacheEntriesByWorkspaceId($dataHandler->BE_USER->workspace);
163    }
164
165    /**
166     * hook that is called when an element shall get deleted
167     *
168     * @param string $table the table of the record
169     * @param int $id the ID of the record
170     * @param array $record The accordant database record
171     * @param bool $recordWasDeleted can be set so that other hooks or
172     * @param DataHandler $dataHandler reference to the main DataHandler object
173     */
174    public function processCmdmap_deleteAction($table, $id, array $record, &$recordWasDeleted, DataHandler $dataHandler)
175    {
176        // only process the hook if it wasn't processed
177        // by someone else before
178        if ($recordWasDeleted) {
179            return;
180        }
181        $recordWasDeleted = true;
182        // For Live version, try if there is a workspace version because if so, rather "delete" that instead
183        // Look, if record is an offline version, then delete directly:
184        if ($record['pid'] != -1) {
185            if ($wsVersion = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $id)) {
186                $record = $wsVersion;
187                $id = $record['uid'];
188            }
189        }
190        $recordVersionState = VersionState::cast($record['t3ver_state']);
191        // Look, if record is an offline version, then delete directly:
192        if ($record['pid'] == -1) {
193            if ($GLOBALS['TCA'][$table]['ctrl']['versioningWS']) {
194                // In Live workspace, delete any. In other workspaces there must be match.
195                if ($dataHandler->BE_USER->workspace == 0 || (int)$record['t3ver_wsid'] == $dataHandler->BE_USER->workspace) {
196                    $liveRec = BackendUtility::getLiveVersionOfRecord($table, $id, 'uid,t3ver_state');
197                    // Processing can be skipped if a delete placeholder shall be swapped/published
198                    // during the current request. Thus it will be deleted later on...
199                    $liveRecordVersionState = VersionState::cast($liveRec['t3ver_state']);
200                    if ($recordVersionState->equals(VersionState::DELETE_PLACEHOLDER) && !empty($liveRec['uid'])
201                        && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action'])
202                        && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith'])
203                        && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action'] === 'swap'
204                        && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith'] == $id
205                    ) {
206                        return null;
207                    }
208
209                    if ($record['t3ver_wsid'] > 0 && $recordVersionState->equals(VersionState::DEFAULT_STATE)) {
210                        // Change normal versioned record to delete placeholder
211                        // Happens when an edited record is deleted
212                        GeneralUtility::makeInstance(ConnectionPool::class)
213                            ->getConnectionForTable($table)
214                            ->update(
215                                $table,
216                                [
217                                    't3ver_label' => 'DELETED!',
218                                    't3ver_state' => VersionState::DELETE_PLACEHOLDER,
219                                ],
220                                ['uid' => $id]
221                            );
222
223                        // Delete localization overlays:
224                        $dataHandler->deleteL10nOverlayRecords($table, $id);
225                    } elseif ($record['t3ver_wsid'] == 0 || !$liveRecordVersionState->indicatesPlaceholder()) {
226                        // Delete those in WS 0 + if their live records state was not "Placeholder".
227                        $dataHandler->deleteEl($table, $id);
228                        // Delete move-placeholder if current version record is a move-to-pointer
229                        if ($recordVersionState->equals(VersionState::MOVE_POINTER)) {
230                            $movePlaceholder = BackendUtility::getMovePlaceholder($table, $liveRec['uid'], 'uid', $record['t3ver_wsid']);
231                            if (!empty($movePlaceholder)) {
232                                $dataHandler->deleteEl($table, $movePlaceholder['uid']);
233                            }
234                        }
235                    } else {
236                        // If live record was placeholder (new/deleted), rather clear
237                        // it from workspace (because it clears both version and placeholder).
238                        $this->version_clearWSID($table, $id, false, $dataHandler);
239                    }
240                } else {
241                    $dataHandler->newlog('Tried to delete record from another workspace', 1);
242                }
243            } else {
244                $dataHandler->newlog('Versioning not enabled for record with PID = -1!', 2);
245            }
246        } elseif ($res = $dataHandler->BE_USER->workspaceAllowLiveRecordsInPID($record['pid'], $table)) {
247            // Look, if record is "online" or in a versionized branch, then delete directly.
248            if ($res > 0) {
249                $dataHandler->deleteEl($table, $id);
250            } else {
251                $dataHandler->newlog('Stage of root point did not allow for deletion', 1);
252            }
253        } elseif ($recordVersionState->equals(VersionState::MOVE_PLACEHOLDER)) {
254            // Placeholders for moving operations are deletable directly.
255            // Get record which its a placeholder for and reset the t3ver_state of that:
256            if ($wsRec = BackendUtility::getWorkspaceVersionOfRecord($record['t3ver_wsid'], $table, $record['t3ver_move_id'], 'uid')) {
257                // Clear the state flag of the workspace version of the record
258                // Setting placeholder state value for version (so it can know it is currently a new version...)
259
260                GeneralUtility::makeInstance(ConnectionPool::class)
261                    ->getConnectionForTable($table)
262                    ->update(
263                        $table,
264                        [
265                            't3ver_state' => (string)new VersionState(VersionState::DEFAULT_STATE)
266                        ],
267                        ['uid' => (int)$wsRec['uid']]
268                    );
269            }
270            $dataHandler->deleteEl($table, $id);
271        } else {
272            // Otherwise, try to delete by versioning:
273            $copyMappingArray = $dataHandler->copyMappingArray;
274            $dataHandler->versionizeRecord($table, $id, 'DELETED!', true);
275            // Determine newly created versions:
276            // (remove placeholders are copied and modified, thus they appear in the copyMappingArray)
277            $versionizedElements = ArrayUtility::arrayDiffAssocRecursive($dataHandler->copyMappingArray, $copyMappingArray);
278            // Delete localization overlays:
279            foreach ($versionizedElements as $versionizedTableName => $versionizedOriginalIds) {
280                foreach ($versionizedOriginalIds as $versionizedOriginalId => $_) {
281                    $dataHandler->deleteL10nOverlayRecords($versionizedTableName, $versionizedOriginalId);
282                }
283            }
284        }
285    }
286
287    /**
288     * In case a sys_workspace_stage record is deleted we do a hard reset
289     * for all existing records in that stage to avoid that any of these end up
290     * as orphan records.
291     *
292     * @param string $command
293     * @param string $table
294     * @param string $id
295     * @param string $value
296     * @param DataHandler $dataHandler
297     */
298    public function processCmdmap_postProcess($command, $table, $id, $value, DataHandler $dataHandler)
299    {
300        if ($command === 'delete') {
301            if ($table === StagesService::TABLE_STAGE) {
302                $this->resetStageOfElements($id);
303            } elseif ($table === WorkspaceService::TABLE_WORKSPACE) {
304                $this->flushWorkspaceElements($id);
305            }
306        }
307    }
308
309    /**
310     * Hook for \TYPO3\CMS\Core\DataHandling\DataHandler::moveRecord that cares about
311     * moving records that are *not* in the live workspace
312     *
313     * @param string $table the table of the record
314     * @param int $uid the ID of the record
315     * @param int $destPid Position to move to: $destPid: >=0 then it points to
316     * @param array $propArr Record properties, like header and pid (includes workspace overlay)
317     * @param array $moveRec Record properties, like header and pid (without workspace overlay)
318     * @param int $resolvedPid The final page ID of the record
319     * @param bool $recordWasMoved can be set so that other hooks or
320     * @param DataHandler $dataHandler
321     */
322    public function moveRecord($table, $uid, $destPid, array $propArr, array $moveRec, $resolvedPid, &$recordWasMoved, DataHandler $dataHandler)
323    {
324        // Only do something in Draft workspace
325        if ($dataHandler->BE_USER->workspace === 0) {
326            return;
327        }
328        if ($destPid < 0) {
329            // Fetch move placeholder, since it might point to a new page in the current workspace
330            $movePlaceHolder = BackendUtility::getMovePlaceholder($table, abs($destPid), 'uid,pid');
331            if ($movePlaceHolder !== false) {
332                $resolvedPid = $movePlaceHolder['pid'];
333            }
334        }
335        $recordWasMoved = true;
336        $moveRecVersionState = VersionState::cast($moveRec['t3ver_state']);
337        // Get workspace version of the source record, if any:
338        $WSversion = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid');
339        // Handle move-placeholders if the current record is not one already
340        if (
341            BackendUtility::isTableWorkspaceEnabled($table)
342            && !$moveRecVersionState->equals(VersionState::MOVE_PLACEHOLDER)
343        ) {
344            // Create version of record first, if it does not exist
345            if (empty($WSversion['uid'])) {
346                $dataHandler->versionizeRecord($table, $uid, 'MovePointer');
347                $WSversion = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid');
348                if ((int)$resolvedPid !== (int)$propArr['pid']) {
349                    $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid);
350                }
351            } elseif ($dataHandler->isRecordCopied($table, $uid) && (int)$dataHandler->copyMappingArray[$table][$uid] === (int)$WSversion['uid']) {
352                // If the record has been versioned before (e.g. cascaded parent-child structure), create only the move-placeholders
353                if ((int)$resolvedPid !== (int)$propArr['pid']) {
354                    $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid);
355                }
356            }
357        }
358        // Check workspace permissions:
359        $workspaceAccessBlocked = [];
360        // Element was in "New/Deleted/Moved" so it can be moved...
361        $recIsNewVersion = $moveRecVersionState->indicatesPlaceholder();
362        $destRes = $dataHandler->BE_USER->workspaceAllowLiveRecordsInPID($resolvedPid, $table);
363        $canMoveRecord = ($recIsNewVersion || BackendUtility::isTableWorkspaceEnabled($table));
364        // Workspace source check:
365        if (!$recIsNewVersion) {
366            $errorCode = $dataHandler->BE_USER->workspaceCannotEditRecord($table, $WSversion['uid'] ? $WSversion['uid'] : $uid);
367            if ($errorCode) {
368                $workspaceAccessBlocked['src1'] = 'Record could not be edited in workspace: ' . $errorCode . ' ';
369            } elseif (!$canMoveRecord && $dataHandler->BE_USER->workspaceAllowLiveRecordsInPID($moveRec['pid'], $table) <= 0) {
370                $workspaceAccessBlocked['src2'] = 'Could not remove record from table "' . $table . '" from its page "' . $moveRec['pid'] . '" ';
371            }
372        }
373        // Workspace destination check:
374        // All records can be inserted if $destRes is greater than zero.
375        // Only new versions can be inserted if $destRes is FALSE.
376        // NO RECORDS can be inserted if $destRes is negative which indicates a stage
377        //  not allowed for use. If "versioningWS" is version 2, moving can take place of versions.
378        // since TYPO3 CMS 7, version2 is the default and the only option
379        if (!($destRes > 0 || $canMoveRecord && !$destRes)) {
380            $workspaceAccessBlocked['dest1'] = 'Could not insert record from table "' . $table . '" in destination PID "' . $resolvedPid . '" ';
381        } elseif ($destRes == 1 && $WSversion['uid']) {
382            $workspaceAccessBlocked['dest2'] = 'Could not insert other versions in destination PID ';
383        }
384        if (empty($workspaceAccessBlocked)) {
385            // If the move operation is done on a versioned record, which is
386            // NOT new/deleted placeholder and versioningWS is in version 2, then...
387            // since TYPO3 CMS 7, version2 is the default and the only option
388            if ($WSversion['uid'] && !$recIsNewVersion && BackendUtility::isTableWorkspaceEnabled($table)) {
389                $this->moveRecord_wsPlaceholders($table, $uid, $destPid, $WSversion['uid'], $dataHandler);
390            } else {
391                // moving not needed, just behave like in live workspace
392                $recordWasMoved = false;
393            }
394        } else {
395            $dataHandler->newlog('Move attempt failed due to workspace restrictions: ' . implode(' // ', $workspaceAccessBlocked), 1);
396        }
397    }
398
399    /**
400     * Processes fields of a moved record and follows references.
401     *
402     * @param DataHandler $dataHandler Calling DataHandler instance
403     * @param int $resolvedPageId Resolved real destination page id
404     * @param string $table Name of parent table
405     * @param int $uid UID of the parent record
406     */
407    protected function moveRecord_processFields(DataHandler $dataHandler, $resolvedPageId, $table, $uid)
408    {
409        $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid);
410        if (empty($versionedRecord)) {
411            return;
412        }
413        foreach ($versionedRecord as $field => $value) {
414            if (empty($GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
415                continue;
416            }
417            $this->moveRecord_processFieldValue(
418                $dataHandler,
419                $resolvedPageId,
420                $table,
421                $uid,
422                $field,
423                $value,
424                $GLOBALS['TCA'][$table]['columns'][$field]['config']
425            );
426        }
427    }
428
429    /**
430     * Processes a single field of a moved record and follows references.
431     *
432     * @param DataHandler $dataHandler Calling DataHandler instance
433     * @param int $resolvedPageId Resolved real destination page id
434     * @param string $table Name of parent table
435     * @param int $uid UID of the parent record
436     * @param string $field Name of the field of the parent record
437     * @param string $value Value of the field of the parent record
438     * @param array $configuration TCA field configuration of the parent record
439     */
440    protected function moveRecord_processFieldValue(DataHandler $dataHandler, $resolvedPageId, $table, $uid, $field, $value, array $configuration)
441    {
442        $inlineFieldType = $dataHandler->getInlineFieldType($configuration);
443        $inlineProcessing = (
444            ($inlineFieldType === 'list' || $inlineFieldType === 'field')
445            && BackendUtility::isTableWorkspaceEnabled($configuration['foreign_table'])
446            && (!isset($configuration['behaviour']['disableMovingChildrenWithParent']) || !$configuration['behaviour']['disableMovingChildrenWithParent'])
447        );
448
449        if ($inlineProcessing) {
450            if ($table === 'pages') {
451                // If the inline elements are related to a page record,
452                // make sure they reside at that page and not at its parent
453                $resolvedPageId = $uid;
454            }
455
456            $dbAnalysis = $this->createRelationHandlerInstance();
457            $dbAnalysis->start($value, $configuration['foreign_table'], '', $uid, $table, $configuration);
458
459            // Moving records to a positive destination will insert each
460            // record at the beginning, thus the order is reversed here:
461            foreach ($dbAnalysis->itemArray as $item) {
462                $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $item['table'], $item['id'], 'uid,t3ver_state');
463                if (empty($versionedRecord) || VersionState::cast($versionedRecord['t3ver_state'])->indicatesPlaceholder()) {
464                    continue;
465                }
466                $dataHandler->moveRecord($item['table'], $item['id'], $resolvedPageId);
467            }
468        }
469    }
470
471    /****************************
472     *****  Notifications  ******
473     ****************************/
474    /**
475     * Send an email notification to users in workspace
476     *
477     * @param array $stat Workspace access array from \TYPO3\CMS\Core\Authentication\BackendUserAuthentication::checkWorkspace()
478     * @param int $stageId New Stage number: 0 = editing, 1= just ready for review, 10 = ready for publication, -1 = rejected!
479     * @param string $table Table name of element (or list of element names if $id is zero)
480     * @param int $id Record uid of element (if zero, then $table is used as reference to element(s) alone)
481     * @param string $comment User comment sent along with action
482     * @param DataHandler $dataHandler DataHandler object
483     * @param array $notificationAlternativeRecipients List of recipients to notify instead of be_users selected by sys_workspace, list is generated by workspace extension module
484     */
485    protected function notifyStageChange(array $stat, $stageId, $table, $id, $comment, DataHandler $dataHandler, array $notificationAlternativeRecipients = [])
486    {
487        $workspaceRec = BackendUtility::getRecord('sys_workspace', $stat['uid']);
488        // So, if $id is not set, then $table is taken to be the complete element name!
489        $elementName = $id ? $table . ':' . $id : $table;
490        if (!is_array($workspaceRec)) {
491            return;
492        }
493
494        // Get the new stage title
495        $stageService = GeneralUtility::makeInstance(StagesService::class);
496        $newStage = $stageService->getStageTitle((int)$stageId);
497        if (empty($notificationAlternativeRecipients)) {
498            // Compile list of recipients:
499            $emails = [];
500            switch ((int)$stat['stagechg_notification']) {
501                case 1:
502                    switch ((int)$stageId) {
503                        case 1:
504                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['reviewers']);
505                            break;
506                        case 10:
507                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
508                            break;
509                        case -1:
510                            // List of elements to reject:
511                            $allElements = explode(',', $elementName);
512                            // Traverse them, and find the history of each
513                            foreach ($allElements as $elRef) {
514                                [$eTable, $eUid] = explode(':', $elRef);
515
516                                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
517                                    ->getQueryBuilderForTable('sys_log');
518
519                                $queryBuilder->getRestrictions()->removeAll();
520
521                                $result = $queryBuilder
522                                    ->select('log_data', 'tstamp', 'userid')
523                                    ->from('sys_log')
524                                    ->where(
525                                        $queryBuilder->expr()->eq(
526                                            'action',
527                                            $queryBuilder->createNamedParameter(6, \PDO::PARAM_INT)
528                                        ),
529                                        $queryBuilder->expr()->eq(
530                                            'details_nr',
531                                            $queryBuilder->createNamedParameter(30, \PDO::PARAM_INT)
532                                        ),
533                                        $queryBuilder->expr()->eq(
534                                            'tablename',
535                                            $queryBuilder->createNamedParameter($eTable, \PDO::PARAM_STR)
536                                        ),
537                                        $queryBuilder->expr()->eq(
538                                            'recuid',
539                                            $queryBuilder->createNamedParameter($eUid, \PDO::PARAM_INT)
540                                        )
541                                    )
542                                    ->orderBy('uid', 'DESC')
543                                    ->execute();
544
545                                // Find all implicated since the last stage-raise from editing to review:
546                                while ($dat = $result->fetch()) {
547                                    $data = unserialize($dat['log_data']);
548                                    $emails = $this->getEmailsForStageChangeNotification($dat['userid'], true) + $emails;
549                                    if ($data['stage'] == 1) {
550                                        break;
551                                    }
552                                }
553                            }
554                            break;
555                        case 0:
556                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['members']);
557                            break;
558                        default:
559                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
560                    }
561                    break;
562                case 10:
563                    $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
564                    $emails = $this->getEmailsForStageChangeNotification($workspaceRec['reviewers']) + $emails;
565                    $emails = $this->getEmailsForStageChangeNotification($workspaceRec['members']) + $emails;
566                    break;
567                default:
568                    // Do nothing
569            }
570        } else {
571            $emails = $notificationAlternativeRecipients;
572        }
573        // prepare and then send the emails
574        if (!empty($emails)) {
575            $previewUriBuilder = GeneralUtility::makeInstance(PreviewUriBuilder::class);
576            // Path to record is found:
577            [$elementTable, $elementUid] = explode(':', $elementName);
578            $elementUid = (int)$elementUid;
579            $elementRecord = BackendUtility::getRecord($elementTable, $elementUid);
580            $recordTitle = BackendUtility::getRecordTitle($elementTable, $elementRecord);
581            if ($elementTable === 'pages') {
582                $pageUid = $elementUid;
583            } else {
584                BackendUtility::fixVersioningPid($elementTable, $elementRecord);
585                $pageUid = ($elementUid = $elementRecord['pid']);
586            }
587
588            // new way, options are
589            // pageTSconfig: tx_version.workspaces.stageNotificationEmail.subject
590            // userTSconfig: page.tx_version.workspaces.stageNotificationEmail.subject
591            $pageTsConfig = BackendUtility::getPagesTSconfig($pageUid);
592            $emailConfig = $pageTsConfig['tx_version.']['workspaces.']['stageNotificationEmail.'];
593            $markers = [
594                '###RECORD_TITLE###' => $recordTitle,
595                '###RECORD_PATH###' => BackendUtility::getRecordPath($elementUid, '', 20),
596                '###SITE_NAME###' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
597                '###SITE_URL###' => GeneralUtility::getIndpEnv('TYPO3_SITE_URL') . TYPO3_mainDir,
598                '###WORKSPACE_TITLE###' => $workspaceRec['title'],
599                '###WORKSPACE_UID###' => $workspaceRec['uid'],
600                '###ELEMENT_NAME###' => $elementName,
601                '###NEXT_STAGE###' => $newStage,
602                '###COMMENT###' => $comment,
603                // See: #30212 - keep both markers for compatibility
604                '###USER_REALNAME###' => $dataHandler->BE_USER->user['realName'],
605                '###USER_FULLNAME###' => $dataHandler->BE_USER->user['realName'],
606                '###USER_USERNAME###' => $dataHandler->BE_USER->user['username']
607            ];
608            // add marker for preview links if workspace extension is loaded
609            $this->workspaceService = GeneralUtility::makeInstance(WorkspaceService::class);
610            // only generate the link if the marker is in the template - prevents database from getting to much entries
611            if (GeneralUtility::isFirstPartOfStr($emailConfig['message'], 'LLL:')) {
612                $tempEmailMessage = $this->getLanguageService()->sL($emailConfig['message']);
613            } else {
614                $tempEmailMessage = $emailConfig['message'];
615            }
616            if (strpos($tempEmailMessage, '###PREVIEW_LINK###') !== false) {
617                $markers['###PREVIEW_LINK###'] = $previewUriBuilder->buildUriForPage((int)$elementUid, 0);
618            }
619            unset($tempEmailMessage);
620
621            $markers['###SPLITTED_PREVIEW_LINK###'] = $previewUriBuilder->buildUriForWorkspaceSplitPreview((int)$elementUid, true);
622            // Hook for preprocessing of the content for formmails:
623            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/version/class.tx_version_tcemain.php']['notifyStageChange-postModifyMarkers'] ?? [] as $className) {
624                $_procObj = GeneralUtility::makeInstance($className);
625                $markers = $_procObj->postModifyMarkers($markers, $this);
626            }
627            // send an email to each individual user, to ensure the
628            // multilanguage version of the email
629            $emailRecipients = [];
630            // an array of language objects that are needed
631            // for emails with different languages
632            $languageObjects = [
633                $this->getLanguageService()->lang => $this->getLanguageService()
634            ];
635            // loop through each recipient and send the email
636            foreach ($emails as $recipientData) {
637                // don't send an email twice
638                if (isset($emailRecipients[$recipientData['email']])) {
639                    continue;
640                }
641                $emailSubject = $emailConfig['subject'];
642                $emailMessage = $emailConfig['message'];
643                $emailRecipients[$recipientData['email']] = $recipientData['email'];
644                // check if the email needs to be localized
645                // in the users' language
646                if (GeneralUtility::isFirstPartOfStr($emailSubject, 'LLL:') || GeneralUtility::isFirstPartOfStr($emailMessage, 'LLL:')) {
647                    $recipientLanguage = $recipientData['lang'] ? $recipientData['lang'] : 'default';
648                    if (!isset($languageObjects[$recipientLanguage])) {
649                        // a LANG object in this language hasn't been
650                        // instantiated yet, so this is done here
651                        $languageObject = GeneralUtility::makeInstance(LanguageService::class);
652                        $languageObject->init($recipientLanguage);
653                        $languageObjects[$recipientLanguage] = $languageObject;
654                    } else {
655                        $languageObject = $languageObjects[$recipientLanguage];
656                    }
657                    if (GeneralUtility::isFirstPartOfStr($emailSubject, 'LLL:')) {
658                        $emailSubject = $languageObject->sL($emailSubject);
659                    }
660                    if (GeneralUtility::isFirstPartOfStr($emailMessage, 'LLL:')) {
661                        $emailMessage = $languageObject->sL($emailMessage);
662                    }
663                }
664                $templateService = GeneralUtility::makeInstance(MarkerBasedTemplateService::class);
665                $emailSubject = $templateService->substituteMarkerArray($emailSubject, $markers, '', true, true);
666                $emailMessage = $templateService->substituteMarkerArray($emailMessage, $markers, '', true, true);
667                // Send an email to the recipient
668                /** @var \TYPO3\CMS\Core\Mail\MailMessage $mail */
669                $mail = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\MailMessage::class);
670                if (!empty($recipientData['realName'])) {
671                    $recipient = [$recipientData['email'] => $recipientData['realName']];
672                } else {
673                    $recipient = $recipientData['email'];
674                }
675                $mail->setTo($recipient)
676                    ->setSubject($emailSubject)
677                    ->setBody($emailMessage);
678                $mail->send();
679            }
680            $emailRecipients = implode(',', $emailRecipients);
681            if ($dataHandler->enableLogging) {
682                $propertyArray = $dataHandler->getRecordProperties($table, $id);
683                $pid = $propertyArray['pid'];
684                $dataHandler->log($table, $id, 0, 0, 0, 'Notification email for stage change was sent to "' . $emailRecipients . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
685            }
686        }
687    }
688
689    /**
690     * Return be_users that should be notified on stage change from input list.
691     * previously called notifyStageChange_getEmails() in DataHandler
692     *
693     * @param string $listOfUsers List of backend users, on the form "be_users_10,be_users_2" or "10,2" in case noTablePrefix is set.
694     * @param bool $noTablePrefix If set, the input list are integers and not strings.
695     * @return array Array of emails
696     */
697    protected function getEmailsForStageChangeNotification($listOfUsers, $noTablePrefix = false)
698    {
699        $users = GeneralUtility::trimExplode(',', $listOfUsers, true);
700        $emails = [];
701        foreach ($users as $userIdent) {
702            if ($noTablePrefix) {
703                $id = (int)$userIdent;
704            } else {
705                [$table, $id] = GeneralUtility::revExplode('_', $userIdent, 2);
706            }
707            if ($table === 'be_users' || $noTablePrefix) {
708                if ($userRecord = BackendUtility::getRecord('be_users', $id, 'uid,email,lang,realName', BackendUtility::BEenableFields('be_users'))) {
709                    if (trim($userRecord['email']) !== '') {
710                        $emails[$id] = $userRecord;
711                    }
712                }
713            }
714        }
715        return $emails;
716    }
717
718    /****************************
719     *****  Stage Changes  ******
720     ****************************/
721    /**
722     * Setting stage of record
723     *
724     * @param string $table Table name
725     * @param int $id
726     * @param int $stageId Stage ID to set
727     * @param string $comment Comment that goes into log
728     * @param bool $notificationEmailInfo Accumulate state changes in memory for compiled notification email?
729     * @param DataHandler $dataHandler DataHandler object
730     * @param array $notificationAlternativeRecipients comma separated list of recipients to notify instead of normal be_users
731     */
732    protected function version_setStage($table, $id, $stageId, $comment = '', $notificationEmailInfo = false, DataHandler $dataHandler, array $notificationAlternativeRecipients = [])
733    {
734        if ($errorCode = $dataHandler->BE_USER->workspaceCannotEditOfflineVersion($table, $id)) {
735            $dataHandler->newlog('Attempt to set stage for record failed: ' . $errorCode, 1);
736        } elseif ($dataHandler->checkRecordUpdateAccess($table, $id)) {
737            $record = BackendUtility::getRecord($table, $id);
738            $stat = $dataHandler->BE_USER->checkWorkspace($record['t3ver_wsid']);
739            // check if the usere is allowed to the current stage, so it's also allowed to send to next stage
740            if ($dataHandler->BE_USER->workspaceCheckStageForCurrent($record['t3ver_stage'])) {
741                // Set stage of record:
742                GeneralUtility::makeInstance(ConnectionPool::class)
743                    ->getConnectionForTable($table)
744                    ->update(
745                        $table,
746                        [
747                            't3ver_stage' => $stageId,
748                        ],
749                        ['uid' => (int)$id]
750                    );
751
752                if ($dataHandler->enableLogging) {
753                    $propertyArray = $dataHandler->getRecordProperties($table, $id);
754                    $pid = $propertyArray['pid'];
755                    $dataHandler->log($table, $id, 0, 0, 0, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
756                }
757                // TEMPORARY, except 6-30 as action/detail number which is observed elsewhere!
758                $dataHandler->log($table, $id, 6, 0, 0, 'Stage raised...', 30, ['comment' => $comment, 'stage' => $stageId]);
759                if ((int)$stat['stagechg_notification'] > 0) {
760                    if ($notificationEmailInfo) {
761                        $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['shared'] = [$stat, $stageId, $comment];
762                        $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['elements'][] = $table . ':' . $id;
763                        $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['alternativeRecipients'] = $notificationAlternativeRecipients;
764                    } else {
765                        $this->notifyStageChange($stat, $stageId, $table, $id, $comment, $dataHandler, $notificationAlternativeRecipients);
766                    }
767                }
768            } else {
769                $dataHandler->newlog('The member user tried to set a stage value "' . $stageId . '" that was not allowed', 1);
770            }
771        } else {
772            $dataHandler->newlog('Attempt to set stage for record failed because you do not have edit access', 1);
773        }
774    }
775
776    /*****************************
777     *****  CMD versioning  ******
778     *****************************/
779
780    /**
781     * Swapping versions of a record
782     * Version from archive (future/past, called "swap version") will get the uid of the "t3ver_oid", the official element with uid = "t3ver_oid" will get the new versions old uid. PIDs are swapped also
783     *
784     * @param string $table Table name
785     * @param int $id UID of the online record to swap
786     * @param int $swapWith UID of the archived version to swap with!
787     * @param bool $swapIntoWS If set, swaps online into workspace instead of publishing out of workspace.
788     * @param DataHandler $dataHandler DataHandler object
789     * @param string $comment Notification comment
790     * @param bool $notificationEmailInfo Accumulate state changes in memory for compiled notification email?
791     * @param array $notificationAlternativeRecipients comma separated list of recipients to notificate instead of normal be_users
792     */
793    protected function version_swap($table, $id, $swapWith, $swapIntoWS = false, DataHandler $dataHandler, $comment = '', $notificationEmailInfo = false, $notificationAlternativeRecipients = [])
794    {
795
796        // Check prerequisites before start swapping
797
798        // Skip records that have been deleted during the current execution
799        if ($dataHandler->hasDeletedRecord($table, $id)) {
800            return;
801        }
802
803        // First, check if we may actually edit the online record
804        if (!$dataHandler->checkRecordUpdateAccess($table, $id)) {
805            $dataHandler->newlog('Error: You cannot swap versions for a record you do not have access to edit!', 1);
806            return;
807        }
808        // Select the two versions:
809        $curVersion = BackendUtility::getRecord($table, $id, '*');
810        $swapVersion = BackendUtility::getRecord($table, $swapWith, '*');
811        $movePlh = [];
812        $movePlhID = 0;
813        if (!(is_array($curVersion) && is_array($swapVersion))) {
814            $dataHandler->newlog('Error: Either online or swap version could not be selected!', 2);
815            return;
816        }
817        if (!$dataHandler->BE_USER->workspacePublishAccess($swapVersion['t3ver_wsid'])) {
818            $dataHandler->newlog('User could not publish records from workspace #' . $swapVersion['t3ver_wsid'], 1);
819            return;
820        }
821        $wsAccess = $dataHandler->BE_USER->checkWorkspace($swapVersion['t3ver_wsid']);
822        if (!($swapVersion['t3ver_wsid'] <= 0 || !($wsAccess['publish_access'] & 1) || (int)$swapVersion['t3ver_stage'] === -10)) {
823            $dataHandler->newlog('Records in workspace #' . $swapVersion['t3ver_wsid'] . ' can only be published when in "Publish" stage.', 1);
824            return;
825        }
826        if (!($dataHandler->doesRecordExist($table, $swapWith, 'show') && $dataHandler->checkRecordUpdateAccess($table, $swapWith))) {
827            $dataHandler->newlog('You cannot publish a record you do not have edit and show permissions for', 1);
828            return;
829        }
830        if ($swapIntoWS && !$dataHandler->BE_USER->workspaceSwapAccess()) {
831            $dataHandler->newlog('Workspace #' . $swapVersion['t3ver_wsid'] . ' does not support swapping.', 1);
832            return;
833        }
834        // Check if the swapWith record really IS a version of the original!
835        if (!(((int)$swapVersion['pid'] == -1 && (int)$curVersion['pid'] >= 0) && (int)$swapVersion['t3ver_oid'] === (int)$id)) {
836            $dataHandler->newlog('In swap version, either pid was not -1 or the t3ver_oid didn\'t match the id of the online version as it must!', 2);
837            return;
838        }
839        // Lock file name:
840        $lockFileName = Environment::getVarPath() . '/lock/swap' . $table . '_' . $id . '.ser';
841        if (@is_file($lockFileName)) {
842            $dataHandler->newlog('A swapping lock file was present. Either another swap process is already running or a previous swap process failed. Ask your administrator to handle the situation.', 2);
843            return;
844        }
845
846        // Now start to swap records by first creating the lock file
847
848        // Write lock-file:
849        GeneralUtility::writeFileToTypo3tempDir($lockFileName, serialize([
850            'tstamp' => $GLOBALS['EXEC_TIME'],
851            'user' => $dataHandler->BE_USER->user['username'],
852            'curVersion' => $curVersion,
853            'swapVersion' => $swapVersion
854        ]));
855        // Find fields to keep
856        $keepFields = $this->getUniqueFields($table);
857        if ($GLOBALS['TCA'][$table]['ctrl']['sortby']) {
858            $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['sortby'];
859        }
860        // l10n-fields must be kept otherwise the localization
861        // will be lost during the publishing
862        if ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']) {
863            $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
864        }
865        // Swap "keepfields"
866        foreach ($keepFields as $fN) {
867            $tmp = $swapVersion[$fN];
868            $swapVersion[$fN] = $curVersion[$fN];
869            $curVersion[$fN] = $tmp;
870        }
871        // Preserve states:
872        $t3ver_state = [];
873        $t3ver_state['swapVersion'] = $swapVersion['t3ver_state'];
874        $t3ver_state['curVersion'] = $curVersion['t3ver_state'];
875        // Modify offline version to become online:
876        $tmp_wsid = $swapVersion['t3ver_wsid'];
877        // Set pid for ONLINE
878        $swapVersion['pid'] = (int)$curVersion['pid'];
879        // We clear this because t3ver_oid only make sense for offline versions
880        // and we want to prevent unintentional misuse of this
881        // value for online records.
882        $swapVersion['t3ver_oid'] = 0;
883        // In case of swapping and the offline record has a state
884        // (like 2 or 4 for deleting or move-pointer) we set the
885        // current workspace ID so the record is not deselected
886        // in the interface by BackendUtility::versioningPlaceholderClause()
887        $swapVersion['t3ver_wsid'] = 0;
888        if ($swapIntoWS) {
889            if ($t3ver_state['swapVersion'] > 0) {
890                $swapVersion['t3ver_wsid'] = $dataHandler->BE_USER->workspace;
891            } else {
892                $swapVersion['t3ver_wsid'] = (int)$curVersion['t3ver_wsid'];
893            }
894        }
895        $swapVersion['t3ver_tstamp'] = $GLOBALS['EXEC_TIME'];
896        $swapVersion['t3ver_stage'] = 0;
897        if (!$swapIntoWS) {
898            $swapVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE);
899        }
900        // Moving element.
901        if (BackendUtility::isTableWorkspaceEnabled($table)) {
902            //  && $t3ver_state['swapVersion']==4   // Maybe we don't need this?
903            if ($plhRec = BackendUtility::getMovePlaceholder($table, $id, 't3ver_state,pid,uid' . ($GLOBALS['TCA'][$table]['ctrl']['sortby'] ? ',' . $GLOBALS['TCA'][$table]['ctrl']['sortby'] : ''))) {
904                $movePlhID = $plhRec['uid'];
905                $movePlh['pid'] = $swapVersion['pid'];
906                $swapVersion['pid'] = (int)$plhRec['pid'];
907                $curVersion['t3ver_state'] = (int)$swapVersion['t3ver_state'];
908                $swapVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE);
909                if ($GLOBALS['TCA'][$table]['ctrl']['sortby']) {
910                    // sortby is a "keepFields" which is why this will work...
911                    $movePlh[$GLOBALS['TCA'][$table]['ctrl']['sortby']] = $swapVersion[$GLOBALS['TCA'][$table]['ctrl']['sortby']];
912                    $swapVersion[$GLOBALS['TCA'][$table]['ctrl']['sortby']] = $plhRec[$GLOBALS['TCA'][$table]['ctrl']['sortby']];
913                }
914            }
915        }
916        // Take care of relations in each field (e.g. IRRE):
917        if (is_array($GLOBALS['TCA'][$table]['columns'])) {
918            foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $fieldConf) {
919                if (isset($fieldConf['config']) && is_array($fieldConf['config'])) {
920                    $this->version_swap_processFields($table, $field, $fieldConf['config'], $curVersion, $swapVersion, $dataHandler);
921                }
922            }
923        }
924        unset($swapVersion['uid']);
925        // Modify online version to become offline:
926        unset($curVersion['uid']);
927        // Set pid for OFFLINE
928        $curVersion['pid'] = -1;
929        $curVersion['t3ver_oid'] = (int)$id;
930        $curVersion['t3ver_wsid'] = $swapIntoWS ? (int)$tmp_wsid : 0;
931        $curVersion['t3ver_tstamp'] = $GLOBALS['EXEC_TIME'];
932        $curVersion['t3ver_count'] = $curVersion['t3ver_count'] + 1;
933        // Increment lifecycle counter
934        $curVersion['t3ver_stage'] = 0;
935        if (!$swapIntoWS) {
936            $curVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE);
937        }
938        // Registering and swapping MM relations in current and swap records:
939        $dataHandler->version_remapMMForVersionSwap($table, $id, $swapWith);
940        // Generating proper history data to prepare logging
941        $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $id, $swapVersion);
942        $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $swapWith, $curVersion);
943
944        // Execute swapping:
945        $sqlErrors = [];
946        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
947
948        $platform = $connection->getDatabasePlatform();
949        $tableDetails = null;
950        if ($platform instanceof SQLServerPlatform) {
951            // mssql needs to set proper PARAM_LOB and others to update fields
952            $tableDetails = $connection->getSchemaManager()->listTableDetails($table);
953        }
954
955        try {
956            $types = [];
957
958            if ($platform instanceof SQLServerPlatform) {
959                foreach ($curVersion as $columnName => $columnValue) {
960                    $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
961                }
962            }
963
964            $connection->update(
965                $table,
966                $swapVersion,
967                ['uid' => (int)$id],
968                $types
969            );
970        } catch (DBALException $e) {
971            $sqlErrors[] = $e->getPrevious()->getMessage();
972        }
973
974        if (empty($sqlErrors)) {
975            try {
976                $types = [];
977                if ($platform instanceof SQLServerPlatform) {
978                    foreach ($curVersion as $columnName => $columnValue) {
979                        $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
980                    }
981                }
982
983                $connection->update(
984                    $table,
985                    $curVersion,
986                    ['uid' => (int)$swapWith],
987                    $types
988                );
989                unlink($lockFileName);
990            } catch (DBALException $e) {
991                $sqlErrors[] = $e->getPrevious()->getMessage();
992            }
993        }
994
995        if (!empty($sqlErrors)) {
996            $dataHandler->newlog('During Swapping: SQL errors happened: ' . implode('; ', $sqlErrors), 2);
997        } else {
998            // Register swapped ids for later remapping:
999            $this->remappedIds[$table][$id] = $swapWith;
1000            $this->remappedIds[$table][$swapWith] = $id;
1001            // If a moving operation took place...:
1002            if ($movePlhID) {
1003                // Remove, if normal publishing:
1004                if (!$swapIntoWS) {
1005                    // For delete + completely delete!
1006                    $dataHandler->deleteEl($table, $movePlhID, true, true);
1007                } else {
1008                    // Otherwise update the movePlaceholder:
1009                    GeneralUtility::makeInstance(ConnectionPool::class)
1010                        ->getConnectionForTable($table)
1011                        ->update(
1012                            $table,
1013                            $movePlh,
1014                            ['uid' => (int)$movePlhID]
1015                        );
1016                    $dataHandler->addRemapStackRefIndex($table, $movePlhID);
1017                }
1018            }
1019            // Checking for delete:
1020            // Delete only if new/deleted placeholders are there.
1021            if (!$swapIntoWS && ((int)$t3ver_state['swapVersion'] === 1 || (int)$t3ver_state['swapVersion'] === 2)) {
1022                // Force delete
1023                $dataHandler->deleteEl($table, $id, true);
1024            }
1025            if ($dataHandler->enableLogging) {
1026                $dataHandler->log($table, $id, 0, 0, 0, ($swapIntoWS ? 'Swapping' : 'Publishing') . ' successful for table "' . $table . '" uid ' . $id . '=>' . $swapWith, -1, [], $dataHandler->eventPid($table, $id, $swapVersion['pid']));
1027            }
1028
1029            // Update reference index of the live record:
1030            $dataHandler->addRemapStackRefIndex($table, $id);
1031            // Set log entry for live record:
1032            $propArr = $dataHandler->getRecordPropertiesFromRow($table, $swapVersion);
1033            if ($propArr['_ORIG_pid'] == -1) {
1034                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated');
1035            } else {
1036                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated');
1037            }
1038            $theLogId = $dataHandler->log($table, $id, 2, $propArr['pid'], 0, $label, 10, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']);
1039            $dataHandler->setHistory($table, $id, $theLogId);
1040            // Update reference index of the offline record:
1041            $dataHandler->addRemapStackRefIndex($table, $swapWith);
1042            // Set log entry for offline record:
1043            $propArr = $dataHandler->getRecordPropertiesFromRow($table, $curVersion);
1044            if ($propArr['_ORIG_pid'] == -1) {
1045                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated');
1046            } else {
1047                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated');
1048            }
1049            $theLogId = $dataHandler->log($table, $swapWith, 2, $propArr['pid'], 0, $label, 10, [$propArr['header'], $table . ':' . $swapWith], $propArr['event_pid']);
1050            $dataHandler->setHistory($table, $swapWith, $theLogId);
1051
1052            $stageId = -20; // \TYPO3\CMS\Workspaces\Service\StagesService::STAGE_PUBLISH_EXECUTE_ID;
1053            if ($notificationEmailInfo) {
1054                $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment;
1055                $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment];
1056                $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = $table . ':' . $id;
1057                $this->notificationEmailInfo[$notificationEmailInfoKey]['alternativeRecipients'] = $notificationAlternativeRecipients;
1058            } else {
1059                $this->notifyStageChange($wsAccess, $stageId, $table, $id, $comment, $dataHandler, $notificationAlternativeRecipients);
1060            }
1061            // Write to log with stageId -20
1062            if ($dataHandler->enableLogging) {
1063                $propArr = $dataHandler->getRecordProperties($table, $id);
1064                $pid = $propArr['pid'];
1065                $dataHandler->log($table, $id, 0, 0, 0, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
1066            }
1067            $dataHandler->log($table, $id, 6, 0, 0, 'Published', 30, ['comment' => $comment, 'stage' => $stageId]);
1068
1069            // Clear cache:
1070            $dataHandler->registerRecordIdForPageCacheClearing($table, $id);
1071            // Checking for "new-placeholder" and if found, delete it (BUT FIRST after swapping!):
1072            if (!$swapIntoWS && $t3ver_state['curVersion'] > 0) {
1073                // For delete + completely delete!
1074                if ($table === 'pages') {
1075                    // Note on fifth argument false: At this point both $curVersion and $swapVersion page records are
1076                    // identical in DB. deleteEl() would now usually find all records assigned to our obsolete
1077                    // page which at the same time belong to our current version page, and would delete them.
1078                    // To suppress this, false tells deleteEl() to only delete the obsolete page but not its assigned records.
1079                    $dataHandler->deleteEl($table, $swapWith, true, true, false);
1080                } else {
1081                    $dataHandler->deleteEl($table, $swapWith, true, true);
1082                }
1083            }
1084
1085            //Update reference index for live workspace too:
1086            /** @var \TYPO3\CMS\Core\Database\ReferenceIndex $refIndexObj */
1087            $refIndexObj = GeneralUtility::makeInstance(ReferenceIndex::class);
1088            $refIndexObj->setWorkspaceId(0);
1089            $refIndexObj->updateRefIndexTable($table, $id);
1090            $refIndexObj->updateRefIndexTable($table, $swapWith);
1091        }
1092    }
1093
1094    /**
1095     * Writes remapped foreign field (IRRE).
1096     *
1097     * @param RelationHandler $dbAnalysis Instance that holds the sorting order of child records
1098     * @param array $configuration The TCA field configuration
1099     * @param int $parentId The uid of the parent record
1100     */
1101    public function writeRemappedForeignField(RelationHandler $dbAnalysis, array $configuration, $parentId)
1102    {
1103        foreach ($dbAnalysis->itemArray as &$item) {
1104            if (isset($this->remappedIds[$item['table']][$item['id']])) {
1105                $item['id'] = $this->remappedIds[$item['table']][$item['id']];
1106            }
1107        }
1108        $dbAnalysis->writeForeignField($configuration, $parentId);
1109    }
1110
1111    /**
1112     * Processes fields of a record for the publishing/swapping process.
1113     * Basically this takes care of IRRE (type "inline") child references.
1114     *
1115     * @param string $tableName Table name
1116     * @param string $fieldName: Field name
1117     * @param array $configuration TCA field configuration
1118     * @param array $liveData: Live record data
1119     * @param array $versionData: Version record data
1120     * @param DataHandler $dataHandler Calling data-handler object
1121     */
1122    protected function version_swap_processFields($tableName, $fieldName, array $configuration, array $liveData, array $versionData, DataHandler $dataHandler)
1123    {
1124        $inlineType = $dataHandler->getInlineFieldType($configuration);
1125        if ($inlineType !== 'field') {
1126            return;
1127        }
1128        $foreignTable = $configuration['foreign_table'];
1129        // Read relations that point to the current record (e.g. live record):
1130        $liveRelations = $this->createRelationHandlerInstance();
1131        $liveRelations->setWorkspaceId(0);
1132        $liveRelations->start('', $foreignTable, '', $liveData['uid'], $tableName, $configuration);
1133        // Read relations that point to the record to be swapped with e.g. draft record):
1134        $versionRelations = $this->createRelationHandlerInstance();
1135        $versionRelations->setUseLiveReferenceIds(false);
1136        $versionRelations->start('', $foreignTable, '', $versionData['uid'], $tableName, $configuration);
1137        // Update relations for both (workspace/versioning) sites:
1138        if (count($liveRelations->itemArray)) {
1139            $dataHandler->addRemapAction(
1140                $tableName,
1141                $liveData['uid'],
1142                [$this, 'updateInlineForeignFieldSorting'],
1143                [$tableName, $liveData['uid'], $foreignTable, $liveRelations->tableArray[$foreignTable], $configuration, $dataHandler->BE_USER->workspace]
1144            );
1145        }
1146        if (count($versionRelations->itemArray)) {
1147            $dataHandler->addRemapAction(
1148                $tableName,
1149                $liveData['uid'],
1150                [$this, 'updateInlineForeignFieldSorting'],
1151                [$tableName, $liveData['uid'], $foreignTable, $versionRelations->tableArray[$foreignTable], $configuration, 0]
1152            );
1153        }
1154    }
1155
1156    /**
1157     * Updates foreign field sorting values of versioned and live
1158     * parents after(!) the whole structure has been published.
1159     *
1160     * This method is used as callback function in
1161     * DataHandlerHook::version_swap_procBasedOnFieldType().
1162     * Sorting fields ("sortby") are not modified during the
1163     * workspace publishing/swapping process directly.
1164     *
1165     * @param string $parentTableName
1166     * @param string $parentId
1167     * @param string $foreignTableName
1168     * @param int[] $foreignIds
1169     * @param array $configuration
1170     * @param int $targetWorkspaceId
1171     * @internal
1172     */
1173    public function updateInlineForeignFieldSorting($parentTableName, $parentId, $foreignTableName, $foreignIds, array $configuration, $targetWorkspaceId)
1174    {
1175        $remappedIds = [];
1176        // Use remapped ids (live id <-> version id)
1177        foreach ($foreignIds as $foreignId) {
1178            if (!empty($this->remappedIds[$foreignTableName][$foreignId])) {
1179                $remappedIds[] = $this->remappedIds[$foreignTableName][$foreignId];
1180            } else {
1181                $remappedIds[] = $foreignId;
1182            }
1183        }
1184
1185        $relationHandler = $this->createRelationHandlerInstance();
1186        $relationHandler->setWorkspaceId($targetWorkspaceId);
1187        $relationHandler->setUseLiveReferenceIds(false);
1188        $relationHandler->start(implode(',', $remappedIds), $foreignTableName);
1189        $relationHandler->processDeletePlaceholder();
1190        $relationHandler->writeForeignField($configuration, $parentId);
1191    }
1192
1193    /**
1194     * Release version from this workspace (and into "Live" workspace but as an offline version).
1195     *
1196     * @param string $table Table name
1197     * @param int $id Record UID
1198     * @param bool $flush If set, will completely delete element
1199     * @param DataHandler $dataHandler DataHandler object
1200     */
1201    protected function version_clearWSID($table, $id, $flush = false, DataHandler $dataHandler)
1202    {
1203        if ($errorCode = $dataHandler->BE_USER->workspaceCannotEditOfflineVersion($table, $id)) {
1204            $dataHandler->newlog('Attempt to reset workspace for record failed: ' . $errorCode, 1);
1205            return;
1206        }
1207        if (!$dataHandler->checkRecordUpdateAccess($table, $id)) {
1208            $dataHandler->newlog('Attempt to reset workspace for record failed because you do not have edit access', 1);
1209            return;
1210        }
1211        $liveRec = BackendUtility::getLiveVersionOfRecord($table, $id, 'uid,t3ver_state');
1212        if (!$liveRec) {
1213            return;
1214        }
1215        // Clear workspace ID:
1216        $updateData = [
1217            't3ver_wsid' => 0,
1218            't3ver_tstamp' => $GLOBALS['EXEC_TIME']
1219        ];
1220        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1221        $connection->update(
1222            $table,
1223            $updateData,
1224            ['uid' => (int)$id]
1225        );
1226
1227        // Clear workspace ID for live version AND DELETE IT as well because it is a new record!
1228        if (
1229            VersionState::cast($liveRec['t3ver_state'])->equals(VersionState::NEW_PLACEHOLDER)
1230            || VersionState::cast($liveRec['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)
1231        ) {
1232            $connection->update(
1233                $table,
1234                $updateData,
1235                ['uid' => (int)$liveRec['uid']]
1236            );
1237
1238            // THIS assumes that the record was placeholder ONLY for ONE record (namely $id)
1239            $dataHandler->deleteEl($table, $liveRec['uid'], true);
1240        }
1241        // If "deleted" flag is set for the version that got released
1242        // it doesn't make sense to keep that "placeholder" anymore and we delete it completly.
1243        $wsRec = BackendUtility::getRecord($table, $id);
1244        if (
1245            $flush
1246            || (
1247                VersionState::cast($wsRec['t3ver_state'])->equals(VersionState::NEW_PLACEHOLDER)
1248                || VersionState::cast($wsRec['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)
1249            )
1250        ) {
1251            $dataHandler->deleteEl($table, $id, true, true);
1252        }
1253        // Remove the move-placeholder if found for live record.
1254        if (BackendUtility::isTableWorkspaceEnabled($table)) {
1255            if ($plhRec = BackendUtility::getMovePlaceholder($table, $liveRec['uid'], 'uid')) {
1256                $dataHandler->deleteEl($table, $plhRec['uid'], true, true);
1257            }
1258        }
1259    }
1260
1261    /**
1262     * In case a sys_workspace_stage record is deleted we do a hard reset
1263     * for all existing records in that stage to avoid that any of these end up
1264     * as orphan records.
1265     *
1266     * @param int $stageId Elements with this stage are resetted
1267     */
1268    protected function resetStageOfElements($stageId)
1269    {
1270        foreach ($this->getTcaTables() as $tcaTable) {
1271            if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) {
1272                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1273                    ->getQueryBuilderForTable($tcaTable);
1274
1275                $queryBuilder
1276                    ->update($tcaTable)
1277                    ->set('t3ver_stage', StagesService::STAGE_EDIT_ID)
1278                    ->where(
1279                        $queryBuilder->expr()->eq(
1280                            't3ver_stage',
1281                            $queryBuilder->createNamedParameter($stageId, \PDO::PARAM_INT)
1282                        ),
1283                        $queryBuilder->expr()->eq(
1284                            'pid',
1285                            $queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
1286                        ),
1287                        $queryBuilder->expr()->gt(
1288                            't3ver_wsid',
1289                            $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1290                        )
1291                    )
1292                    ->execute();
1293            }
1294        }
1295    }
1296
1297    /**
1298     * Flushes elements of a particular workspace to avoid orphan records.
1299     *
1300     * @param int $workspaceId The workspace to be flushed
1301     */
1302    protected function flushWorkspaceElements($workspaceId)
1303    {
1304        $command = [];
1305        foreach ($this->getTcaTables() as $tcaTable) {
1306            if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) {
1307                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1308                    ->getQueryBuilderForTable($tcaTable);
1309                $queryBuilder->getRestrictions()
1310                    ->removeAll()
1311                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
1312                    ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, $workspaceId, false));
1313
1314                $result = $queryBuilder
1315                    ->select('uid')
1316                    ->from($tcaTable)
1317                    ->orderBy('uid')
1318                    ->execute();
1319
1320                while (($recordId = $result->fetchColumn()) !== false) {
1321                    $command[$tcaTable][$recordId]['version']['action'] = 'flush';
1322                }
1323            }
1324        }
1325        if (!empty($command)) {
1326            $dataHandler = $this->getDataHandler();
1327            $dataHandler->start([], $command);
1328            $dataHandler->process_cmdmap();
1329        }
1330    }
1331
1332    /**
1333     * Gets all defined TCA tables.
1334     *
1335     * @return array
1336     */
1337    protected function getTcaTables()
1338    {
1339        return array_keys($GLOBALS['TCA']);
1340    }
1341
1342    /**
1343     * @return DataHandler
1344     */
1345    protected function getDataHandler()
1346    {
1347        return GeneralUtility::makeInstance(DataHandler::class);
1348    }
1349
1350    /**
1351     * Flushes the workspace cache for current workspace and for the virtual "all workspaces" too.
1352     *
1353     * @param int $workspaceId The workspace to be flushed in cache
1354     */
1355    protected function flushWorkspaceCacheEntriesByWorkspaceId($workspaceId)
1356    {
1357        $workspacesCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('workspaces_cache');
1358        $workspacesCache->flushByTag($workspaceId);
1359        $workspacesCache->flushByTag(WorkspaceService::SELECT_ALL_WORKSPACES);
1360    }
1361
1362    /*******************************
1363     *****  helper functions  ******
1364     *******************************/
1365
1366    /**
1367     * Finds all elements for swapping versions in workspace
1368     *
1369     * @param string $table Table name of the original element to swap
1370     * @param int $id UID of the original element to swap (online)
1371     * @param int $offlineId As above but offline
1372     * @return array Element data. Key is table name, values are array with first element as online UID, second - offline UID
1373     */
1374    public function findPageElementsForVersionSwap($table, $id, $offlineId)
1375    {
1376        $rec = BackendUtility::getRecord($table, $offlineId, 't3ver_wsid');
1377        $workspaceId = (int)$rec['t3ver_wsid'];
1378        $elementData = [];
1379        if ($workspaceId === 0) {
1380            return $elementData;
1381        }
1382        // Get page UID for LIVE and workspace
1383        if ($table !== 'pages') {
1384            $rec = BackendUtility::getRecord($table, $id, 'pid');
1385            $pageId = $rec['pid'];
1386            $rec = BackendUtility::getRecord('pages', $pageId);
1387            BackendUtility::workspaceOL('pages', $rec, $workspaceId);
1388            $offlinePageId = $rec['_ORIG_uid'];
1389        } else {
1390            $pageId = $id;
1391            $offlinePageId = $offlineId;
1392        }
1393        // Traversing all tables supporting versioning:
1394        foreach ($GLOBALS['TCA'] as $table => $cfg) {
1395            if ($GLOBALS['TCA'][$table]['ctrl']['versioningWS'] && $table !== 'pages') {
1396                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1397                    ->getQueryBuilderForTable($table);
1398
1399                $queryBuilder->getRestrictions()
1400                    ->removeAll()
1401                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1402
1403                $statement = $queryBuilder
1404                    ->select('A.uid AS offlineUid', 'B.uid AS uid')
1405                    ->from($table, 'A')
1406                    ->from($table, 'B')
1407                    ->where(
1408                        $queryBuilder->expr()->eq(
1409                            'A.pid',
1410                            $queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
1411                        ),
1412                        $queryBuilder->expr()->eq(
1413                            'B.pid',
1414                            $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
1415                        ),
1416                        $queryBuilder->expr()->eq(
1417                            'A.t3ver_wsid',
1418                            $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1419                        ),
1420                        $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1421                    )
1422                    ->execute();
1423
1424                while ($row = $statement->fetch()) {
1425                    $elementData[$table][] = [$row['uid'], $row['offlineUid']];
1426                }
1427            }
1428        }
1429        if ($offlinePageId && $offlinePageId != $pageId) {
1430            $elementData['pages'][] = [$pageId, $offlinePageId];
1431        }
1432
1433        return $elementData;
1434    }
1435
1436    /**
1437     * Searches for all elements from all tables on the given pages in the same workspace.
1438     *
1439     * @param array $pageIdList List of PIDs to search
1440     * @param int $workspaceId Workspace ID
1441     * @param array $elementList List of found elements. Key is table name, value is array of element UIDs
1442     */
1443    public function findPageElementsForVersionStageChange(array $pageIdList, $workspaceId, array &$elementList)
1444    {
1445        if ($workspaceId == 0) {
1446            return;
1447        }
1448        // Traversing all tables supporting versioning:
1449        foreach ($GLOBALS['TCA'] as $table => $cfg) {
1450            if ($GLOBALS['TCA'][$table]['ctrl']['versioningWS'] && $table !== 'pages') {
1451                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1452                    ->getQueryBuilderForTable($table);
1453
1454                $queryBuilder->getRestrictions()
1455                    ->removeAll()
1456                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1457
1458                $statement = $queryBuilder
1459                    ->select('A.uid')
1460                    ->from($table, 'A')
1461                    ->from($table, 'B')
1462                    ->where(
1463                        $queryBuilder->expr()->eq(
1464                            'A.pid',
1465                            $queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
1466                        ),
1467                        $queryBuilder->expr()->in(
1468                            'B.pid',
1469                            $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY)
1470                        ),
1471                        $queryBuilder->expr()->eq(
1472                            'A.t3ver_wsid',
1473                            $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1474                        ),
1475                        $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1476                    )
1477                    ->groupBy('A.uid')
1478                    ->execute();
1479
1480                while ($row = $statement->fetch()) {
1481                    $elementList[$table][] = $row['uid'];
1482                }
1483                if (is_array($elementList[$table])) {
1484                    // Yes, it is possible to get non-unique array even with DISTINCT above!
1485                    // It happens because several UIDs are passed in the array already.
1486                    $elementList[$table] = array_unique($elementList[$table]);
1487                }
1488            }
1489        }
1490    }
1491
1492    /**
1493     * Finds page UIDs for the element from table <code>$table</code> with UIDs from <code>$idList</code>
1494     *
1495     * @param string $table Table to search
1496     * @param array $idList List of records' UIDs
1497     * @param int $workspaceId Workspace ID. We need this parameter because user can be in LIVE but he still can publisg DRAFT from ws module!
1498     * @param array $pageIdList List of found page UIDs
1499     * @param array $elementList List of found element UIDs. Key is table name, value is list of UIDs
1500     */
1501    public function findPageIdsForVersionStateChange($table, array $idList, $workspaceId, array &$pageIdList, array &$elementList)
1502    {
1503        if ($workspaceId == 0) {
1504            return;
1505        }
1506
1507        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1508            ->getQueryBuilderForTable($table);
1509        $queryBuilder->getRestrictions()
1510            ->removeAll()
1511            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1512
1513        $statement = $queryBuilder
1514            ->select('B.pid')
1515            ->from($table, 'A')
1516            ->from($table, 'B')
1517            ->where(
1518                $queryBuilder->expr()->eq(
1519                    'A.pid',
1520                    $queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
1521                ),
1522                $queryBuilder->expr()->eq(
1523                    'A.t3ver_wsid',
1524                    $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1525                ),
1526                $queryBuilder->expr()->in(
1527                    'A.uid',
1528                    $queryBuilder->createNamedParameter($idList, Connection::PARAM_INT_ARRAY)
1529                ),
1530                $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1531            )
1532            ->groupBy('B.pid')
1533            ->execute();
1534
1535        while ($row = $statement->fetch()) {
1536            $pageIdList[] = $row['pid'];
1537            // Find ws version
1538            // Note: cannot use BackendUtility::getRecordWSOL()
1539            // here because it does not accept workspace id!
1540            $rec = BackendUtility::getRecord('pages', $row[0]);
1541            BackendUtility::workspaceOL('pages', $rec, $workspaceId);
1542            if ($rec['_ORIG_uid']) {
1543                $elementList['pages'][$row[0]] = $rec['_ORIG_uid'];
1544            }
1545        }
1546        // The line below is necessary even with DISTINCT
1547        // because several elements can be passed by caller
1548        $pageIdList = array_unique($pageIdList);
1549    }
1550
1551    /**
1552     * Finds real page IDs for state change.
1553     *
1554     * @param array $idList List of page UIDs, possibly versioned
1555     */
1556    public function findRealPageIds(array &$idList)
1557    {
1558        foreach ($idList as $key => $id) {
1559            $rec = BackendUtility::getRecord('pages', $id, 't3ver_oid');
1560            if ($rec['t3ver_oid'] > 0) {
1561                $idList[$key] = $rec['t3ver_oid'];
1562            }
1563        }
1564    }
1565
1566    /**
1567     * Creates a move placeholder for workspaces.
1568     * USE ONLY INTERNALLY
1569     * Moving placeholder: Can be done because the system sees it as a placeholder for NEW elements like t3ver_state=VersionState::NEW_PLACEHOLDER
1570     * Moving original: Will either create the placeholder if it doesn't exist or move existing placeholder in workspace.
1571     *
1572     * @param string $table Table name to move
1573     * @param int $uid Record uid to move (online record)
1574     * @param int $destPid Position to move to: $destPid: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
1575     * @param int $wsUid UID of offline version of online record
1576     * @param DataHandler $dataHandler DataHandler object
1577     * @see moveRecord()
1578     */
1579    protected function moveRecord_wsPlaceholders($table, $uid, $destPid, $wsUid, DataHandler $dataHandler)
1580    {
1581        // If a record gets moved after a record that already has a placeholder record
1582        // then the new placeholder record needs to be after the existing one
1583        $originalRecordDestinationPid = $destPid;
1584        if ($destPid < 0) {
1585            $movePlaceHolder = BackendUtility::getMovePlaceholder($table, abs($destPid), 'uid');
1586            if ($movePlaceHolder !== false) {
1587                $destPid = -$movePlaceHolder['uid'];
1588            }
1589        }
1590        if ($plh = BackendUtility::getMovePlaceholder($table, $uid, 'uid')) {
1591            // If already a placeholder exists, move it:
1592            $dataHandler->moveRecord_raw($table, $plh['uid'], $destPid);
1593        } else {
1594            // First, we create a placeholder record in the Live workspace that
1595            // represents the position to where the record is eventually moved to.
1596            $newVersion_placeholderFieldArray = [];
1597
1598            $factory = GeneralUtility::makeInstance(
1599                PlaceholderShadowColumnsResolver::class,
1600                $table,
1601                $GLOBALS['TCA'][$table] ?? []
1602            );
1603            $shadowColumns = $factory->forMovePlaceholder();
1604            // Set values from the versioned record to the move placeholder
1605            if (!empty($shadowColumns)) {
1606                $versionedRecord = BackendUtility::getRecord($table, $wsUid);
1607                foreach ($shadowColumns as $shadowColumn) {
1608                    if (isset($versionedRecord[$shadowColumn])) {
1609                        $newVersion_placeholderFieldArray[$shadowColumn] = $versionedRecord[$shadowColumn];
1610                    }
1611                }
1612            }
1613
1614            if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
1615                $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
1616            }
1617            if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
1618                $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $dataHandler->userid;
1619            }
1620            if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
1621                $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
1622            }
1623            if ($table === 'pages') {
1624                // Copy page access settings from original page to placeholder
1625                $perms_clause = $dataHandler->BE_USER->getPagePermsClause(Permission::PAGE_SHOW);
1626                $access = BackendUtility::readPageAccess($uid, $perms_clause);
1627                $newVersion_placeholderFieldArray['perms_userid'] = $access['perms_userid'];
1628                $newVersion_placeholderFieldArray['perms_groupid'] = $access['perms_groupid'];
1629                $newVersion_placeholderFieldArray['perms_user'] = $access['perms_user'];
1630                $newVersion_placeholderFieldArray['perms_group'] = $access['perms_group'];
1631                $newVersion_placeholderFieldArray['perms_everybody'] = $access['perms_everybody'];
1632            }
1633            $newVersion_placeholderFieldArray['t3ver_label'] = 'MovePlaceholder #' . $uid;
1634            $newVersion_placeholderFieldArray['t3ver_move_id'] = $uid;
1635            // Setting placeholder state value for temporary record
1636            $newVersion_placeholderFieldArray['t3ver_state'] = (string)new VersionState(VersionState::MOVE_PLACEHOLDER);
1637            // Setting workspace - only so display of place holders can filter out those from other workspaces.
1638            $newVersion_placeholderFieldArray['t3ver_wsid'] = $dataHandler->BE_USER->workspace;
1639            $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['label']] = $dataHandler->getPlaceholderTitleForTableLabel($table, 'MOVE-TO PLACEHOLDER for #' . $uid);
1640            // moving localized records requires to keep localization-settings for the placeholder too
1641            if (isset($GLOBALS['TCA'][$table]['ctrl']['languageField']) && isset($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
1642                $l10nParentRec = BackendUtility::getRecord($table, $uid);
1643                $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['languageField']] = $l10nParentRec[$GLOBALS['TCA'][$table]['ctrl']['languageField']];
1644                $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $l10nParentRec[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
1645                if (isset($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'])) {
1646                    $newVersion_placeholderFieldArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = $l10nParentRec[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']];
1647                }
1648                unset($l10nParentRec);
1649            }
1650            // Initially, create at root level.
1651            $newVersion_placeholderFieldArray['pid'] = 0;
1652            $id = 'NEW_MOVE_PLH';
1653            // Saving placeholder as 'original'
1654            $dataHandler->insertDB($table, $id, $newVersion_placeholderFieldArray, false);
1655            // Move the new placeholder from temporary root-level to location:
1656            $dataHandler->moveRecord_raw($table, $dataHandler->substNEWwithIDs[$id], $destPid);
1657            // Move the workspace-version of the original to be the version of the move-to-placeholder:
1658            // Setting placeholder state value for version (so it can know it is currently a new version...)
1659            $updateFields = [
1660                't3ver_state' => (string)new VersionState(VersionState::MOVE_POINTER)
1661            ];
1662
1663            GeneralUtility::makeInstance(ConnectionPool::class)
1664                ->getConnectionForTable($table)
1665                ->update(
1666                    $table,
1667                    $updateFields,
1668                    ['uid' => (int)$wsUid]
1669                );
1670        }
1671        // Check for the localizations of that element and move them as well
1672        $dataHandler->moveL10nOverlayRecords($table, $uid, $destPid, $originalRecordDestinationPid);
1673    }
1674
1675    /**
1676     * Gets an instance of the command map helper.
1677     *
1678     * @param DataHandler $dataHandler DataHandler object
1679     * @return CommandMap
1680     */
1681    public function getCommandMap(DataHandler $dataHandler)
1682    {
1683        return GeneralUtility::makeInstance(
1684            CommandMap::class,
1685            $this,
1686            $dataHandler,
1687            $dataHandler->cmdmap,
1688            $dataHandler->BE_USER->workspace
1689        );
1690    }
1691
1692    /**
1693     * Returns all fieldnames from a table which have the unique evaluation type set.
1694     *
1695     * @param string $table Table name
1696     * @return array Array of fieldnames
1697     */
1698    protected function getUniqueFields($table)
1699    {
1700        $listArr = [];
1701        if (empty($GLOBALS['TCA'][$table]['columns'])) {
1702            return $listArr;
1703        }
1704        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $configArr) {
1705            if ($configArr['config']['type'] === 'input') {
1706                $evalCodesArray = GeneralUtility::trimExplode(',', $configArr['config']['eval'], true);
1707                if (in_array('uniqueInPid', $evalCodesArray) || in_array('unique', $evalCodesArray)) {
1708                    $listArr[] = $field;
1709                }
1710            }
1711        }
1712        return $listArr;
1713    }
1714
1715    /**
1716     * @return RelationHandler
1717     */
1718    protected function createRelationHandlerInstance()
1719    {
1720        return GeneralUtility::makeInstance(RelationHandler::class);
1721    }
1722
1723    /**
1724     * @return LanguageService
1725     */
1726    protected function getLanguageService()
1727    {
1728        return $GLOBALS['LANG'];
1729    }
1730}
1731