TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,554 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\History\RecordHistoryRollback;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\ValueFormatter\FlexFormValueFormatter;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\DiffGranularity;
use TYPO3\CMS\Core\Utility\DiffUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller for showing the history module of TYPO3s backend.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementHistoryController
{
protected RecordHistory $historyObject;
/**
* Display inline differences or not
*/
protected bool $showDiff = true;
protected array $recordCache = [];
protected ModuleTemplate $view;
protected string $returnUrl = '';
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly DiffUtility $diffUtility,
private readonly FlexFormValueFormatter $flexFormValueFormatter,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ComponentFactory $componentFactory,
private readonly SiteFinder $siteFinder,
) {}
/**
* Injects the request object for the current request or sub request
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->view = $this->moduleTemplateFactory->create($request);
$backendUser = $this->getBackendUser();
$this->view->getDocHeaderComponent()->setPageBreadcrumb([]);
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$lastHistoryEntry = (int)($parsedBody['historyEntry'] ?? $queryParams['historyEntry'] ?? 0);
$rollbackFields = $parsedBody['rollbackFields'] ?? $queryParams['rollbackFields'] ?? null;
$element = $parsedBody['element'] ?? $queryParams['element'] ?? null;
$moduleSettings = $this->processSettings($request);
$this->view->assign('isUserInWorkspace', $backendUser->workspace > 0);
$this->showDiff = (bool)$moduleSettings['showDiff'];
// Start history object
$this->historyObject = GeneralUtility::makeInstance(RecordHistory::class, $element);
$this->historyObject->setShowSubElements((bool)$moduleSettings['showSubElements']);
$this->historyObject->setLastHistoryEntryNumber($lastHistoryEntry);
if ($moduleSettings['maxSteps']) {
$this->historyObject->setMaxSteps((int)$moduleSettings['maxSteps']);
}
// Do the actual logic now (rollback, show a diff for certain changes,
// or show the full history of a page or a specific record)
$changeLog = $this->historyObject->getChangeLog();
if (!empty($changeLog)) {
if ($rollbackFields !== null) {
$diff = $this->historyObject->getDiff($changeLog);
GeneralUtility::makeInstance(RecordHistoryRollback::class)->performRollback($rollbackFields, $diff);
} elseif ($lastHistoryEntry) {
$completeDiff = $this->historyObject->getDiff($changeLog);
$this->displayMultipleDiff($completeDiff);
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl(['historyEntry' => '']))
->setIcon($this->iconFactory->getIcon('actions-view-go-back', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:fullView'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button);
}
if ($this->historyObject->getElementString() !== '') {
$this->displayHistory($changeLog);
}
}
$elementData = $this->historyObject->getElementInformation();
$editLock = false;
if (!empty($elementData)) {
[$elementTable, $elementUid] = $elementData;
$elementUid = (int)$elementUid;
$this->setPagePath($elementTable, $elementUid);
$editLock = $this->getEditLockFromElement($elementTable, $elementUid);
// Get link to page history if the element history is shown
if ($elementTable !== 'pages') {
$parentPage = BackendUtility::getRecord($elementTable, $elementUid, '*', '', false);
if ($parentPage['pid'] > 0 && BackendUtility::readPageAccess($parentPage['pid'], $backendUser->getPagePermsClause(Permission::PAGE_SHOW))) {
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl([
'element' => 'pages:' . $parentPage['pid'],
'historyEntry' => '',
]))
->setIcon($this->iconFactory->getIcon('apps-pagetree-page-default', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:elementHistory_link'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
}
}
if ($element !== null) {
$this->addLanguageSwitcher($request, $backendUser, $element);
}
$this->view->assign('editLock', $editLock);
$this->view->assign('moduleSettings', $moduleSettings);
$this->view->assign('settingsFormUrl', $this->buildUrl());
// Setting up the buttons and markers for docheader
$this->getButtons();
return $this->view->renderResponse('RecordHistory/Main');
}
/**
* Creates the correct path to the current record
*/
protected function setPagePath(string $table, int $uid): void
{
$record = BackendUtility::getRecord($table, $uid, '*', '', false);
if ($table === 'pages') {
$pageId = $uid;
} else {
$pageId = $record['pid'];
}
$pageAccess = BackendUtility::readPageAccess($pageId, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (is_array($pageAccess)) {
$this->view->getDocHeaderComponent()->setPageBreadcrumb($pageAccess);
}
$schema = $this->tcaSchemaFactory->get($table);
$this->view->assignMultiple([
'recordTable' => $table,
'recordTableReadable' => $schema->getTitle($this->getLanguageService()->sL(...)),
'recordUid' => $uid,
'recordTitle' => $this->generateTitle($table, (string)$uid),
]);
}
protected function getButtons(): void
{
if ($this->returnUrl) {
$backButton = $this->componentFactory->createLinkButton()
->setHref($this->returnUrl)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL));
$this->view->addButtonToButtonBar($backButton);
}
}
protected function processSettings(ServerRequestInterface $request): array
{
// Get current selection from UC, merge data, write it back to UC
$currentSelection = $this->getBackendUser()->getModuleData('history');
if (!is_array($currentSelection)) {
$currentSelection = ['maxSteps' => '', 'showDiff' => 1, 'showSubElements' => 1];
}
$currentSelectionOverride = $request->getParsedBody()['settings'] ?? null;
if (is_array($currentSelectionOverride) && !empty($currentSelectionOverride)) {
$currentSelection = array_merge($currentSelection, $currentSelectionOverride);
$this->getBackendUser()->pushModuleData('history', $currentSelection);
}
return $currentSelection;
}
/**
* Add a translation selection dropdown if the record is language aware.
*/
protected function addLanguageSwitcher(
ServerRequestInterface $request,
BackendUserAuthentication $backendUser,
string $element,
): void {
$translations = $this->historyObject->getTranslations($element);
if ($translations === null) {
return;
}
$languageDropDownButton = $this->componentFactory->createDropDownButton()
->setLabel($this->getLanguageService()->sL('core.core:labels.language'))
->setShowLabelText(true);
try {
$site = $this->siteFinder->getSiteByPageId($translations['page']);
} catch (SiteNotFoundException) {
$site = $request->getAttribute('site');
}
$availableLanguages = $site->getAvailableLanguages($backendUser, false, $translations['page']);
foreach ($translations['elements'] as $translation) {
$siteLanguage = $availableLanguages[$translation['language']] ?? null;
if (!$siteLanguage instanceof SiteLanguage) {
continue;
}
$languageItem = $this->componentFactory->createDropDownRadio()
->setActive($translation['element'] === $element)
->setIcon($this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()))
->setHref((string)$this->uriBuilder->buildUriFromRoute('record_history', [
'element' => $translation['element'],
'returnUrl' => $this->returnUrl,
]))
->setLabel($siteLanguage->getTitle());
$languageDropDownButton->addItem($languageItem);
if ($languageItem->isActive()) {
$languageDropDownButton->setLabel($siteLanguage->getTitle());
}
}
$this->view->getDocHeaderComponent()->setLanguageSelector($languageDropDownButton);
}
/**
* Displays a diff over multiple fields including rollback links
*
* @param array $diff Difference array
*/
protected function displayMultipleDiff(array $diff): void
{
$languageService = $this->getLanguageService();
// Get all array keys needed
/** @var string[] $arrayKeys */
$arrayKeys = array_merge(array_keys($diff['newData']), array_keys($diff['insertsDeletes']), array_keys($diff['oldData']));
$arrayKeys = array_unique($arrayKeys);
if (!empty($arrayKeys)) {
$lines = [];
foreach ($arrayKeys as $key) {
$singleLine = [];
$elParts = explode(':', $key);
// Turn around diff because it should be a "rollback preview"
if ((int)($diff['insertsDeletes'][$key] ?? 0) === 1) {
// insert
$singleLine['insertDelete'] = 'delete';
} elseif ((int)($diff['insertsDeletes'][$key] ?? 0) === -1) {
$singleLine['insertDelete'] = 'insert';
}
// Build up temporary diff array
// turn around diff because it should be a "rollback preview"
if ($diff['newData'][$key] ?? false) {
$tmpArr = [
'newRecord' => $diff['oldData'][$key],
'oldRecord' => $diff['newData'][$key],
];
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($tmpArr['newRecord']);
foreach ($tmpFieldList as $fieldKey => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($elParts[0]) && ($schema = $this->tcaSchemaFactory->get($elParts[0]))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$fieldKey] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$fieldKey]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($tmpArr, $elParts[0], (int)$elParts[1], true);
}
}
$elParts = explode(':', $key);
$singleLine['revertRecordUrl'] = $this->buildUrl(['rollbackFields' => $key]);
$singleLine['title'] = $this->generateTitle($elParts[0], $elParts[1]);
$singleLine['recordTable'] = $elParts[0];
$singleLine['recordUid'] = $elParts[1];
$lines[] = $singleLine;
}
$this->view->assign('revertAllUrl', $this->buildUrl(['rollbackFields' => 'ALL']));
$this->view->assign('multipleDiff', $lines);
}
$this->view->assign('showDifferences', true);
}
/**
* Shows the full change log
*/
protected function displayHistory(array $historyEntries): void
{
if ($historyEntries === []) {
return;
}
$languageService = $this->getLanguageService();
$lines = [];
$beUserArray = BackendUtility::getUserNames('username,realName,usergroup,uid');
// Traverse changeLog array:
foreach ($historyEntries as $entry) {
// Build up single line
$singleLine = [];
// Get user names
$singleLine['backendUserUid'] = $entry['userid'];
$singleLine['backendUserName'] = $beUserArray[$entry['userid']]['username'] ?? '';
$singleLine['backendUserRealName'] = $beUserArray[$entry['userid']]['realName'] ?? '';
// Executed by switch user
if (!empty($entry['originaluserid'])) {
$singleLine['originalBackendUserUid'] = $entry['originaluserid'];
$singleLine['originalBackendUserName'] = $beUserArray[$entry['originaluserid']]['username'] ?? '';
$singleLine['originalBackendRealName'] = $beUserArray[$entry['originaluserid']]['realName'] ?? '';
}
// Is a change in a workspace?
$singleLine['isChangedInWorkspace'] = (int)$entry['workspace'] > 0;
// Diff link
$singleLine['diffUrl'] = $this->buildUrl(['historyEntry' => $entry['uid']]);
// Add time
$singleLine['day'] = BackendUtility::date($entry['tstamp']);
$singleLine['timestamp'] = DateTimeFactory::createFromTimestamp($entry['tstamp']);
$singleLine['title'] = $this->generateTitle($entry['tablename'], (string)$entry['recuid']);
$singleLine['recordTable'] = $entry['tablename'];
$singleLine['recordUid'] = $entry['recuid'];
$singleLine['elementUrl'] = $this->buildUrl(['element' => $entry['tablename'] . ':' . $entry['recuid']]);
$singleLine['actiontype'] = $entry['actiontype'];
if ((int)$entry['actiontype'] === RecordHistoryStore::ACTION_MODIFY || (int)$entry['actiontype'] === RecordHistoryStore::ACTION_PUBLISH) {
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($entry['newRecord']);
foreach ($tmpFieldList as $key => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($entry['tablename']) && ($schema = $this->tcaSchemaFactory->get($entry['tablename']))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$key] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$key]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($entry, $entry['tablename'], (int)$entry['recuid']);
}
}
// put line together
$lines[] = $singleLine;
}
$this->view->assign('history', $lines);
}
/**
* Renders HTML table-rows with the comparison information of a sys_history entry record
*
* @param array $entry sys_history entry record.
* @param string $table The table name
* @param int $rollbackUid The UID of the record
* @param bool $showRollbackLink Whether a rollback link should be shown for each changed field
* @return array array of records
*/
protected function renderDiff(array $entry, string $table, int $rollbackUid, bool $showRollbackLink = false): array
{
if (!$this->tcaSchemaFactory->has($table)) {
return [];
}
$lines = [];
if (is_array($entry['newRecord'] ?? null)) {
$fieldsToDisplay = array_keys($entry['newRecord']);
$languageService = $this->getLanguageService();
$schema = $this->tcaSchemaFactory->get($table);
foreach ($fieldsToDisplay as $fN) {
if (!$schema->hasField($fN)) {
continue;
}
$fieldInformation = $schema->getField($fN);
if (!$fieldInformation->isType(TableColumnType::PASSTHROUGH)) {
if ($fieldInformation->isType(TableColumnType::FLEX)) {
$colConfig = $fieldInformation->getConfiguration();
$old = $this->flexFormValueFormatter->format($table, $fN, ($entry['oldRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$new = $this->flexFormValueFormatter->format($table, $fN, ($entry['newRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new), DiffGranularity::CHARACTER);
} else {
$old = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['oldRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$new = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['newRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new));
}
$rollbackUrl = '';
if ($rollbackUid && $showRollbackLink) {
$rollbackUrl = $this->buildUrl(['rollbackFields' => $table . ':' . $rollbackUid . ':' . $fN]);
}
$lines[] = [
'title' => $languageService->sL($fieldInformation->getLabel()),
'rollbackUrl' => $rollbackUrl,
'result' => str_replace('\n', PHP_EOL, str_replace('\r\n', '\n', $diffResult)),
];
}
}
}
return $lines;
}
/**
* Generates the URL for a link to the current page
*/
protected function buildUrl(array $overrideParameters = []): string
{
$params = [];
// Setting default values based on GET parameters:
$elementString = $this->historyObject->getElementString();
if ($elementString !== '') {
$params['element'] = $elementString;
}
$params['historyEntry'] = $this->historyObject->getLastHistoryEntryNumber();
if (!empty($this->returnUrl)) {
$params['returnUrl'] = $this->returnUrl;
}
// Merging overriding values:
$params = array_merge($params, $overrideParameters);
// Make the link:
return (string)$this->uriBuilder->buildUriFromRoute('record_history', $params);
}
/**
* Generates the title and puts the record title behind
*/
protected function generateTitle(string $table, string $uid): string
{
if ($this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Label)) {
$record = $this->getRecord($table, (int)$uid) ?? [];
return BackendUtility::getRecordTitle($table, $record);
}
return '';
}
/**
* Gets a database record (cached).
*/
protected function getRecord(string $table, int $uid): ?array
{
if (!isset($this->recordCache[$table][$uid])) {
$this->recordCache[$table][$uid] = BackendUtility::getRecord($table, $uid, '*', '', false);
}
return $this->recordCache[$table][$uid];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Get the editlock value from page of a history element
*/
protected function getEditLockFromElement(string $tableName, int $elementUid): bool
{
// If the user is admin, then he may always edit the page.
if ($this->getBackendUser()->isAdmin()) {
return false;
}
$schema = $this->tcaSchemaFactory->get($tableName);
// Early return if $elementUid is zero
if ($elementUid === 0) {
return !$schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction();
}
$record = BackendUtility::getRecord($tableName, $elementUid, '*', '', false);
// we need the parent page record for the editlock info if element isn't a page
if ($tableName !== 'pages') {
$pageId = $record['pid'];
$record = BackendUtility::getRecord('pages', $pageId, '*', '', false);
}
return $schema->hasCapability(TcaSchemaCapability::EditLock)
&& ($record[$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false);
}
}
@@ -0,0 +1,797 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileType;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\Index\MetaDataRepository;
use TYPO3\CMS\Core\Resource\Rendering\RendererRegistry;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Schema\VisibleSchemaFieldsCollector;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Modal rendering detail about a record. Reached by "Display information" on click menu and records module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementInformationController
{
/**
* Type of element: "db", "file" or "folder"
*/
protected string $type = 'db';
protected array $row = [];
protected ?string $table = null;
protected ?File $fileObject = null;
protected ?Folder $folderObject = null;
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ResourceFactory $resourceFactory,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly VisibleSchemaFieldsCollector $visibleSchemaFieldsCollector,
private readonly SearchableSchemaFieldsCollector $searchableSchemaFieldsCollector,
private readonly MetaDataRepository $metaDataRepository,
private readonly ConnectionPool $connectionPool,
private readonly RendererRegistry $rendererRegistry,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$view = $this->moduleTemplateFactory->create($request);
$view->getDocHeaderComponent()->disable();
$queryParams = $request->getQueryParams();
$this->table = $queryParams['table'] ?? null;
$uid = $queryParams['uid'] ?? '';
$permsClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
// Determines if table/uid point to database record or file and if user has access to view information
$accessAllowed = false;
if ($this->tcaSchemaFactory->has($this->table)) {
$uid = (int)$uid;
// Check permissions and uid value:
if ($uid && $backendUser->check('tables_select', $this->table)) {
if ((string)$this->table === 'pages') {
$this->row = BackendUtility::readPageAccess($uid, $permsClause) ?: [];
$accessAllowed = $this->row !== [];
} else {
$this->row = BackendUtility::getRecordWSOL($this->table, $uid);
if ($this->row) {
if (isset($this->row['_ORIG_uid'])) {
// Make $uid the uid of the versioned record, while $this->row['uid'] is live record uid
$uid = (int)$this->row['_ORIG_uid'];
}
$pageInfo = BackendUtility::readPageAccess((int)$this->row['pid'], $permsClause) ?: [];
$accessAllowed = $pageInfo !== []
|| ((int)$this->row['pid'] === 0 && $this->tcaSchemaFactory->get($this->table)->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
}
}
} elseif ($this->table === '_FILE' || $this->table === '_FOLDER' || $this->table === 'sys_file') {
$fileOrFolderObject = $this->resourceFactory->retrieveFileOrFolderObject($uid);
if ($fileOrFolderObject instanceof Folder) {
$this->folderObject = $fileOrFolderObject;
$accessAllowed = $this->folderObject->checkActionPermission('read');
$this->type = 'folder';
} elseif ($fileOrFolderObject instanceof File) {
$this->fileObject = $fileOrFolderObject;
$accessAllowed = $this->fileObject->checkActionPermission('read');
$this->type = 'file';
$this->table = 'sys_file';
$this->row = BackendUtility::getRecordWSOL($this->table, $fileOrFolderObject->getUid());
}
}
// Rendering of the output via fluid
$view->assign('accessAllowed', $accessAllowed);
$view->assign('hookContent', '');
if (!$accessAllowed) {
return $view->renderResponse('ContentElement/ElementInformation');
}
// render type by user func
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/show_item.php']['typeRendering'] ?? [] as $className) {
$typeRenderObj = GeneralUtility::makeInstance($className);
if (method_exists($typeRenderObj, 'isValid') && method_exists($typeRenderObj, 'render')) {
if ($typeRenderObj->isValid($this->type, $this)) {
$view->assign('hookContent', $typeRenderObj->render($this->type, $this, $view));
return $view->renderResponse('ContentElement/ElementInformation');
}
}
}
$pageTitle = $this->getPageTitle();
$view->setTitle($pageTitle['table'] . ': ' . $pageTitle['title']);
$view->assignMultiple($pageTitle);
$view->assignMultiple($this->getPreview($request));
$view->assignMultiple($this->getPropertiesForTable());
$view->assignMultiple($this->getReferences($request, $uid));
$view->assign('returnUrl', GeneralUtility::sanitizeLocalUrl($request->getQueryParams()['returnUrl'] ?? '', $request));
$view->assign('maxTitleLength', $this->getBackendUser()->uc['titleLen'] ?? 20);
return $view->renderResponse('ContentElement/ElementInformation');
}
/**
* Get page title with icon, table title and record title
*/
public function getPageTitle(): array
{
$pageTitle = [
'title' => BackendUtility::getRecordTitle($this->table, $this->row),
];
if ($this->type === 'folder') {
$pageTitle['title'] = htmlspecialchars($this->folderObject->getName());
$pageTitle['table'] = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder');
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->folderObject, IconSize::SMALL)->render();
} elseif ($this->type === 'file') {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->fileObject, IconSize::SMALL)->render();
} else {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForRecord($this->table, $this->row, IconSize::SMALL);
}
return $pageTitle;
}
public function getTable(): ?string
{
return $this->table;
}
public function getRow(): array
{
return $this->row;
}
public function getFileObject(): ?File
{
return $this->fileObject;
}
public function getFolderObject(): ?Folder
{
return $this->folderObject;
}
/**
* Get preview for current record
*/
protected function getPreview(ServerRequestInterface $request): array
{
$preview = [];
// Perhaps @todo in future: Also display preview for records - without fileObject
if (!$this->fileObject) {
return $preview;
}
// check if file is marked as missing
if ($this->fileObject->isMissing()) {
$preview['missingFile'] = $this->fileObject->getName();
} else {
$fileRenderer = $this->rendererRegistry->getRenderer($this->fileObject);
$preview['url'] = $this->fileObject->getPublicUrl() ?? '';
// Add "edit metadata" button
$preview['editMetadataUrl'] = '';
if (($metaDataUid = $this->fileObject->getProperties()['metadata_uid'] ?? false)
&& $this->fileObject->isIndexed()
&& $this->fileObject->checkActionPermission('editMeta')
&& $this->getBackendUser()->check('tables_modify', 'sys_file_metadata')
) {
$urlParameters = [
'edit' => [
'sys_file_metadata' => [
$metaDataUid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$preview['editMetadataUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
}
$width = min(590, $this->fileObject->getMetaData()['width'] ?? 590) . 'm';
$height = min(400, $this->fileObject->getMetaData()['height'] ?? 400) . 'm';
// Check if there is a FileRenderer
if ($fileRenderer !== null) {
$preview['fileRenderer'] = $fileRenderer->render($this->fileObject, $width, $height);
// else check if we can create an Image preview
} elseif ($this->fileObject->isImage()) {
$preview['fileObject'] = $this->fileObject;
$preview['width'] = $width;
$preview['height'] = $height;
}
}
return $preview;
}
/**
* Get property array for html table
*/
protected function getPropertiesForTable(): array
{
$lang = $this->getLanguageService();
$propertiesForTable = [];
$propertiesForTable['extraFields'] = $this->getExtraFields();
// Traverse the list of fields to display for the record:
$fieldList = $this->getFieldList($this->table, $this->row);
$schema = $this->tcaSchemaFactory->has($this->table) ? $this->tcaSchemaFactory->get($this->table) : null;
foreach ($fieldList as $name) {
$name = trim($name);
$uid = $this->row['uid'] ?? 0;
if (!$schema?->hasField($name)) {
continue;
}
// @todo Add meaningful information for mfa field. For the time being we don't display anything at all.
if ($this->type === 'db' && $name === 'mfa' && in_array($this->table, ['be_users', 'fe_users'], true)) {
continue;
}
// not a real field -> skip
if ($this->type === 'file' && $name === 'fileinfo') {
continue;
}
// handled explicitly below with proper byte formatting -> skip
if ($this->type === 'file' && $name === 'size') {
continue;
}
// Field does not exist (e.g. having type=none) -> skip
if (!array_key_exists($name, $this->row)) {
continue;
}
$label = $lang->sL($schema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue($this->table, $name, $this->row[$name], 0, false, false, $uid, true, 0, $this->row),
'fieldLabel' => htmlspecialchars($label),
];
}
// additional information for folders and files
if ($this->folderObject instanceof Folder || $this->fileObject instanceof File) {
// storage
if ($this->folderObject instanceof Folder) {
$propertiesForTable['fields']['storage'] = [
'fieldValue' => $this->folderObject->getStorage()->getName(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.storage')),
];
}
// folder
$resourceObject = $this->fileObject ?: $this->folderObject;
$parentFolder = $resourceObject->getParentFolder();
$propertiesForTable['fields']['folder'] = [
'fieldValue' => $parentFolder->getReadablePath(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder')),
];
if ($this->fileObject instanceof File) {
// show file dimensions for images
if ($this->fileObject->isType(FileType::IMAGE)) {
$propertiesForTable['fields']['width'] = [
'fieldValue' => $this->fileObject->getProperty('width') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.width')),
];
$propertiesForTable['fields']['height'] = [
'fieldValue' => $this->fileObject->getProperty('height') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.height')),
];
}
// file size
$fileSizeInBytes = (int)$this->fileObject->getProperty('size');
$propertiesForTable['fields']['size'] = [
'fieldValue' => sprintf(
'%s (%s)',
GeneralUtility::formatSize($fileSizeInBytes, htmlspecialchars($this->getLanguageService()->sL('core.common:byteSizeUnits'))),
htmlspecialchars($lang->translate('size_in_bytes', 'core.core', ['numberOfBytes' => GeneralUtility::formatSize($fileSizeInBytes, ' ')])),
),
'fieldLabel' => $lang->sL($schema?->hasField('size') ? $schema->getField('size')->getLabel() : ''),
];
// show the metadata of a file as well
$metaData = $this->metaDataRepository->findByFileUid((int)($this->row['uid'] ?? 0));
// If there is no metadata record, skip it
if ($metaData !== []) {
$fileMetadataSchema = $this->tcaSchemaFactory->get('sys_file_metadata');
$allowedFields = $this->getFieldList('sys_file_metadata', $metaData);
foreach ($metaData as $name => $value) {
if (!in_array($name, $allowedFields, true)) {
continue;
}
if ($name === 'crdate') {
// Is of type=passthrough and already part of
// meta information displayed on top of the table
continue;
}
if (!$fileMetadataSchema->hasField($name)) {
continue;
}
$label = $lang->sL($fileMetadataSchema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue('sys_file_metadata', $name, $value, 0, false, false, (int)$metaData['uid'], true, 0, $metaData),
'fieldLabel' => htmlspecialchars($label),
];
}
}
}
}
return $propertiesForTable;
}
/**
* Get the list of fields that should be shown for the given table
*/
protected function getFieldList(string $table, array $row): array
{
$fieldNamesToExclude = [];
if ($this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) {
$fieldNamesToExclude[] = $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName();
}
if ($schema->isLanguageAware()) {
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$fieldNamesToExclude[] = $languageCapability->getTranslationOriginPointerField()->getName();
if ($languageCapability->hasDiffSourceField()) {
$fieldNamesToExclude[] = $languageCapability->getDiffSourceField()?->getName();
}
}
}
return $this->searchableSchemaFieldsCollector->getUniqueFieldList(
$table,
$this->visibleSchemaFieldsCollector->getFieldNames($table, $row, $fieldNamesToExclude),
false
);
}
/**
* Get the extra fields (uid, timestamps, creator) for the table
*/
protected function getExtraFields(): array
{
$lang = $this->getLanguageService();
$keyLabelPair = [];
if (in_array($this->type, ['folder', 'file'], true)) {
if ($this->type === 'file') {
$keyLabelPair['uid'] = [
'value' => (int)$this->row['uid'],
];
$keyLabelPair['creation_date'] = [
'value' => BackendUtility::datetime($this->row['creation_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')),
'isDatetime' => true,
];
$keyLabelPair['modification_date'] = [
'value' => BackendUtility::datetime($this->row['modification_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')),
'isDatetime' => true,
];
} else {
$keyLabelPair['uid'] = [
'value' => $this->folderObject->getCombinedIdentifier(),
];
}
} else {
$keyLabelPair['uid'] = [
'value' => BackendUtility::getProcessedValueExtra($this->table, 'uid', $this->row['uid']),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:show_item.php.uid')), ':'),
];
$schema = $this->tcaSchemaFactory->get($this->table);
if ($schema->hasCapability(TcaSchemaCapability::CreatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')), ':'),
'isDatetime' => true,
];
}
if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')), ':'),
'isDatetime' => true,
];
}
// Show the user who created the record
$recordHistory = GeneralUtility::makeInstance(RecordHistory::class);
$ownerInformation = $recordHistory->getCreationInformationForRecord($this->table, $this->row);
$ownerUid = (int)(is_array($ownerInformation) && $ownerInformation['usertype'] === 'BE' ? $ownerInformation['userid'] : 0);
if ($ownerUid) {
$creatorRecord = BackendUtility::getRecord('be_users', $ownerUid);
if ($creatorRecord) {
$keyLabelPair['creatorRecord'] = [
'value' => $creatorRecord,
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationUserId')), ':'),
];
}
}
}
return $keyLabelPair;
}
/**
* Get references section (references from and references to current record)
*/
protected function getReferences(ServerRequestInterface $request, int|string $uid): array
{
$references = [];
switch ($this->type) {
case 'db': {
$references['refLines'] = $this->makeRef($this->table, $uid, $request);
$references['refFromLines'] = $this->makeRefFrom($this->table, $uid, $request);
break;
}
case 'file': {
if ($this->fileObject && $this->fileObject->isIndexed()) {
$references['refLines'] = $this->makeRef('_FILE', $this->fileObject, $request);
}
break;
}
}
return $references;
}
/**
* Get field name for specified table/column name
*
* @param string $fieldName Column name
*/
protected function getLabelForTableColumn(TcaSchema $schema, string $fieldName): string
{
if ($schema->hasField($fieldName)) {
$field = $schema->getField($fieldName);
$field = $field->getLabel() ? $this->getLanguageService()->sL($field->getLabel()) : $fieldName;
if (trim($field) === '') {
$field = $fieldName;
}
} else {
$field = $fieldName;
}
return $field;
}
/**
* Returns the record actions
*
* @param int $uid
* @throws RouteNotFoundException
*/
protected function getRecordActions(TcaSchema $schema, $uid, ServerRequestInterface $request): array
{
if ($uid < 0) {
return [];
}
$actions = [];
// Edit button
$urlParameters = [
'edit' => [
$schema->getName() => [
$uid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordEditUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
// History button
$urlParameters = [
'element' => $schema->getName() . ':' . $uid,
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordHistoryUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_history', $urlParameters);
if ($schema->getName() === 'pages') {
// Recordlist button
$actions['recordsModuleUrl'] = (string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $uid, 'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri()]);
// retrieve record to get page language
$record = BackendUtility::getRecord($schema->getName(), $uid);
$previewUriBuilder = PreviewUriBuilder::create($record)
->withRootLine(BackendUtility::BEgetRootLine($uid));
// View page button
$actions['previewUrlAttributes'] = $previewUriBuilder->serializeDispatcherAttributes();
}
return $actions;
}
/**
* Make reference display
*
* @param string $table Table name
* @param int|File $ref Filename or uid
* @throws RouteNotFoundException
*/
protected function makeRef(string $table, $ref, ServerRequestInterface $request): array
{
$refLines = [];
$lang = $this->getLanguageService();
// Files reside in sys_file table
if ($table === '_FILE') {
$selectTable = 'sys_file';
$selectUid = $ref->getUid();
} else {
$selectTable = $table;
$selectUid = $ref;
}
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'ref_table',
$queryBuilder->createNamedParameter($selectTable)
),
$queryBuilder->expr()->eq(
'ref_uid',
$queryBuilder->createNamedParameter($selectUid, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'tablename',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
if ($row['tablename'] === 'sys_file_reference') {
$row = $this->transformFileReferenceToRecordReference($row);
if ($row === null) {
continue;
}
}
if (!$this->tcaSchemaFactory->has($row['tablename'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['tablename']);
$line = [];
$record = BackendUtility::getRecordWSOL($row['tablename'], $row['recuid']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$parentRecord = BackendUtility::getRecord('pages', $record['pid']);
$parentRecordTitle = is_array($parentRecord)
? BackendUtility::getRecordTitle('pages', $parentRecord)
: '';
$urlParameters = [
'edit' => [
$row['tablename'] => [
$row['recuid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['tablename'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['tablename'], $record);
$line['parentRecord'] = $parentRecord;
$line['parentRecordTitle'] = $parentRecordTitle;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0, 0);
$line['actions'] = $this->getRecordActions($schema, $row['recuid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refLines[] = $line;
}
return $refLines;
}
/**
* Make reference display (what this elements points to)
*
* @param string $table Table name
* @param int $ref Filename or uid
*/
protected function makeRefFrom($table, $ref, ServerRequestInterface $request): array
{
$refFromLines = [];
$lang = $this->getLanguageService();
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'tablename',
$queryBuilder->createNamedParameter($table)
),
$queryBuilder->expr()->eq(
'recuid',
$queryBuilder->createNamedParameter($ref, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'ref_table',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
$line = [];
$record = BackendUtility::getRecordWSOL($row['ref_table'], $row['ref_uid']);
if (!$this->tcaSchemaFactory->has($row['ref_table'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['ref_table']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$urlParameters = [
'edit' => [
$row['ref_table'] => [
$row['ref_uid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['ref_table'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['ref_table'], $record);
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0);
$line['actions'] = $this->getRecordActions($schema, $row['ref_uid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refFromLines[] = $line;
}
return $refFromLines;
}
/**
* Convert FAL file reference (sys_file_reference) to reference index (sys_refindex) table format
*/
protected function transformFileReferenceToRecordReference(array $referenceRecord): ?array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_file_reference');
$queryBuilder->getRestrictions()->removeAll();
$fileReference = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($referenceRecord['recuid'], Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAssociative();
return $fileReference ? [
'recuid' => $fileReference['uid_foreign'],
'tablename' => $fileReference['tablenames'],
'field' => $fileReference['fieldname'],
'flexpointer' => '',
'softref_key' => '',
'sorting' => $fileReference['sorting_foreign'],
] : null;
}
/**
* @param array $record Record to be checked (ensure pid is resolved for workspaces)
*/
protected function canAccessPage(TcaSchema $schema, array $record): bool
{
$recordPid = (int)($schema->getName() === 'pages' ? $record['uid'] : $record['pid']);
$isInWebMount = (bool)$this->getBackendUser()->isInWebMount($schema->getName() === 'pages' ? $record : $record['pid']);
return $isInWebMount || ($recordPid === 0 && $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Tree\View\ContentMovingPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The "move tt_content element" wizard. Reachable via records module "Re-position content element" on tt_content records.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class MoveElementController
{
use PageRendererBackendSetupTrait;
public function __construct(
private PageRenderer $pageRenderer,
private BackendViewFactory $backendViewFactory,
private LanguageServiceFactory $languageServiceFactory,
private ExtensionConfiguration $extensionConfiguration
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend(
$this->pageRenderer,
$this->extensionConfiguration,
$request,
$this->languageServiceFactory->createFromUserPreferences($this->getBackendUser())
);
$view = $this->backendViewFactory->create($request);
$queryParams = $request->getQueryParams();
$contentOnly = $queryParams['contentOnly'] ?? false;
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/global-event-handler.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/tree/page-browser.js');
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/wizard/move-content-element.js', 'MoveContentElement')->instance()
);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/Wizards/move_content_elements.xlf');
$view->assignMultiple(array_merge($this->getContentVariables($request), [
'contentOnly' => $contentOnly,
]));
$content = $view->render('ContentElement/MoveElement');
if ($contentOnly) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body>' . $content);
return new HtmlResponse($this->pageRenderer->render($request));
}
private function getContentVariables(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$contentElementUid = (int)($parsedBody['uid'] ?? $queryParams['uid'] ?? 0);
$pageId = (int)($parsedBody['expandPage'] ?? $queryParams['expandPage'] ?? 0);
$sysLanguage = (int)($parsedBody['sys_language'] ?? $queryParams['sys_language'] ?? 0);
$makeCopy = (bool)($parsedBody['makeCopy'] ?? $queryParams['makeCopy'] ?? 0);
$permsClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
if (!$contentElementUid) {
return [];
}
$contentElement = BackendUtility::getRecordWSOL('tt_content', $contentElementUid);
$pageInfo = BackendUtility::readPageAccess($pageId, $permsClause);
$contentElementTitle = BackendUtility::getRecordTitle('tt_content', $contentElement);
$assigns = [
'record' => $contentElement,
'makeCopyChecked' => $makeCopy,
'pageInfo' => $pageInfo,
'recordTitle' => BackendUtility::cropToTitleLength($contentElementTitle),
];
if (is_array($pageInfo) && $this->getBackendUser()->isInWebMount($pageInfo['uid'], $permsClause)) {
// Initialize the content position map:
$contentPositionMap = GeneralUtility::makeInstance(ContentMovingPagePositionMap::class);
$contentPositionMap->copyMode = $makeCopy ? 'copy' : 'move';
$contentPositionMap->moveUid = $contentElementUid;
$contentPositionMap->cur_sys_language = $sysLanguage;
$pageTitle = BackendUtility::getRecordTitle('pages', $pageInfo);
$assigns['pageRecord']['recordTooltip'] = BackendUtility::getRecordIconAltText($pageInfo, 'pages', false);
$assigns['pageRecord']['recordTitle'] = BackendUtility::cropToTitleLength($pageTitle);
$assigns['contentElementColumns'] = $contentPositionMap->printContentElementColumns($pageId, $pageInfo, $request);
}
return $assigns;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,710 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Tree\View\ContentCreationPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Struct\SelectItem;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* New Content element wizard. This is the modal that pops up when clicking "+content" in page module, which
* will trigger wizardAction() since there is a colPos given. Method positionMapAction() is triggered for
* instance from the records module "+content" on tt_content table header, and from records module doc-header "+"
* and then "Click here for wizard".
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class NewContentElementController
{
protected int $id = 0;
protected int $uid_pid = 0;
protected array $pageInfo = [];
protected int $sys_language = 0;
protected string $returnUrl = '';
/**
* If set, the content is destined for a specific column.
*/
protected ?int $colPos = null;
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly DependencyOrderingService $dependencyOrderingService,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly BackendLayoutView $backendLayoutView,
) {}
/**
* Process incoming request and dispatch to the requested action
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// Setting internal vars:
$this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$this->sys_language = (int)($parsedBody['language_tag'] ?? $queryParams['language_tag'] ?? 0);
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null;
$this->colPos = $colPos === null ? null : (int)$colPos;
$this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0);
// Getting the current page and receiving access information
$this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$action = (string)($parsedBody['action'] ?? $queryParams['action'] ?? 'wizard');
if ($action === 'wizard') {
return $this->wizardAction($request);
}
if ($action === 'positionMap') {
return $this->positionMapAction($request);
}
return new HtmlResponse('Action not allowed', 400);
}
/**
* Renders the wizard
*/
protected function wizardAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->id || $this->pageInfo === []) {
// No pageId or no access.
return new HtmlResponse('No Access');
}
// Whether position selection must be performed (no colPos was yet defined)
$positionSelection = $this->colPos === null;
// Get processed and modified wizard items
$wizardItems = $this->eventDispatcher->dispatch(
new ModifyNewContentElementWizardItemsEvent(
$this->getWizards($request),
$this->pageInfo,
$this->colPos,
$this->sys_language,
$this->uid_pid,
$request,
)
)->getWizardItems();
$key = 'common';
$categories = [];
foreach ($wizardItems as $wizardKey => $wizardItem) {
// An item is either a header or an item rendered with title/description and icon:
if (isset($wizardItem['header'])) {
$key = $wizardKey;
$categories[$key] = [
'identifier' => $key,
'label' => $wizardItem['header'] ?: '-',
'items' => [],
];
} else {
// Get default values for the wizard item
$defaultValues = (array)($wizardItem['defaultValues'] ?? []);
// Initialize the view variables for the item
$item = [
'identifier' => $wizardKey,
'icon' => $wizardItem['iconIdentifier'] ?? '',
'iconOverlay' => $wizardItem['iconOverlay'] ?? '',
'label' => $wizardItem['title'] ?? '',
'description' => $wizardItem['description'] ?? '',
'defaultValues' => $defaultValues,
];
// If the URL was already created (e.g. via the PSR-14 event) this needs to be
// kept and not overwritten
if (isset($wizardItem['url'])) {
$item['url'] = $wizardItem['url'];
if ($positionSelection) {
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
}
} elseif ($positionSelection) {
$item['url'] = (string)$this->uriBuilder
->buildUriFromRoute(
'new_content_element_wizard',
[
'action' => 'positionMap',
'id' => $this->id,
'sys_language_uid' => $this->sys_language,
'returnUrl' => $this->returnUrl,
]
);
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
} else {
// In case no position has to be selected, we can just add the target
if ($wizardItem['saveAndClose'] ?? false) {
// Go to DataHandler directly instead of FormEngine
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [
'data' => [
'tt_content' => [
StringUtility::getUniqueId('NEW') => array_replace($defaultValues, [
'colPos' => $this->colPos,
'pid' => $this->uid_pid,
'sys_language_uid' => $this->sys_language,
]),
],
],
'redirect' => $this->returnUrl,
]);
} else {
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'tt_content' => [
$this->uid_pid => 'new',
],
],
'module' => '_CURRENT_MODULE_',
'returnUrl' => $this->returnUrl,
'defVals' => [
'tt_content' => array_replace($defaultValues, [
'colPos' => $this->colPos,
'sys_language_uid' => $this->sys_language,
]),
],
]);
}
}
$categories[$key]['items'][] = $item;
}
}
// Unset empty categories
foreach ($categories as $key => $category) {
if ($category['items'] === []) {
unset($categories[$key]);
}
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'positionSelection' => $positionSelection,
'categoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($categories, false),
]);
return new HtmlResponse($view->render('NewContentElement/Wizard'));
}
/**
* Renders the position map
*/
protected function positionMapAction(ServerRequestInterface $request): ResponseInterface
{
$pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
$posMap = GeneralUtility::makeInstance(ContentCreationPagePositionMap::class);
$posMap->cur_sys_language = $this->sys_language;
$posMap->defVals = (array)($request->getParsedBody()['defVals'] ?? []);
$posMap->saveAndClose = (bool)($request->getParsedBody()['saveAndClose'] ?? false);
$posMap->R_URI = $this->returnUrl;
$view = $this->backendViewFactory->create($request);
$view->assign('posMap', $posMap->printContentElementColumns($this->id, $pageInfo, $request));
return new HtmlResponse($view->render('NewContentElement/PositionMap'));
}
/**
* Returns the array of elements in the wizard display.
* For the plugin section there is support for adding elements there from a global variable.
*/
protected function getWizards(ServerRequestInterface $request): array
{
$wizards = $this->loadAvailableWizards();
$newContentElementWizardTsConfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['wizards.']['newContentElement.'] ?? [];
$wizardsFromPageTSConfig = $this->migrateCommonGroupToDefault($newContentElementWizardTsConfig['wizardItems.'] ?? []);
$wizardsFromPageTSConfig = $this->migratePositionalCommonGroupToDefault($wizardsFromPageTSConfig);
$wizards = $this->mergeContentElementWizardsWithPageTSConfigWizards($wizards, $wizardsFromPageTSConfig);
$wizards = $this->removeWizardsByPageTs($wizards, $newContentElementWizardTsConfig);
$wizards = $this->removeWizardsByBackendLayoutColPosRestriction($wizards, $this->pageInfo, $this->colPos, $request);
if ($wizards === []) {
return [];
}
$wizardItems = [];
foreach ($wizards as $groupKey => $wizardGroup) {
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'before');
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'after');
}
$orderedWizards = $this->orderWizards($wizards);
foreach ($orderedWizards as $groupKey => $wizardGroup) {
$groupKey = rtrim($groupKey, '.');
$groupItems = [];
$wizardElements = $wizardGroup['elements.'] ?? [];
if (is_array($wizardElements)) {
$wizardElements = $this->orderElements($wizardElements);
foreach ($wizardElements as $itemKey => $itemConf) {
$itemKey = rtrim($itemKey, '.');
if ($itemConf !== []) {
$groupItems[$groupKey . '_' . $itemKey] = $this->prepareWizardItem($itemConf);
}
}
}
if (!empty($groupItems)) {
$wizardItems[$groupKey]['header'] = $this->getLanguageService()->sL($wizardGroup['header'] ?? '');
$wizardItems = array_merge($wizardItems, $groupItems);
}
}
// Remove elements where preset values are not allowed:
return $this->removeInvalidWizardItems($wizardItems);
}
protected function loadAvailableWizards(): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$items = $fieldConfig['items'] ?? [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
$groupedWizardItems = [];
foreach (array_keys($itemGroups) as $groupIdentifier) {
$groupedWizardItems[$groupIdentifier . '.']['header'] = $itemGroups[$groupIdentifier];
}
foreach ($items as $item) {
$selectItem = SelectItem::fromTcaItemArray($item);
if ($selectItem->isDivider()) {
continue;
}
$recordType = $selectItem->getValue();
$groupIdentifier = $selectItem->getGroup();
$groupedWizardItems[$groupIdentifier . '.']['elements.'] ??= [];
// In case this group is not defined in itemGroups, use the group identifier as label.
$groupedWizardItems[$groupIdentifier . '.']['header'] ??= $groupIdentifier;
$itemDescription = $selectItem->getDescription();
$wizardEntry = [
'iconIdentifier' => $selectItem->getIcon(),
'iconOverlay' => $selectItem->getIconOverlay(),
'title' => $selectItem->getLabel(),
'description' => $itemDescription['description'] ?? ($itemDescription ?? ''),
'defaultValues' => [
'CType' => $recordType,
],
];
if ($schema->hasSubSchema($recordType)) {
$wizardEntry = array_replace_recursive($wizardEntry, $schema->getSubSchema($recordType)->getRawConfiguration()['creationOptions'] ?? []);
}
$groupedWizardItems[$groupIdentifier . '.']['elements.'][$recordType . '.'] = $wizardEntry;
}
return $groupedWizardItems;
}
/**
* This method merges Content Element wizards defined by TCA with wizards defined in PageTSConfig.
* PageTS has precedence.
* It might happen that both TCA and PageTS define an entry with exactly the same default values.
* In such a case, the automatically added TCA entry is dropped.
*/
protected function mergeContentElementWizardsWithPageTSConfigWizards(array $contentElementWizards, array $pageTsConfigWizards): array
{
$uniqueDefaultValuesInPageTsWizards = [];
foreach ($pageTsConfigWizards as $wizard) {
foreach ($wizard['elements.'] ?? [] as $elementConfig) {
$defaultValues = $elementConfig['tt_content_defValues.'] ?? [];
if ($defaultValues === []) {
continue;
}
ksort($defaultValues);
$uniqueDefaultValuesInPageTsWizards[] = $defaultValues;
}
}
foreach ($contentElementWizards as $group => $wizard) {
foreach ($wizard['elements.'] ?? [] as $key => $elementConfig) {
// Remove duplicated entry.
$defaultValues = $elementConfig['defaultValues'];
ksort($defaultValues);
if (in_array($defaultValues, $uniqueDefaultValuesInPageTsWizards, true)) {
unset($contentElementWizards[$group]['elements.'][$key]);
}
}
}
$mergedWizards = array_replace_recursive($contentElementWizards, $pageTsConfigWizards);
return $mergedWizards;
}
/**
* Orders elements within a wizard group using before/after configuration.
* Similar to orderWizards() but for individual content elements.
*/
protected function orderElements(array $elements): array
{
// Check if any element has before/after configuration
// and return early if no reordering is required.
if (!$this->hasPositionalArguments($elements)) {
return $elements;
}
// Prepare elements for dependency ordering.
// Create implicit chain based on initial order for consecutive elements
// without explicit dependencies, preserving relative order while allowing
// explicit positioning.
$preparedElements = [];
// First pass: prepare all elements with their explicit dependencies
foreach ($elements as $elementKey => $element) {
$preparedElement = $element;
// Prepare before/after values (they might be comma-separated strings)
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'before');
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'after');
$preparedElements[$elementKey] = $preparedElement;
}
// Second pass: add implicit chain for consecutive elements without explicit dependencies
// This preserves relative order within blocks, while explicit dependencies can reorder them
$previousIndependentElementKey = null;
foreach ($elements as $elementKey => $element) {
$isIndependent = empty($element['before']) && empty($element['after']);
if ($isIndependent) {
// Element without explicit dependency: chain with previous independent element
if ($previousIndependentElementKey !== null) {
$existingAfter = $preparedElements[$elementKey]['after'] ?? [];
if (!in_array($previousIndependentElementKey, $existingAfter, true)) {
$preparedElements[$elementKey]['after'] = array_merge($existingAfter, [$previousIndependentElementKey]);
}
}
$previousIndependentElementKey = $elementKey;
}
}
// Use dependency ordering service to order elements
return $this->dependencyOrderingService->orderByDependencies($preparedElements);
}
protected function hasPositionalArguments(array $elements): bool
{
foreach ($elements as $element) {
if (!empty($element['before']) || !empty($element['after'])) {
return true;
}
}
return false;
}
/**
* There are two separate ordering systems for wizard groups:
* 1. TCA itemGroup sorting by associative array item order.
* 2. PageTS defined order by "before" and "after".
*
* System 1. has a well-defined order, where every item defines "after" (linked list).
* Due to this, the two system cannot be combined.
* As soon as system 2 defines at least one "before" or "after" it takes over.
*/
protected function orderWizards(array $wizards): array
{
// First round: Order by TCA defined sorting.
$hasAtLeastOnePositionalArgument = false;
foreach ($wizards as $group => $wizard) {
if (isset($wizard['before'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsBefore'] = $wizard['before'];
unset($wizards[$group]['before']);
}
if (isset($wizard['after'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsAfter'] = $wizard['after'];
unset($wizards[$group]['after']);
}
}
// No order defined by pageTS. Use TCA sorting.
if (!$hasAtLeastOnePositionalArgument) {
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
// Auto-set positional information based on TCA itemGroups sorting.
$lastGroup = null;
foreach (array_keys($itemGroups) as $groupIdentifier) {
if (!array_key_exists($groupIdentifier . '.', $wizards)) {
continue;
}
if ($lastGroup !== null) {
$wizards[$groupIdentifier . '.']['after'] = [$lastGroup . '.'];
}
$lastGroup = $groupIdentifier;
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
// Override order by pageTsConfig.
foreach ($wizards as $group => $wizard) {
// Unset "after" previously set by Content Element wizards.
unset($wizards[$group]['after']);
if (isset($wizard['pageTsBefore'])) {
$wizards[$group]['before'] = $wizard['pageTsBefore'];
unset($wizards[$group]['pageTsBefore']);
}
if (isset($wizard['pageTsAfter'])) {
$wizards[$group]['after'] = $wizard['pageTsAfter'];
unset($wizards[$group]['pageTsAfter']);
}
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
/**
* This method returns the wizard items, defined in Page TSconfig for b/w
* compatibility.
*
* Additionally, it migrates previously defined wizard items in the
* `common` group to the new `default` group, which is defined in TCA.
*
* @param array<string, array> $wizardsFromPageTs
* @return array<string, array>
*/
protected function migrateCommonGroupToDefault(array $wizardsFromPageTs): array
{
if (!array_key_exists('common.', $wizardsFromPageTs)) {
// In case "common." is not defined, just return the wizards, which are still defined via Page TSconfig
return $wizardsFromPageTs;
}
// Prepare "removeItems" to be merged
if ($wizardsFromPageTs['default.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['default.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['removeItems'], true);
}
if ($wizardsFromPageTs['common.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['common.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['removeItems'], true);
}
$defaultItems = array_merge_recursive($wizardsFromPageTs['default.'] ?? [], $wizardsFromPageTs['common.']);
unset($wizardsFromPageTs['common.']);
if ($defaultItems !== []) {
$wizardsFromPageTs['default.'] = $defaultItems;
}
return $wizardsFromPageTs;
}
protected function migratePositionalCommonGroupToDefault(array $wizards): array
{
foreach ($wizards as $group => $wizard) {
if (($wizard['before'] ?? '') === 'common') {
$wizards[$group]['before'] = 'default';
}
if (($wizard['after'] ?? '') === 'common') {
$wizards[$group]['after'] = 'default';
}
}
return $wizards;
}
protected function prepareWizardItem(array $itemConf): array
{
// Just replace the "known" keys of $itemConf. This way extensions are able to set custom keys, which are not
// used by the controller, but might be evaluated by listeners of the ModifyNewContentElementWizardItemsEvent.
$itemConf = array_replace_recursive(
$itemConf,
[
'title' => trim($this->getLanguageService()->sL($itemConf['title'] ?? '')),
'description' => trim($this->getLanguageService()->sL($itemConf['description'] ?? '')),
'iconIdentifier' => $itemConf['iconIdentifier'] ?? null,
'saveAndClose' => (bool)($itemConf['saveAndClose'] ?? false),
'defaultValues' => array_replace_recursive(
$itemConf['tt_content_defValues'] ?? [],
$itemConf['tt_content_defValues.'] ?? [],
$itemConf['defaultValues'] ?? []
),
]
);
unset($itemConf['tt_content_defValues'], $itemConf['tt_content_defValues.']);
return $itemConf;
}
protected function removeWizardsByPageTs(array $wizards, mixed $wizardsItemsPageTs): array
{
$removeWizardItems = $wizardsItemsPageTs['wizardItems.']['removeItems'] ?? [];
if (is_string($removeWizardItems)) {
$removeWizardItems = GeneralUtility::trimExplode(',', $removeWizardItems, true);
}
foreach ($wizards as $key => &$wizard) {
// Leave out removeItems etc.
if (is_string($wizard)) {
unset($wizards[$key]);
continue;
}
if (in_array(rtrim((string)$key, '.'), $removeWizardItems, true)) {
unset($wizards[$key]);
continue;
}
$removeWizardElements = $wizardsItemsPageTs['wizardItems.'][$key]['removeItems'] ?? [];
if (is_string($removeWizardElements)) {
$removeWizardElements = GeneralUtility::trimExplode(',', $removeWizardElements, true);
}
foreach ($wizard['elements.'] ?? [] as $identifier => $element) {
if (in_array(rtrim((string)$identifier, '.'), $removeWizardElements, true)) {
unset($wizard['elements.'][$identifier]);
}
}
}
return $wizards;
}
protected function removeWizardsByBackendLayoutColPosRestriction(array $wizardGroups, array $pageInfo, ?int $colPos, ServerRequestInterface $request): array
{
// Force colPos to 0 if null to apply restrictions for 0 by default.
$colPos = (int)$colPos;
// This is the page uid of a workspace overlay already so backend layouts of workspace
// changed or moved pages should be considered correctly.
$pid = (int)$pageInfo['uid'];
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pid);
$columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pid, $request);
if (!empty($columnConfiguration['allowedContentTypes'])) {
$allowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (!in_array(trim($cType), $allowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
if (!empty($columnConfiguration['disallowedContentTypes'])) {
$disAllowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (in_array(trim($cType), $disAllowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
return $wizardGroups;
}
/**
* Checks the array for elements which might contain invalid default values and will unset them!
* Looks for the "defaultValues" key in each element and if found it will traverse that array
* as fieldname / value pairs and check.
*/
protected function removeInvalidWizardItems(array $wizardItems): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
$removeItems = [];
$keepItems = [];
// Get TCEFORM from TSconfig of current page
$TCEFORM_TSconfig = FormEngineUtility::getTCEFORM_TSconfig('tt_content', ['pid' => $this->id]);
$backendUser = $this->getBackendUser();
// Traverse wizard items:
foreach ($wizardItems as $key => $cfg) {
if (!is_array($cfg['defaultValues'] ?? false)) {
continue;
}
// This is not a group; this is likely broken configuration
if ($cfg['defaultValues'] === []) {
unset($wizardItems[$key]);
}
// If defaultValues are defined, check access by traversing all fields with default values:
foreach ($cfg['defaultValues'] as $fieldName => $value) {
if (!$schema->hasField($fieldName)) {
continue;
}
// Get information about if the field value is OK:
$config = $schema->getField($fieldName)->getConfiguration();
$userNotAllowedToAccess = ($config['type'] ?? '') === 'select' && ($config['authMode'] ?? false)
&& !$backendUser->checkAuthMode('tt_content', $fieldName, $value);
// Check removeItems
if (!isset($removeItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['removeItems'] ?? false)) {
$removeItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['removeItems'],
true
));
}
// Check keepItems
if (!isset($keepItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['keepItems'] ?? false)) {
$keepItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['keepItems'],
true
));
}
$isNotInKeepItems = !empty($keepItems[$fieldName]) && !isset($keepItems[$fieldName][$value]);
if ($userNotAllowedToAccess || ($fieldName === 'CType' && (isset($removeItems[$fieldName][$value]) || $isNotInKeepItems))) {
// Remove element all together:
unset($wizardItems[$key]);
break;
}
// Add the parameter:
$wizardItems[$key]['defaultValues'][$fieldName] = $this->getLanguageService()->sL($value);
}
}
return $wizardItems;
}
/**
* Prepare a wizard tab configuration for sorting.
*/
protected function prepareDependencyOrdering(array $wizardGroup, string $key): array
{
if (is_string($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = GeneralUtility::trimExplode(',', $wizardGroup[$key], true);
}
if (is_array($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = array_map(
static fn(string $s): string => rtrim($s, '.') . '.',
$wizardGroup[$key]
);
}
return $wizardGroup;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}