449 lines
22 KiB
PHP
449 lines
22 KiB
PHP
<?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\Form\Container;
|
|
|
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
|
use TYPO3\CMS\Backend\Form\Event\CustomFileControlsEvent;
|
|
use TYPO3\CMS\Backend\Form\Event\CustomFileSelectorsEvent;
|
|
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
|
|
use TYPO3\CMS\Core\Crypto\HashService;
|
|
use TYPO3\CMS\Core\Imaging\IconFactory;
|
|
use TYPO3\CMS\Core\Imaging\IconSize;
|
|
use TYPO3\CMS\Core\Localization\LanguageService;
|
|
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
|
|
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
|
|
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
|
|
use TYPO3\CMS\Core\Resource\Folder;
|
|
use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry;
|
|
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Core\Utility\MathUtility;
|
|
use TYPO3\CMS\Core\Utility\StringUtility;
|
|
|
|
/**
|
|
* Files entry container.
|
|
*
|
|
* This container is the entry step to rendering a file reference. It is created by SingleFieldContainer.
|
|
*
|
|
* The code creates the main structure for the single file reference, initializes the inlineData array,
|
|
* that is manipulated and also returned in its manipulated state. The "control" stuff of file
|
|
* references is rendered here, for example the "create new" button.
|
|
*
|
|
* For each existing file reference, a FileReferenceContainer is called for further processing.
|
|
*/
|
|
class FilesControlContainer extends AbstractContainer
|
|
{
|
|
private const string FILE_REFERENCE_TABLE = 'sys_file_reference';
|
|
|
|
/**
|
|
* Inline data array used in JS, returned as JSON object to frontend
|
|
*/
|
|
protected array $fileReferenceData = [];
|
|
|
|
/**
|
|
* @var array<int,JavaScriptModuleInstruction|string|array<string,string>>
|
|
*/
|
|
protected array $javaScriptModules = [];
|
|
|
|
protected $defaultFieldWizard = [
|
|
'localizationStateSelector' => [
|
|
'renderType' => 'localizationStateSelector',
|
|
],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly IconFactory $iconFactory,
|
|
private readonly InlineStackProcessor $inlineStackProcessor,
|
|
private readonly EventDispatcherInterface $eventDispatcher,
|
|
private readonly OnlineMediaHelperRegistry $onlineMediaHelperRegistry,
|
|
private readonly DefaultUploadFolderResolver $defaultUploadFolderResolver,
|
|
private readonly HashService $hashService,
|
|
) {}
|
|
|
|
/**
|
|
* Entry method
|
|
*
|
|
* @return array As defined in initializeResultArray() of AbstractNode
|
|
*/
|
|
public function render(): array
|
|
{
|
|
$languageService = $this->getLanguageService();
|
|
|
|
$this->fileReferenceData = $this->data['inlineData'];
|
|
|
|
$inlineStructure = $this->data['inlineStructure'];
|
|
|
|
$table = $this->data['tableName'];
|
|
$row = $this->data['databaseRow'];
|
|
$field = $this->data['fieldName'];
|
|
$parameterArray = $this->data['parameterArray'];
|
|
|
|
$resultArray = $this->initializeResultArray();
|
|
|
|
$config = $parameterArray['fieldConf']['config'];
|
|
$isReadOnly = (bool)($config['readOnly'] ?? false);
|
|
$language = 0;
|
|
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
|
|
$languageFieldName = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
|
|
$language = isset($row[$languageFieldName][0]) ? (int)$row[$languageFieldName][0] : (int)$row[$languageFieldName];
|
|
}
|
|
|
|
// Add the current inline job to the structure stack
|
|
$newStructureItem = [
|
|
'table' => $table,
|
|
'uid' => $row['uid'],
|
|
'field' => $field,
|
|
'config' => $config,
|
|
];
|
|
|
|
// Extract FlexForm parts (if any) from element name, e.g. array('vDEF', 'lDEF', 'FlexField', 'vDEF')
|
|
$itemName = (string)$parameterArray['itemFormElName'];
|
|
if ($itemName !== '') {
|
|
$flexFormParts = $this->extractFlexFormParts($itemName);
|
|
if ($flexFormParts !== null) {
|
|
$newStructureItem['flexform'] = $flexFormParts;
|
|
if ($flexFormParts !== []
|
|
&& isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'])
|
|
) {
|
|
// Transport the flexform DS identifier fields to the FormFilesAjaxController
|
|
$config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'];
|
|
}
|
|
}
|
|
}
|
|
|
|
$inlineStructure['stable'][] = $newStructureItem;
|
|
|
|
// Hand over original returnUrl to FormFilesAjaxController. Needed if opening for instance a
|
|
// nested element in a new view to then go back to the original returnUrl and not the url of
|
|
// the inline ajax controller
|
|
$config['originalReturnUrl'] = $this->data['returnUrl'];
|
|
|
|
// e.g. data[<table>][<uid>][<field>]
|
|
$formFieldName = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure);
|
|
// e.g. data-<pid>-<table1>-<uid1>-<field1>-<table2>-<uid2>-<field2>
|
|
$formFieldIdentifier = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
|
|
|
|
$inlineChildren = $parameterArray['fieldConf']['children'] ?? [];
|
|
|
|
$config['inline']['first'] = $config['inline']['last'] = false;
|
|
if (is_array($inlineChildren) && $inlineChildren !== []) {
|
|
$firstChild = array_first($inlineChildren);
|
|
if (isset($firstChild['databaseRow']['uid'])) {
|
|
$config['inline']['first'] = $firstChild['databaseRow']['uid'];
|
|
}
|
|
$lastChild = array_last($inlineChildren);
|
|
if (isset($lastChild['databaseRow']['uid'])) {
|
|
$config['inline']['last'] = $lastChild['databaseRow']['uid'];
|
|
}
|
|
}
|
|
|
|
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
|
|
|
|
$this->fileReferenceData['config'][$formFieldIdentifier] = [
|
|
'table' => self::FILE_REFERENCE_TABLE,
|
|
];
|
|
$configJson = (string)json_encode($config);
|
|
$this->fileReferenceData['config'][$formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE] = [
|
|
'min' => $config['minitems'] ?? null,
|
|
'max' => $config['maxitems'] ?? null,
|
|
'sortable' => $config['appearance']['useSortable'] ?? false,
|
|
'top' => [
|
|
'table' => $top['table'],
|
|
'uid' => $top['uid'],
|
|
],
|
|
'context' => [
|
|
'config' => $configJson,
|
|
'hmac' => $this->hashService->hmac($configJson, 'FilesContext'),
|
|
],
|
|
];
|
|
$this->fileReferenceData['nested'][$formFieldIdentifier] = $this->data['tabAndInlineStack'];
|
|
|
|
$resultArray['inlineData'] = $this->fileReferenceData;
|
|
|
|
// @todo: It might be a good idea to have something like "isLocalizedRecord" or similar set by a data provider
|
|
$uidOfDefaultRecord = 0;
|
|
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
|
|
$originPointerField = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
|
|
$uidOfDefaultRecord = $row[$originPointerField] ?? 0;
|
|
}
|
|
$isLocalizedParent = $language > 0
|
|
&& ($uidOfDefaultRecord[0] ?? $uidOfDefaultRecord) > 0
|
|
&& MathUtility::canBeInterpretedAsInteger($row['uid']);
|
|
$numberOfFullLocalizedChildren = 0;
|
|
$numberOfNotYetLocalizedChildren = 0;
|
|
foreach ($inlineChildren as $child) {
|
|
if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
|
|
$numberOfFullLocalizedChildren++;
|
|
}
|
|
if ($isLocalizedParent && $child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
|
|
$numberOfNotYetLocalizedChildren++;
|
|
}
|
|
}
|
|
|
|
if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0)) {
|
|
$config['inline']['showNewFileReferenceButton'] = false;
|
|
$config['inline']['showCreateNewRelationButton'] = false;
|
|
$config['inline']['showOnlineMediaAddButtonStyle'] = false;
|
|
}
|
|
|
|
$fieldInformationResult = $this->renderFieldInformation();
|
|
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
|
|
|
|
$fieldWizardResult = $this->renderFieldWizard();
|
|
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
|
|
|
|
$sortableRecordUids = $fileReferencesHtml = [];
|
|
foreach ($inlineChildren as $options) {
|
|
$options['inlineParentUid'] = $row['uid'];
|
|
$options['inlineFirstPid'] = $this->data['inlineFirstPid'];
|
|
$options['inlineParentConfig'] = $config;
|
|
$options['inlineData'] = $this->fileReferenceData;
|
|
$options['inlineStructure'] = $inlineStructure;
|
|
$options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
|
|
$options['renderType'] = 'fileReferenceContainer';
|
|
$fileReference = $this->nodeFactory->create($options)->render();
|
|
$fileReferencesHtml[] = $fileReference['html'];
|
|
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fileReference, false);
|
|
if (!$options['isInlineDefaultLanguageRecordInLocalizedParentContext'] && isset($options['databaseRow']['uid'])) {
|
|
// Don't add record to list of "valid" uids if it is only the default
|
|
// language record of a not yet localized child
|
|
$sortableRecordUids[] = $options['databaseRow']['uid'];
|
|
}
|
|
}
|
|
|
|
$view = $this->backendViewFactory->create($this->data['request']);
|
|
$view->assignMultiple([
|
|
'formFieldIdentifier' => $formFieldIdentifier,
|
|
'formFieldName' => $formFieldName,
|
|
'webComponentAttributes' => GeneralUtility::implodeAttributes([
|
|
'id' => $formFieldIdentifier,
|
|
'data-type' => 'file',
|
|
'data-object-group' => $formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE,
|
|
'data-form-field' => $formFieldName,
|
|
'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false',
|
|
'data-sortable' => (bool)($config['appearance']['useSortable'] ?? false) ? 'true' : 'false',
|
|
'data-min' => (int)($config['minitems'] ?? 0),
|
|
'data-max' => (int)($config['maxitems'] ?? 0),
|
|
], true),
|
|
'fieldInformation' => $fieldInformationResult['html'],
|
|
'fieldWizard' => $fieldWizardResult['html'],
|
|
'fileReferences' => [
|
|
'id' => $formFieldIdentifier . '_records',
|
|
'title' => $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? '')),
|
|
'records' => implode(LF, $fileReferencesHtml),
|
|
],
|
|
'sortableRecordUids' => implode(',', $sortableRecordUids),
|
|
'validationRules' => $this->getValidationDataAsJsonString([
|
|
'type' => 'inline',
|
|
'minitems' => $config['minitems'] ?? null,
|
|
'maxitems' => $config['maxitems'] ?? null,
|
|
]),
|
|
]);
|
|
|
|
if (!$isReadOnly && ($config['appearance']['showFileSelectors'] ?? true) !== false) {
|
|
/** @var FileExtensionFilter $fileExtensionFilter */
|
|
$fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
|
|
$fileExtensionFilter->setAllowedFileExtensions($config['allowed'] ?? null);
|
|
$fileExtensionFilter->setDisallowedFileExtensions($config['disallowed'] ?? null);
|
|
$view->assign('fileSelectors', $this->getFileSelectors($inlineStructure, $config, $fileExtensionFilter));
|
|
$filteredFileExtensions = $fileExtensionFilter->getFilteredFileExtensions();
|
|
// Do not display "allowed file extensions" if all extensions are allowed (indicated by ['*'])
|
|
if (($filteredFileExtensions['allowedFileExtensions'] ?? null) === ['*']) {
|
|
$filteredFileExtensions = [];
|
|
}
|
|
$view->assignMultiple($filteredFileExtensions);
|
|
// Render the localization buttons if needed
|
|
if ($numberOfNotYetLocalizedChildren) {
|
|
$view->assignMultiple([
|
|
'showAllLocalizationLink' => !empty($config['appearance']['showAllLocalizationLink']),
|
|
'showSynchronizationLink' => !empty($config['appearance']['showSynchronizationLink']),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$event = $this->eventDispatcher->dispatch(
|
|
new CustomFileControlsEvent($resultArray, $table, $field, $row, $config, $formFieldIdentifier, $formFieldName)
|
|
);
|
|
$resultArray = $event->getResultArray();
|
|
$controls = $event->getControls();
|
|
|
|
if ($controls !== []) {
|
|
$view->assign('customControls', [
|
|
'id' => $formFieldIdentifier . '_customControls',
|
|
'controls' => implode("\n", $controls),
|
|
]);
|
|
}
|
|
|
|
$resultArray['javaScriptModules'] = array_merge(
|
|
$resultArray['javaScriptModules'],
|
|
$this->javaScriptModules,
|
|
[JavaScriptModuleInstruction::create('@typo3/backend/form-engine/container/inline-control-container.js')]
|
|
);
|
|
|
|
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($view->render('Form/FilesControlContainer'));
|
|
return $resultArray;
|
|
}
|
|
|
|
/**
|
|
* Generate buttons to select, reference and upload files.
|
|
*/
|
|
protected function getFileSelectors(array $inlineStructure, array $inlineConfiguration, FileExtensionFilter $fileExtensionFilter): array
|
|
{
|
|
$languageService = $this->getLanguageService();
|
|
$backendUser = $this->getBackendUserAuthentication();
|
|
|
|
$currentStructureDomObjectIdPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
|
|
$objectPrefix = $currentStructureDomObjectIdPrefix . '-' . self::FILE_REFERENCE_TABLE;
|
|
|
|
$controls = [];
|
|
if ($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true) {
|
|
if ($inlineConfiguration['appearance']['createNewRelationLinkTitle'] ?? false) {
|
|
$buttonText = $inlineConfiguration['appearance']['createNewRelationLinkTitle'];
|
|
} else {
|
|
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.createNewRelation';
|
|
}
|
|
$buttonText = $languageService->sL($buttonText);
|
|
$attributes = [
|
|
'type' => 'button',
|
|
'class' => 'btn btn-default t3js-element-browser',
|
|
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
|
|
'title' => $buttonText,
|
|
'data-mode' => 'file',
|
|
'data-allowed-types' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
|
|
'data-disallowed-types' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
|
|
'data-irre-object-id' => $objectPrefix,
|
|
];
|
|
$controls[] = '
|
|
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
|
|
' . $this->iconFactory->getIcon('actions-insert-record', IconSize::SMALL)->render() . '
|
|
' . htmlspecialchars($buttonText) . '
|
|
</button>';
|
|
}
|
|
|
|
$onlineMediaAllowed = [];
|
|
foreach ($this->onlineMediaHelperRegistry->getSupportedFileExtensions() as $supportedFileExtension) {
|
|
if ($fileExtensionFilter->isAllowed($supportedFileExtension)) {
|
|
$onlineMediaAllowed[] = $supportedFileExtension;
|
|
}
|
|
}
|
|
|
|
$showUpload = (bool)($inlineConfiguration['appearance']['fileUploadAllowed'] ?? true);
|
|
$showByUrl = ($inlineConfiguration['appearance']['fileByUrlAllowed'] ?? true) && $onlineMediaAllowed !== [];
|
|
|
|
if (($showUpload || $showByUrl) && $backendUser->getUserSettings()->isUploadFieldsInTopOfEBEnabled()) {
|
|
$folder = $this->defaultUploadFolderResolver->resolve(
|
|
$backendUser,
|
|
$this->data['tableName'] === 'pages' ? $this->data['vanillaUid'] : ($this->data['parentPageRow']['uid'] ?? 0),
|
|
$this->data['tableName'],
|
|
$this->data['fieldName']
|
|
);
|
|
if (
|
|
$folder instanceof Folder
|
|
&& $folder->getStorage()->checkUserActionPermission('add', 'File')
|
|
) {
|
|
if ($showUpload) {
|
|
if ($inlineConfiguration['appearance']['uploadFilesLinkTitle'] ?? false) {
|
|
$buttonText = $inlineConfiguration['appearance']['uploadFilesLinkTitle'];
|
|
} else {
|
|
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:file_upload.select-and-submit';
|
|
}
|
|
$buttonText = $languageService->sL($buttonText);
|
|
|
|
$attributes = [
|
|
'type' => 'button',
|
|
'class' => 'btn btn-default t3js-drag-uploader',
|
|
'title' => $buttonText,
|
|
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
|
|
'data-dropzone-target' => '#' . StringUtility::escapeCssSelector($currentStructureDomObjectIdPrefix),
|
|
'data-insert-dropzone-before' => '1',
|
|
'data-file-irre-object' => $objectPrefix,
|
|
'data-file-allowed' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
|
|
'data-file-disallowed' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
|
|
'data-target-folder' => $folder->getCombinedIdentifier(),
|
|
'data-max-file-size' => (string)(GeneralUtility::getMaxUploadFileSize() * 1024),
|
|
];
|
|
$controls[] = '
|
|
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
|
|
' . $this->iconFactory->getIcon('actions-upload', IconSize::SMALL)->render() . '
|
|
' . htmlspecialchars($buttonText) . '
|
|
</button>';
|
|
|
|
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/drag-uploader.js');
|
|
}
|
|
if ($showByUrl) {
|
|
if ($inlineConfiguration['appearance']['addMediaLinkTitle'] ?? false) {
|
|
$buttonText = $inlineConfiguration['appearance']['addMediaLinkTitle'];
|
|
} else {
|
|
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.button';
|
|
}
|
|
$buttonText = $languageService->sL($buttonText);
|
|
$attributes = [
|
|
'type' => 'button',
|
|
'class' => 'btn btn-default t3js-online-media-add-btn',
|
|
'title' => $buttonText,
|
|
'hidden' => !($inlineConfiguration['inline']['showOnlineMediaAddButtonStyle'] ?? true) ? 'hidden' : null,
|
|
'data-target-folder' => $folder->getCombinedIdentifier(),
|
|
'data-file-irre-object' => $objectPrefix,
|
|
'data-online-media-allowed' => implode(',', $onlineMediaAllowed),
|
|
'data-btn-submit' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
|
|
'data-placeholder' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
|
|
'data-online-media-allowed-help-text' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.allowEmbedSources'),
|
|
];
|
|
|
|
// @todo Should be implemented as web component
|
|
$controls[] = '
|
|
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
|
|
' . $this->iconFactory->getIcon('actions-online-media-add', IconSize::SMALL)->render() . '
|
|
' . htmlspecialchars($buttonText) . '
|
|
</button>';
|
|
|
|
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/online-media.js');
|
|
}
|
|
}
|
|
}
|
|
|
|
$event = $this->eventDispatcher->dispatch(
|
|
new CustomFileSelectorsEvent($controls, $this->javaScriptModules, $this->data['tableName'], $this->data['fieldName'], $this->data['databaseRow'], $inlineConfiguration, $fileExtensionFilter, $objectPrefix)
|
|
);
|
|
$this->javaScriptModules = $event->getJavaScriptModules();
|
|
return $event->getSelectors();
|
|
}
|
|
|
|
/**
|
|
* Extracts FlexForm parts of a form element name like
|
|
* data[table][uid][field][sDEF][lDEF][FlexForm][vDEF]
|
|
*/
|
|
protected function extractFlexFormParts(string $formElementName): ?array
|
|
{
|
|
$flexFormParts = null;
|
|
$matches = [];
|
|
if (preg_match('#^data(?:\[[^]]+\]){3}(\[data\](?:\[[^]]+\]){4,})$#', $formElementName, $matches)) {
|
|
$flexFormParts = GeneralUtility::trimExplode(
|
|
'][',
|
|
trim($matches[1], '[]')
|
|
);
|
|
}
|
|
return $flexFormParts;
|
|
}
|
|
|
|
protected function getLanguageService(): LanguageService
|
|
{
|
|
return $GLOBALS['LANG'];
|
|
}
|
|
}
|