TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
|
||||
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
|
||||
use TYPO3\CMS\Core\Resource\Folder;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
|
||||
/**
|
||||
* Service to find and clean up old form upload folders.
|
||||
*
|
||||
* When ext:form handles file uploads, it creates sub-folders named
|
||||
* `form_<40-hex-chars>` inside the configured upload folder (default:
|
||||
* `1:/user_upload/`). Over time, these folders accumulate — both from
|
||||
* completed and incomplete form submissions.
|
||||
*
|
||||
* Since uploaded files are not moved upon form submission, there is no
|
||||
* way to distinguish between folders from completed and abandoned
|
||||
* submissions. This service identifies form upload folders based on
|
||||
* their age (modification time) and provides methods to list and delete them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
readonly class CleanupFormUploadsService
|
||||
{
|
||||
/**
|
||||
* Regex matching the folder naming convention used by
|
||||
* UploadedFileReferenceConverter::importUploadedResource():
|
||||
* `form_` followed by exactly 40 hex characters (HMAC output).
|
||||
*/
|
||||
private const string FORM_UPLOAD_FOLDER_PATTERN = '/^form_[a-f0-9]{40}$/';
|
||||
|
||||
public function __construct(
|
||||
private ResourceFactory $resourceFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Finds expired form upload folders in the given upload folders.
|
||||
*
|
||||
* A folder is considered expired when:
|
||||
* 1. Its name matches the `form_<40-hex-chars>` pattern
|
||||
* 2. Its modification time is older than the given maximum age
|
||||
*
|
||||
* @param int $maximumAge Maximum age in seconds. Folders older than this are considered expired.
|
||||
* @param list<string> $uploadFolderIdentifiers List of combined folder identifiers to scan
|
||||
* (e.g. ['1:/user_upload/', '2:/custom_uploads/']).
|
||||
* @return list<Folder> List of expired form upload folders
|
||||
*/
|
||||
public function getExpiredFolders(int $maximumAge, array $uploadFolderIdentifiers): array
|
||||
{
|
||||
$cutoffTimestamp = time() - $maximumAge;
|
||||
$expiredFolders = [];
|
||||
|
||||
foreach ($uploadFolderIdentifiers as $folderIdentifier) {
|
||||
$expiredFolders = [
|
||||
...$expiredFolders,
|
||||
...$this->findExpiredFoldersInParent($folderIdentifier, $cutoffTimestamp),
|
||||
];
|
||||
}
|
||||
|
||||
return $expiredFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given folders and returns a result summary.
|
||||
*
|
||||
* @param list<Folder> $folders Folders to delete
|
||||
* @return array{deleted: int, failed: int, errors: list<array{folder: string, message: string}>}
|
||||
*/
|
||||
public function deleteFolders(array $folders): array
|
||||
{
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
try {
|
||||
$folder->delete(true);
|
||||
$deleted++;
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$errors[] = [
|
||||
'folder' => $folder->getCombinedIdentifier(),
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
$this->logger->error(
|
||||
'Failed to delete form upload folder "{folder}": {message}',
|
||||
['folder' => $folder->getCombinedIdentifier(), 'message' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'deleted' => $deleted,
|
||||
'failed' => $failed,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find expired form upload folders in a specific parent folder.
|
||||
*
|
||||
* @return list<Folder>
|
||||
*/
|
||||
private function findExpiredFoldersInParent(string $folderIdentifier, int $cutoffTimestamp): array
|
||||
{
|
||||
$expiredFolders = [];
|
||||
|
||||
try {
|
||||
$parentFolder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($folderIdentifier);
|
||||
} catch (FolderDoesNotExistException|InsufficientFolderAccessPermissionsException|\InvalidArgumentException $e) {
|
||||
$this->logger->warning(
|
||||
'Could not access upload folder "{folder}": {message}',
|
||||
['folder' => $folderIdentifier, 'message' => $e->getMessage()]
|
||||
);
|
||||
return $expiredFolders;
|
||||
}
|
||||
|
||||
foreach ($parentFolder->getSubfolders() as $subFolder) {
|
||||
if ($this->isExpiredFormUploadFolder($subFolder, $cutoffTimestamp)) {
|
||||
$expiredFolders[] = $subFolder;
|
||||
}
|
||||
}
|
||||
|
||||
return $expiredFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a folder is an expired form upload folder.
|
||||
*
|
||||
* A folder is considered an expired form upload folder when:
|
||||
* 1. Its name matches the exact `form_<40-hex-chars>` pattern
|
||||
* (as generated by UploadedFileReferenceConverter::importUploadedResource())
|
||||
* 2. Its modification time is older than the cutoff timestamp
|
||||
*/
|
||||
private function isExpiredFormUploadFolder(Folder $folder, int $cutoffTimestamp): bool
|
||||
{
|
||||
if (preg_match(self::FORM_UPLOAD_FOLDER_PATTERN, $folder->getName()) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $folder->getModificationTime() < $cutoffTimestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
|
||||
use TYPO3\CMS\Core\Resource\File;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\MathUtility;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
use TYPO3\CMS\Form\Domain\Repository\FormDefinitionRepository;
|
||||
|
||||
/**
|
||||
* This class is subjected to change.
|
||||
* **Do NOT subclass**
|
||||
*
|
||||
* Scope: frontend / backend
|
||||
* @internal
|
||||
*/
|
||||
class DatabaseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResourceFactory $resourceFactory,
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns an array with all sys_refindex database rows which be
|
||||
* connected to a formDefinition identified by $persistenceIdentifier
|
||||
*
|
||||
* $persistenceIdentifier string can contain:
|
||||
* - number -> interpreted as a sys_file reference UID to a FAL-stored YAML file (user-generated content)
|
||||
* - EXT:... -> interpreted as a NON-FAL extension-based file
|
||||
* - any string -> interpreted as FAL-based filename
|
||||
*
|
||||
* Note that we explicitly do NOT check for file existence here,
|
||||
* because we want to be able to reveal sys_refindex entries to files
|
||||
* that have been deleted meanwhile!
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function getReferencesByPersistenceIdentifier(string $persistenceIdentifier): array
|
||||
{
|
||||
if (empty($persistenceIdentifier)) {
|
||||
throw new \InvalidArgumentException('$persistenceIdentifier must not be empty.', 1472238493);
|
||||
}
|
||||
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
|
||||
$constraints = [$queryBuilder->expr()->eq('softref_key', $queryBuilder->createNamedParameter('formPersistenceIdentifier'))];
|
||||
|
||||
// Indicator whether the string-based lookup in sys_refindex shall be performed (true; non FAL-based) or not (false; FAL-based)
|
||||
$useStringReference = false;
|
||||
|
||||
// Check what $persistenceIdentifier contains.
|
||||
if (PathUtility::isExtensionPath($persistenceIdentifier)) {
|
||||
// Uses "EXT:" notation, so it cannot be a FAL identifier.
|
||||
// We pass the whole "EXT:..." lookup through to the sys_refindex query
|
||||
// due to its constraint on softref_key=formPersistenceIdentifier,
|
||||
// we expect no false entries even with "weird" string notations. If sys_refindex
|
||||
// has it, we yield it.
|
||||
$useStringReference = true;
|
||||
} elseif (MathUtility::canBeInterpretedAsInteger($persistenceIdentifier)) {
|
||||
$constraints[] = $queryBuilder->expr()->or(
|
||||
$queryBuilder->expr()->eq('ref_string', $queryBuilder->createNamedParameter($persistenceIdentifier)),
|
||||
$queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($persistenceIdentifier, Connection::PARAM_INT))
|
||||
);
|
||||
} else {
|
||||
// Anything else would be either a notation like "/fileadmin/something.form.yaml"
|
||||
// or a numeric identifier for a sys_file.
|
||||
try {
|
||||
// We use this "bulk method" because this is the best-bet from resourceFactory
|
||||
// to resolve both an integer-ish input value or a FAL value. There is no
|
||||
// substitute for an "only get a file, not a directory" lookup.
|
||||
$file = $this->resourceFactory->retrieveFileOrFolderObject($persistenceIdentifier);
|
||||
|
||||
if ($file === null) {
|
||||
// The associated identifier could (no longer) be retrieved via FAL.
|
||||
// However, we do want to see existing entries to such stale entries to
|
||||
// be able to reveal bad references, either by its ref_string or ref_uid
|
||||
$useStringReference = true;
|
||||
} elseif ($file instanceof File) {
|
||||
// We succeeded in retrieving the FAL file object.
|
||||
$constraints[] = $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($file->getUid(), Connection::PARAM_INT));
|
||||
} else {
|
||||
// We might have retrieved a "Folder" object. Fall back to passthrough
|
||||
// with the intent, to retrieve all possible sys_refindex entries.
|
||||
// If that fails, it's ok to return an empty array.
|
||||
$useStringReference = true;
|
||||
}
|
||||
} catch (ResourceDoesNotExistException) {
|
||||
// This exception gets triggered when $persistenceIdentifier is not something
|
||||
// that could be resolved by the bulk-method.
|
||||
// As above, we want to retrieve all the possible sys_refindex entries,
|
||||
// so we fall back again to "ref_string".
|
||||
// This should happen when $persistenceIdentifier is set to a string like '/fileadmin/somefile.form.yaml',
|
||||
// and a FAL storage could be retrieved, but not the actual file.
|
||||
$useStringReference = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($useStringReference) {
|
||||
$constraints[] = $queryBuilder->expr()->eq('ref_string', $queryBuilder->createNamedParameter($persistenceIdentifier));
|
||||
}
|
||||
|
||||
return $queryBuilder
|
||||
->select('*')
|
||||
->from('sys_refindex')
|
||||
->where(...$constraints)
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all form definition persistenceIdentifiers
|
||||
* as keys and their reference counts as values.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function getAllReferencesForPersistenceIdentifier(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($this->getAllReferences('ref_string') as $item) {
|
||||
$items[$item['identifier']] = $item['items'];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all form definition file uids as keys
|
||||
* and their reference counts as values.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function getAllReferencesForFileUid(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($this->getAllReferences('ref_uid') as $item) {
|
||||
$items[$item['identifier']] = $item['items'];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all database-stored form definition UIDs as keys
|
||||
* and their reference counts as values.
|
||||
*
|
||||
* These are tracked in sys_refindex via ref_table='form_definition' and ref_uid=<form_definition UID>.
|
||||
*
|
||||
* @return array<string, int> persistenceIdentifier (UID as string) => reference count
|
||||
* @internal
|
||||
*/
|
||||
public function getAllReferencesForFormDefinitionUid(): array
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select('ref_uid AS identifier')
|
||||
->addSelectLiteral('COUNT(' . $queryBuilder->quoteIdentifier('ref_uid') . ') AS ' . $queryBuilder->quoteIdentifier('items'))
|
||||
->from('sys_refindex')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('softref_key', $queryBuilder->createNamedParameter('formPersistenceIdentifier')),
|
||||
$queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter(FormDefinitionRepository::TABLE_NAME)),
|
||||
$queryBuilder->expr()->gt('ref_uid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
|
||||
)
|
||||
->groupBy('ref_uid')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$items[(string)$row['identifier']] = (int)$row['items'];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
protected function getAllReferences(string $column): array
|
||||
{
|
||||
if ($column !== 'ref_string' && $column !== 'ref_uid') {
|
||||
throw new \InvalidArgumentException('$column must be "ref_string" or "ref_uid".', 1535406600);
|
||||
}
|
||||
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
|
||||
|
||||
$constraints = [
|
||||
$queryBuilder->expr()->eq('softref_key', $queryBuilder->createNamedParameter('formPersistenceIdentifier')),
|
||||
];
|
||||
|
||||
if ($column === 'ref_string') {
|
||||
$constraints[] = $queryBuilder->expr()->neq('ref_string', $queryBuilder->createNamedParameter(''));
|
||||
} else {
|
||||
$constraints[] = $queryBuilder->expr()->gt('ref_uid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT));
|
||||
}
|
||||
|
||||
return $queryBuilder
|
||||
->select($column . ' AS identifier')
|
||||
->addSelectLiteral('COUNT(' . $queryBuilder->quoteIdentifier($column) . ') AS ' . $queryBuilder->quoteIdentifier('items'))
|
||||
->from('sys_refindex')
|
||||
->where(...$constraints)
|
||||
->groupBy($column)
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
|
||||
|
||||
/**
|
||||
* Service to enrich form editor definitions with additional runtime data.
|
||||
*
|
||||
* This service processes form editor definitions and enriches them with
|
||||
* additional configuration data that is needed for the form editor UI.
|
||||
* Currently, it handles the enrichment of textarea editors with RTE options.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
readonly class FormEditorEnrichmentService
|
||||
{
|
||||
public function __construct(
|
||||
private RichTextConfigurationService $richTextConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Enrich form editor definitions with RTE options and other runtime data.
|
||||
*
|
||||
* Processes all form editor definitions and adds CKEditor configuration
|
||||
* to textarea editors that have enableRichtext enabled.
|
||||
*
|
||||
* @param array $formEditorDefinitions The form editor definitions to enrich
|
||||
* @return array The enriched form editor definitions
|
||||
*/
|
||||
public function enrichFormEditorDefinitions(array $formEditorDefinitions): array
|
||||
{
|
||||
// Only enrich with RTE options if the rte_ckeditor extension is loaded
|
||||
if (!ExtensionManagementUtility::isLoaded('rte_ckeditor')) {
|
||||
return $formEditorDefinitions;
|
||||
}
|
||||
|
||||
foreach ($formEditorDefinitions as &$definitions) {
|
||||
foreach ($definitions as &$definition) {
|
||||
$this->enrichDefinitionWithRichTextOptions($definition);
|
||||
}
|
||||
}
|
||||
|
||||
return $formEditorDefinitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a single definition with RTE options for its editors and property collections.
|
||||
*/
|
||||
protected function enrichDefinitionWithRichTextOptions(array &$definition): void
|
||||
{
|
||||
if (is_array($definition['editors'] ?? null)) {
|
||||
$this->enrichEditorsWithRichTextOptions($definition['editors']);
|
||||
}
|
||||
|
||||
if (is_array($definition['propertyCollections'] ?? null)) {
|
||||
$this->enrichPropertyCollectionsWithRichTextOptions($definition['propertyCollections']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich property collections (e.g., finishers, validators) with RTE options.
|
||||
*
|
||||
* Property collections have an additional numeric level in their structure:
|
||||
* propertyCollections -> collectionName (e.g., 'finishers') -> numeric index -> editors
|
||||
*/
|
||||
protected function enrichPropertyCollectionsWithRichTextOptions(array &$propertyCollections): void
|
||||
{
|
||||
foreach ($propertyCollections as &$collectionItems) {
|
||||
if (!is_array($collectionItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($collectionItems as &$collectionItem) {
|
||||
if (is_array($collectionItem['editors'] ?? null)) {
|
||||
$this->enrichEditorsWithRichTextOptions($collectionItem['editors']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich editors array with RTE options if enableRichtext is set.
|
||||
*
|
||||
* Iterates through all editors and adds RTE configuration options
|
||||
* to textarea editors that have rich text enabled.
|
||||
*/
|
||||
protected function enrichEditorsWithRichTextOptions(array &$editors): void
|
||||
{
|
||||
foreach ($editors as &$editor) {
|
||||
if ($this->shouldEnrichEditorWithRichText($editor)) {
|
||||
$editor['rteOptions'] = $this->resolveRichTextOptions($editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an editor should be enriched with RTE options.
|
||||
*
|
||||
* An editor qualifies for RTE enrichment if it is a textarea editor
|
||||
* and has the enableRichtext flag set to true.
|
||||
*/
|
||||
protected function shouldEnrichEditorWithRichText(array $editor): bool
|
||||
{
|
||||
return ($editor['templateName'] ?? '') === 'Inspector-TextareaEditor'
|
||||
&& ($editor['enableRichtext'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CKEditor configuration options for the given editor.
|
||||
*
|
||||
* Retrieves the RTE preset configuration and resolves it into
|
||||
* a complete CKEditor configuration that can be used in the form editor.
|
||||
*/
|
||||
protected function resolveRichTextOptions(array $editor): array
|
||||
{
|
||||
$presetName = $editor['richtextConfiguration'] ?? 'form-label';
|
||||
return $this->richTextConfigurationService->resolveCkEditorConfiguration($presetName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
/**
|
||||
* Result of a single form transfer operation
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class FormTransferResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $sourceIdentifier,
|
||||
public string $targetIdentifier,
|
||||
public string $formIdentifier,
|
||||
public string $formName,
|
||||
public bool $sourceDeleted = false,
|
||||
public ?string $deletionError = null,
|
||||
) {}
|
||||
|
||||
public function isFullySuccessful(): bool
|
||||
{
|
||||
return $this->deletionError === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Form\Domain\DTO\FormData;
|
||||
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
|
||||
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
|
||||
use TYPO3\CMS\Form\Domain\DTO\StorageContext;
|
||||
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
|
||||
use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;
|
||||
use TYPO3\CMS\Form\Storage\StorageAdapterFactory;
|
||||
use TYPO3\CMS\Form\Storage\StorageAdapterInterface;
|
||||
|
||||
/**
|
||||
* Service for transferring form definitions between storage backends
|
||||
*
|
||||
* Used by the CLI command `form:definition:transfer` and the
|
||||
* upgrade wizard for migrating file-based forms to database storage.
|
||||
* Handles reading from a source storage adapter, ensuring identifier
|
||||
* uniqueness, and writing to a target storage adapter.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class FormTransferService
|
||||
{
|
||||
public function __construct(
|
||||
private StorageAdapterFactory $storageAdapterFactory,
|
||||
private FormPersistenceManagerInterface $formPersistenceManager,
|
||||
private ConnectionPool $connectionPool,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List all forms in the given source storage
|
||||
*
|
||||
* @return list<FormMetadata>
|
||||
*/
|
||||
public function listSourceForms(string $sourceType, ?string $formIdentifier = null): array
|
||||
{
|
||||
$sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType);
|
||||
$forms = $sourceAdapter->findAll(new SearchCriteria());
|
||||
|
||||
if ($formIdentifier !== null) {
|
||||
$forms = array_values(array_filter(
|
||||
$forms,
|
||||
static fn(FormMetadata $form) => $form->identifier === $formIdentifier,
|
||||
));
|
||||
}
|
||||
|
||||
return $forms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a form definition from a source storage
|
||||
*/
|
||||
public function readForm(string $sourceType, string $persistenceIdentifier): FormData
|
||||
{
|
||||
return $this->storageAdapterFactory->getAdapterByType($sourceType)->read(FormIdentifier::fromString($persistenceIdentifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a form from a storage
|
||||
*/
|
||||
public function deleteForm(string $storageType, string $persistenceIdentifier): void
|
||||
{
|
||||
$adapter = $this->storageAdapterFactory->getAdapterByType($storageType);
|
||||
$adapter->delete(FormIdentifier::fromString($persistenceIdentifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a single form from source to target storage
|
||||
*
|
||||
* @return FormTransferResult
|
||||
*/
|
||||
public function transferForm(
|
||||
FormMetadata $sourceForm,
|
||||
string $sourceType,
|
||||
string $targetType,
|
||||
string $targetLocation,
|
||||
bool $deleteSource = false,
|
||||
): FormTransferResult {
|
||||
$sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType);
|
||||
$targetAdapter = $this->storageAdapterFactory->getAdapterByType($targetType);
|
||||
|
||||
// Read from source
|
||||
$sourcePersistenceIdentifier = $sourceForm->persistenceIdentifier ?? $sourceForm->identifier;
|
||||
$formData = $sourceAdapter->read(FormIdentifier::fromString($sourcePersistenceIdentifier));
|
||||
|
||||
// Ensure unique identifier in target.
|
||||
// For a move operation the source form will be deleted afterwards, so it must not be
|
||||
// counted as a duplicate. Therefore only the target adapter is checked for conflicts.
|
||||
// For a copy operation all adapters are checked to avoid the same logical identifier
|
||||
// existing in multiple storages simultaneously.
|
||||
if ($deleteSource) {
|
||||
$uniqueIdentifier = $this->getUniqueIdentifierInAdapter($targetAdapter, $formData->identifier);
|
||||
} else {
|
||||
$uniqueIdentifier = $this->formPersistenceManager->getUniqueIdentifier($formData->identifier);
|
||||
}
|
||||
|
||||
// Build FormData with potentially updated identifier
|
||||
$targetFormData = $uniqueIdentifier !== $formData->identifier
|
||||
? FormData::fromArray(array_merge($formData->toArray(), ['identifier' => $uniqueIdentifier]))
|
||||
: $formData;
|
||||
|
||||
// Get unique persistence identifier in target storage
|
||||
$targetPersistenceIdentifier = $targetAdapter->getUniquePersistenceIdentifier(
|
||||
$uniqueIdentifier,
|
||||
$targetLocation,
|
||||
);
|
||||
|
||||
// Write to target
|
||||
$context = $this->buildStorageContext($targetLocation);
|
||||
$savedIdentifier = $targetAdapter->write(
|
||||
FormIdentifier::fromString($targetPersistenceIdentifier),
|
||||
$targetFormData,
|
||||
$context,
|
||||
);
|
||||
|
||||
// Optionally delete from source
|
||||
$sourceDeleted = false;
|
||||
$deletionError = null;
|
||||
if ($deleteSource) {
|
||||
try {
|
||||
$sourceAdapter->delete(FormIdentifier::fromString($sourcePersistenceIdentifier));
|
||||
$sourceDeleted = true;
|
||||
} catch (\Exception $e) {
|
||||
$deletionError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return new FormTransferResult(
|
||||
sourceIdentifier: $sourcePersistenceIdentifier,
|
||||
targetIdentifier: $savedIdentifier->identifier,
|
||||
formIdentifier: $uniqueIdentifier,
|
||||
formName: $sourceForm->name,
|
||||
sourceDeleted: $sourceDeleted,
|
||||
deletionError: $deletionError,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered storage type identifiers
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getAvailableStorageTypes(): array
|
||||
{
|
||||
return $this->storageAdapterFactory->getRegisteredTypeIdentifiers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a storage type exists
|
||||
*/
|
||||
public function hasStorageType(string $typeIdentifier): bool
|
||||
{
|
||||
return $this->storageAdapterFactory->hasAdapterType($typeIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adapter for a storage type (for validation purposes)
|
||||
*/
|
||||
public function getAdapter(string $typeIdentifier): StorageAdapterInterface
|
||||
{
|
||||
return $this->storageAdapterFactory->getAdapterByType($typeIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique form identifier by checking only the given storage adapter for conflicts.
|
||||
*
|
||||
* Used for move operations: since the source form is deleted after transfer, only the
|
||||
* target storage needs to be free of the identifier — not all storages globally.
|
||||
*
|
||||
* @throws \RuntimeException if no unique identifier can be found
|
||||
*/
|
||||
private function getUniqueIdentifierInAdapter(StorageAdapterInterface $adapter, string $identifier): string
|
||||
{
|
||||
$originalIdentifier = $identifier;
|
||||
|
||||
if (!$adapter->existsByFormIdentifier($identifier)) {
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
for ($attempts = 1; $attempts < 100; $attempts++) {
|
||||
$identifier = sprintf('%s_%d', $originalIdentifier, $attempts);
|
||||
if (!$adapter->existsByFormIdentifier($identifier)) {
|
||||
return $identifier;
|
||||
}
|
||||
}
|
||||
|
||||
$identifier = $originalIdentifier . '_' . time();
|
||||
if (!$adapter->existsByFormIdentifier($identifier)) {
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf('Could not find a unique identifier for form identifier "%s" after %d attempts', $originalIdentifier, $attempts),
|
||||
1742477400
|
||||
);
|
||||
}
|
||||
|
||||
private function buildStorageContext(string $targetLocation): ?StorageContext
|
||||
{
|
||||
if (ctype_digit($targetLocation)) {
|
||||
return StorageContext::create((int)$targetLocation);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tt_content FlexForm references from old persistence identifiers
|
||||
* to new ones.
|
||||
*
|
||||
* Uses DOM/XPath parsing to precisely target only the
|
||||
* `settings.persistenceIdentifier` field in FlexForm XML, avoiding
|
||||
* false replacements in other fields.
|
||||
*
|
||||
* @param array<string, string> $migrationMap Old persistenceIdentifier => new persistenceIdentifier
|
||||
* @return int Number of updated content element references
|
||||
*/
|
||||
public function updateContentElementReferences(array $migrationMap): int
|
||||
{
|
||||
$updatedCount = 0;
|
||||
$connection = $this->connectionPool->getConnectionForTable('tt_content');
|
||||
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
|
||||
$queryBuilder->getRestrictions()->removeAll()->add(
|
||||
GeneralUtility::makeInstance(DeletedRestriction::class)
|
||||
);
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select('uid', 'pi_flexform')
|
||||
->from('tt_content')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq(
|
||||
'CType',
|
||||
$queryBuilder->createNamedParameter('form_formframework')
|
||||
),
|
||||
$queryBuilder->expr()->isNotNull('pi_flexform'),
|
||||
$queryBuilder->expr()->neq(
|
||||
'pi_flexform',
|
||||
$queryBuilder->createNamedParameter('')
|
||||
)
|
||||
)
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$flexForm = $row['pi_flexform'];
|
||||
$newValue = $this->replacePersistenceIdentifierInFlexForm($flexForm, $migrationMap);
|
||||
|
||||
if ($newValue !== null && $newValue !== $flexForm) {
|
||||
$connection->update(
|
||||
'tt_content',
|
||||
['pi_flexform' => $newValue],
|
||||
['uid' => (int)$row['uid']]
|
||||
);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $updatedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the persistenceIdentifier value in FlexForm XML using DOM parsing.
|
||||
*
|
||||
* Specifically targets only the <field index="settings.persistenceIdentifier">
|
||||
* element to avoid replacing values in other FlexForm fields.
|
||||
*
|
||||
* @param string $flexFormXml The raw FlexForm XML string
|
||||
* @param array<string, string> $migrationMap Old persistenceIdentifier => new persistenceIdentifier
|
||||
* @return string|null The modified XML string, or null if parsing failed or no changes were made
|
||||
*/
|
||||
private function replacePersistenceIdentifierInFlexForm(string $flexFormXml, array $migrationMap): ?string
|
||||
{
|
||||
$document = new \DOMDocument();
|
||||
$previousErrorHandling = libxml_use_internal_errors(true);
|
||||
|
||||
if (!$document->loadXML($flexFormXml)) {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousErrorHandling);
|
||||
$this->logger->warning('Could not parse FlexForm XML.');
|
||||
return null;
|
||||
}
|
||||
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousErrorHandling);
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$nodes = $xpath->query('//field[@index="settings.persistenceIdentifier"]/value[@index="vDEF"]');
|
||||
|
||||
if ($nodes === false || $nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified = false;
|
||||
foreach ($nodes as $node) {
|
||||
$currentValue = $node->nodeValue;
|
||||
if (isset($migrationMap[$currentValue])) {
|
||||
$node->nodeValue = (string)$migrationMap[$currentValue];
|
||||
$modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$modified) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $document->saveXML();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
|
||||
use TYPO3\CMS\Core\Configuration\Richtext;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Html\RteHtmlParser;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
|
||||
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
|
||||
/**
|
||||
* Service to resolve RTE configuration for the form editor.
|
||||
*
|
||||
* This service prepares CKEditor 5 configuration for use in the TYPO3 Form Editor backend.
|
||||
* It loads configuration from global TYPO3 RTE presets, processes external plugins,
|
||||
* and transforms the configuration to be compatible with the form editor context.
|
||||
*
|
||||
* Similar to RichTextElement, but adapted for the form editor's specific requirements.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
readonly class RichTextConfigurationService
|
||||
{
|
||||
public function __construct(
|
||||
private Richtext $richtext,
|
||||
private RteHtmlParser $rteHtmlParser,
|
||||
private SystemResourcePublisherInterface $resourcePublisher,
|
||||
private SystemResourceFactory $systemResourceFactory,
|
||||
private UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves and prepares CKEditor configuration for the form editor.
|
||||
*
|
||||
* This method loads the specified RTE preset from TYPO3's global configuration,
|
||||
* processes it, and returns a configuration array ready for use with CKEditor 5.
|
||||
*
|
||||
* @param string $presetName Name of the RTE preset (e.g., 'form-label', 'form-content')
|
||||
* @return array The processed CKEditor configuration, or empty array if rte_ckeditor is not loaded
|
||||
*/
|
||||
public function resolveCkEditorConfiguration(string $presetName = 'form-label'): array
|
||||
{
|
||||
// Check if rte_ckeditor extension is loaded
|
||||
if (!ExtensionManagementUtility::isLoaded('rte_ckeditor')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$richtextConfiguration = $this->loadRichtextConfiguration($presetName);
|
||||
return $this->prepareConfigurationForEditor($richtextConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the processing configuration (proc.) for HTML transformations.
|
||||
*
|
||||
* This method loads the RTE preset and returns the processing configuration
|
||||
* that can be used with RteHtmlParser for HTML transformations.
|
||||
*
|
||||
* Note: Unlike resolveCkEditorConfiguration(), this method does NOT check for rte_ckeditor
|
||||
* because RteHtmlParser is part of the Core and works independently of the editor.
|
||||
*
|
||||
* @param string $presetName Name of the RTE preset (e.g., 'form-label', 'form-content')
|
||||
* @return array The processing configuration array
|
||||
*/
|
||||
public function resolveProcessingConfiguration(string $presetName = 'form-label'): array
|
||||
{
|
||||
|
||||
$richtextConfiguration = $this->loadRichtextConfiguration($presetName);
|
||||
return $richtextConfiguration['proc.'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms HTML content from RTE format for database persistence.
|
||||
*
|
||||
* @param string $htmlContent The HTML content from the RTE editor
|
||||
* @param string $presetName Name of the RTE preset to use for transformation rules
|
||||
* @return string The transformed HTML ready for database storage
|
||||
*/
|
||||
public function transformTextForPersistence(string $htmlContent, string $presetName = 'form-label'): string
|
||||
{
|
||||
$processingConfiguration = $this->resolveProcessingConfiguration($presetName);
|
||||
return $this->rteHtmlParser->transformTextForPersistence($htmlContent, $processingConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms HTML content from database format for RTE display.
|
||||
*
|
||||
* @param string $htmlContent The HTML content from the database
|
||||
* @param string $presetName Name of the RTE preset to use for transformation rules
|
||||
* @return string The transformed HTML ready for the RTE editor
|
||||
*/
|
||||
public function transformTextForRichTextEditor(string $htmlContent, string $presetName = 'form-label'): string
|
||||
{
|
||||
$processingConfiguration = $this->resolveProcessingConfiguration($presetName);
|
||||
|
||||
return $this->rteHtmlParser->transformTextForRichTextEditor($htmlContent, $processingConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full RTE configuration from the preset.
|
||||
*
|
||||
* @param string $presetName Name of the RTE preset
|
||||
* @return array The full richtext configuration
|
||||
*/
|
||||
private function loadRichtextConfiguration(string $presetName): array
|
||||
{
|
||||
// Load RTE configuration from TYPO3's global preset system
|
||||
// We use dummy values since we're in the form editor context without a specific record
|
||||
return $this->richtext->getConfiguration(
|
||||
'tx_form_dummy',
|
||||
'dummy_field',
|
||||
0,
|
||||
'',
|
||||
['richtextConfiguration' => $presetName]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the loaded RTE configuration for the CKEditor.
|
||||
*
|
||||
* @param array $richtextConfiguration The raw richtext configuration from preset
|
||||
* @return array The prepared configuration for CKEditor
|
||||
*/
|
||||
private function prepareConfigurationForEditor(array $richtextConfiguration): array
|
||||
{
|
||||
$configuration = [
|
||||
'customConfig' => '',
|
||||
'label' => '',
|
||||
];
|
||||
|
||||
if (is_array($richtextConfiguration['editor']['config'] ?? null)) {
|
||||
$configuration = array_replace_recursive($configuration, $richtextConfiguration['editor']['config']);
|
||||
}
|
||||
|
||||
$this->processExternalPlugins($richtextConfiguration, $configuration);
|
||||
|
||||
$this->configureLanguage($configuration);
|
||||
|
||||
$configuration = $this->replaceLanguageFileReferences($configuration);
|
||||
$configuration = $this->replaceAbsolutePathsToRelativeResourcesPath($configuration);
|
||||
|
||||
if (!isset($configuration['debug'])) {
|
||||
$configuration['debug'] = ($GLOBALS['TYPO3_CONF_VARS']['BE']['debug'] ?? false)
|
||||
&& Environment::getContext()->isDevelopment();
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes external plugins configuration.
|
||||
*
|
||||
* External plugins may require additional configuration like route URLs for the link browser.
|
||||
* This method handles the transformation of route names to actual URLs.
|
||||
*
|
||||
* Similar to RichTextElement::getExtraPlugins() and resolveCkEditorConfiguration().
|
||||
*
|
||||
* @param array $richtextConfiguration The full richtext configuration
|
||||
* @param array $configuration The configuration array to modify (passed by reference)
|
||||
*/
|
||||
private function processExternalPlugins(array $richtextConfiguration, array &$configuration): void
|
||||
{
|
||||
$externalPlugins = $richtextConfiguration['editor']['externalPlugins'] ?? [];
|
||||
|
||||
if ($externalPlugins === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($externalPlugins as $pluginName => $pluginConfig) {
|
||||
$configName = $pluginConfig['configName'] ?? $pluginName;
|
||||
|
||||
if (isset($pluginConfig['route'])) {
|
||||
$pluginConfig['routeUrl'] = $this->buildPluginRouteUrl($pluginConfig['route']);
|
||||
}
|
||||
|
||||
unset($pluginConfig['route'], $pluginConfig['configName'], $pluginConfig['resource']);
|
||||
|
||||
if ($pluginConfig !== []) {
|
||||
if (!isset($configuration[$configName])) {
|
||||
$configuration[$configName] = $pluginConfig;
|
||||
} elseif (is_array($configuration[$configName])) {
|
||||
$configuration[$configName] = array_replace_recursive(
|
||||
$pluginConfig,
|
||||
$configuration[$configName]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the route URL for an external plugin.
|
||||
*
|
||||
* @param string $route The route identifier (e.g., 'rteckeditor_wizard_browse_links')
|
||||
* @return string The complete URL for the route
|
||||
*/
|
||||
private function buildPluginRouteUrl(string $route): string
|
||||
{
|
||||
// Build URL parameters for the route
|
||||
// Using dummy values for form editor context as we don't have a specific record
|
||||
$urlParameters = [
|
||||
'P' => [
|
||||
'table' => 'tx_form',
|
||||
'uid' => 0,
|
||||
'fieldName' => 'form_field',
|
||||
'recordType' => '',
|
||||
'pid' => 0,
|
||||
'richtextConfigurationName' => '',
|
||||
],
|
||||
];
|
||||
|
||||
return (string)$this->uriBuilder->buildUriFromRoute($route, $urlParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures language settings for the editor.
|
||||
*
|
||||
* Sets both UI language (based on backend user preference) and content language.
|
||||
* For the form editor context, content language is always set to 'en'.
|
||||
*
|
||||
* @param array $configuration The configuration array to modify (passed by reference)
|
||||
*/
|
||||
private function configureLanguage(array &$configuration): void
|
||||
{
|
||||
// Set the UI language of the editor
|
||||
if (empty($configuration['language'])
|
||||
|| (is_array($configuration['language']) && empty($configuration['language']['ui']))
|
||||
) {
|
||||
$userLang = (string)($this->getBackendUser()->user['lang'] ?? 'en');
|
||||
$configuration['language']['ui'] = $userLang === 'default' ? 'en' : $userLang;
|
||||
} elseif (!is_array($configuration['language'])) {
|
||||
// Convert string language config to array format
|
||||
$configuration['language'] = [
|
||||
'ui' => $configuration['language'],
|
||||
];
|
||||
}
|
||||
|
||||
// Set content language to 'en' for form editor (no specific content language context)
|
||||
$configuration['language']['content'] = 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces LLL: language references with translated values.
|
||||
*
|
||||
* Recursively processes the configuration array and translates all language labels.
|
||||
*
|
||||
* @param array $configuration The configuration to process
|
||||
* @return array The configuration with translated labels
|
||||
*/
|
||||
private function replaceLanguageFileReferences(array $configuration): array
|
||||
{
|
||||
foreach ($configuration as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$configuration[$key] = $this->replaceLanguageFileReferences($value);
|
||||
} elseif (is_string($value) && str_starts_with($value, 'LLL:')) {
|
||||
$configuration[$key] = $this->getLanguageService()->sL($value);
|
||||
}
|
||||
}
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces absolute EXT: paths with relative web paths.
|
||||
*
|
||||
* Recursively processes the configuration array and converts all EXT: paths
|
||||
* to publicly accessible web paths.
|
||||
*
|
||||
* @param array $configuration The configuration to process
|
||||
* @return array The configuration with resolved paths
|
||||
*/
|
||||
private function replaceAbsolutePathsToRelativeResourcesPath(array $configuration): array
|
||||
{
|
||||
foreach ($configuration as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$configuration[$key] = $this->replaceAbsolutePathsToRelativeResourcesPath($value);
|
||||
} elseif (
|
||||
is_string($value)
|
||||
&& $value !== ''
|
||||
&& PathUtility::isExtensionPath(strtoupper($value), true)
|
||||
) {
|
||||
$configuration[$key] = $this->resolveUrlPath($value);
|
||||
}
|
||||
}
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a system resource to an absolute web URL.
|
||||
*
|
||||
* @param string $value The resource path (e.g., 'EXT:my_extension/Resources/Public/Css/file.css')
|
||||
* @return string The public web URL to the resource
|
||||
*/
|
||||
private function resolveUrlPath(string $value): string
|
||||
{
|
||||
$resource = $this->systemResourceFactory->createPublicResource($value);
|
||||
return (string)$this->resourcePublisher->generateUri($resource, null);
|
||||
}
|
||||
|
||||
private function getBackendUser(): BackendUserAuthentication
|
||||
{
|
||||
return $GLOBALS['BE_USER'];
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
<?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\Form\Service;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
|
||||
use TYPO3\CMS\Core\Localization\Locale;
|
||||
use TYPO3\CMS\Core\Localization\Locales;
|
||||
use TYPO3\CMS\Core\TypoScript\FrontendTypoScript;
|
||||
use TYPO3\CMS\Core\Utility\ArrayUtility;
|
||||
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
|
||||
use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
|
||||
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
|
||||
use TYPO3\CMS\Form\Domain\Translation\FormTranslationKeychainBuilder;
|
||||
|
||||
/**
|
||||
* Advanced translations
|
||||
* This class is subjected to change.
|
||||
* **Do NOT subclass**
|
||||
*
|
||||
* Scope: frontend / backend
|
||||
* @internal
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
class TranslationService
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly LanguageServiceFactory $languageServiceFactory,
|
||||
protected readonly Locales $locales,
|
||||
protected readonly FormTranslationKeychainBuilder $keychainBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the localized label of the LOCAL_LANG key, $key.
|
||||
*
|
||||
* @param mixed $key The key from the LOCAL_LANG array for which to return the value.
|
||||
* @param array|null $arguments the arguments of the extension, being passed over to vsprintf
|
||||
* @param mixed $defaultValue
|
||||
* @return mixed The value from LOCAL_LANG or $defaultValue if no translation was found.
|
||||
* @internal
|
||||
*/
|
||||
public function translate(
|
||||
$key,
|
||||
?array $arguments = null,
|
||||
?string $locallangPathAndFilename = null,
|
||||
Locale|string|null $locale = null,
|
||||
$defaultValue = ''
|
||||
) {
|
||||
$key = (string)$key;
|
||||
|
||||
if ($locallangPathAndFilename) {
|
||||
$key = $locallangPathAndFilename . ':' . $key;
|
||||
}
|
||||
|
||||
// Parse the key to extract file reference and label key separately, which is
|
||||
// required for TypoScript label overrides that are keyed by the file reference.
|
||||
$keyParts = explode(':', $key);
|
||||
if (str_starts_with($key, 'LLL:')) {
|
||||
$locallangPathAndFilename = $keyParts[1] . ':' . $keyParts[2];
|
||||
$key = $keyParts[3];
|
||||
} elseif (PathUtility::isExtensionPath($key)) {
|
||||
$locallangPathAndFilename = $keyParts[0] . ':' . $keyParts[1];
|
||||
$key = $keyParts[2];
|
||||
} elseif (count($keyParts) === 2) {
|
||||
$locallangPathAndFilename = $keyParts[0];
|
||||
$key = $keyParts[1];
|
||||
}
|
||||
|
||||
$request = $this->getRequest();
|
||||
$languageService = $this->createLanguageService($locale, $request);
|
||||
|
||||
if (!empty($locallangPathAndFilename) && $request) {
|
||||
$this->applyTypoScriptOverrides($languageService, $locallangPathAndFilename, $request);
|
||||
}
|
||||
|
||||
$fullReference = !empty($locallangPathAndFilename) ? $locallangPathAndFilename . ':' . $key : $key;
|
||||
$value = $languageService->label($fullReference, $arguments ?? []);
|
||||
return $value ?? $defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively translate values.
|
||||
*
|
||||
* @return array the modified array
|
||||
* @internal
|
||||
*/
|
||||
public function translateValuesRecursive(array $array, array $translationFiles = []): array
|
||||
{
|
||||
$result = $array;
|
||||
foreach ($result as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$result[$key] = $this->translateValuesRecursive($value, $translationFiles);
|
||||
} else {
|
||||
$translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFiles);
|
||||
|
||||
if (!empty($translationFiles)) {
|
||||
foreach ($translationFiles as $translationFile) {
|
||||
$translatedValue = $this->translate($value, null, $translationFile, null);
|
||||
if (!empty($translatedValue)) {
|
||||
$result[$key] = $translatedValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$result[$key] = $this->translate($value, null, null, null, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array the modified array
|
||||
* @internal
|
||||
*/
|
||||
public function translateToAllBackendLanguages(
|
||||
string $key,
|
||||
?array $arguments = null,
|
||||
array $translationFiles = []
|
||||
): array {
|
||||
$result = [];
|
||||
$translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFiles);
|
||||
|
||||
foreach ($this->locales->getActiveLanguages() as $language) {
|
||||
$result[$language] = $key;
|
||||
foreach ($translationFiles as $translationFile) {
|
||||
$translatedValue = $this->translate($key, $arguments, $translationFile, $language, $key);
|
||||
if ($translatedValue !== $key) {
|
||||
$result[$language] = $translatedValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function translateFinisherOption(
|
||||
FormRuntime $formRuntime,
|
||||
string $finisherIdentifier,
|
||||
string $optionKey,
|
||||
string $optionValue,
|
||||
array $renderingOptions = []
|
||||
): string {
|
||||
if (empty($finisherIdentifier)) {
|
||||
throw new \InvalidArgumentException('The argument "finisherIdentifier" is empty', 1476216059);
|
||||
}
|
||||
if (empty($optionKey)) {
|
||||
throw new \InvalidArgumentException('The argument "optionKey" is empty', 1476216060);
|
||||
}
|
||||
|
||||
if (in_array($optionKey, $renderingOptions['propertiesExcludedFromTranslation'] ?? [], true)) {
|
||||
return $optionValue;
|
||||
}
|
||||
|
||||
$finisherIdentifier = preg_replace('/Finisher$/', '', $finisherIdentifier);
|
||||
$translationFiles = $renderingOptions['translationFiles'] ?? [];
|
||||
if (empty($translationFiles)) {
|
||||
$translationFiles = $formRuntime->getRenderingOptions()['translation']['translationFiles'];
|
||||
}
|
||||
|
||||
$translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFiles);
|
||||
|
||||
if (isset($renderingOptions['translatePropertyValueIfEmpty'])) {
|
||||
$translatePropertyValueIfEmpty = (bool)$renderingOptions['translatePropertyValueIfEmpty'];
|
||||
} else {
|
||||
$translatePropertyValueIfEmpty = true;
|
||||
}
|
||||
|
||||
if (empty($optionValue) && !$translatePropertyValueIfEmpty) {
|
||||
return $optionValue;
|
||||
}
|
||||
|
||||
$locale = null;
|
||||
if (isset($renderingOptions['language'])) {
|
||||
$locale = $renderingOptions['language'];
|
||||
}
|
||||
|
||||
try {
|
||||
$arguments = ArrayUtility::getValueByPath($renderingOptions['arguments'] ?? [], $optionKey, '.');
|
||||
} catch (MissingArrayPathException $e) {
|
||||
$arguments = [];
|
||||
}
|
||||
|
||||
$originalFormIdentifier = null;
|
||||
if (isset($formRuntime->getRenderingOptions()['_originalIdentifier'])) {
|
||||
$originalFormIdentifier = $formRuntime->getRenderingOptions()['_originalIdentifier'];
|
||||
}
|
||||
|
||||
$translationKeyChain = $this->keychainBuilder->buildForFinisherOption(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$finisherIdentifier,
|
||||
$optionKey,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
|
||||
$translatedValue = $this->processTranslationChain($translationKeyChain, $locale, $arguments);
|
||||
$translatedValue = $this->isEmptyTranslatedValue($translatedValue) ? $optionValue : $translatedValue;
|
||||
|
||||
return $translatedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @internal
|
||||
*/
|
||||
public function translateFormElementValue(
|
||||
RootRenderableInterface $element,
|
||||
array $propertyParts,
|
||||
FormRuntime $formRuntime,
|
||||
Locale|string|null $locale = null,
|
||||
): array|string|null {
|
||||
if (empty($propertyParts)) {
|
||||
throw new \InvalidArgumentException('The argument "propertyParts" is empty', 1476216007);
|
||||
}
|
||||
|
||||
$propertyType = 'properties';
|
||||
$property = implode('.', $propertyParts);
|
||||
$renderingOptions = $element->getRenderingOptions();
|
||||
|
||||
if ($property === 'label') {
|
||||
$defaultValue = $element->getLabel();
|
||||
} elseif ($property === 'defaultValue' && $element instanceof FormElementInterface) {
|
||||
$defaultValue = $element->getDefaultValue();
|
||||
} else {
|
||||
if ($element instanceof FormElementInterface) {
|
||||
try {
|
||||
$defaultValue = ArrayUtility::getValueByPath($element->getProperties(), $propertyParts, '.');
|
||||
} catch (MissingArrayPathException $exception) {
|
||||
$defaultValue = null;
|
||||
}
|
||||
} else {
|
||||
$propertyType = 'renderingOptions';
|
||||
try {
|
||||
$defaultValue = ArrayUtility::getValueByPath($renderingOptions, $propertyParts, '.');
|
||||
} catch (MissingArrayPathException $exception) {
|
||||
$defaultValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($renderingOptions['translation']['translatePropertyValueIfEmpty'])) {
|
||||
$translatePropertyValueIfEmpty = $renderingOptions['translation']['translatePropertyValueIfEmpty'];
|
||||
} else {
|
||||
$translatePropertyValueIfEmpty = true;
|
||||
}
|
||||
|
||||
if ($this->isEmptyTranslatedValue($defaultValue) && !$translatePropertyValueIfEmpty) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$defaultValue = $this->isEmptyTranslatedValue($defaultValue) ? '' : $defaultValue;
|
||||
$translationFiles = $renderingOptions['translation']['translationFiles'] ?? [];
|
||||
if (empty($translationFiles)) {
|
||||
$translationFiles = $formRuntime->getRenderingOptions()['translation']['translationFiles'];
|
||||
}
|
||||
|
||||
$translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFiles);
|
||||
|
||||
if (!$locale && isset($renderingOptions['translation']['language'])) {
|
||||
$locale = $renderingOptions['translation']['language'];
|
||||
}
|
||||
|
||||
try {
|
||||
$arguments = ArrayUtility::getValueByPath($renderingOptions['translation']['arguments'] ?? [], $propertyParts, '.');
|
||||
} catch (MissingArrayPathException $e) {
|
||||
$arguments = [];
|
||||
}
|
||||
|
||||
$originalFormIdentifier = null;
|
||||
if (isset($formRuntime->getRenderingOptions()['_originalIdentifier'])) {
|
||||
$originalFormIdentifier = $formRuntime->getRenderingOptions()['_originalIdentifier'];
|
||||
}
|
||||
|
||||
$elementIsFormRuntime = $element instanceof FormRuntime;
|
||||
$elementIdentifier = $element->getIdentifier();
|
||||
$elementType = $element->getType();
|
||||
|
||||
if ($property === 'options' && is_array($defaultValue)) {
|
||||
foreach ($defaultValue as $optionValue => &$optionLabel) {
|
||||
if ($elementIsFormRuntime) {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForFormRuntimeOption(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$property,
|
||||
$optionValue,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
} else {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForElementOption(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$property,
|
||||
$optionValue,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
$translatedValue = $this->processTranslationChain($translationKeyChain, $locale, $arguments);
|
||||
$optionLabel = $this->isEmptyTranslatedValue($translatedValue) ? $optionLabel : $translatedValue;
|
||||
}
|
||||
$translatedValue = $defaultValue;
|
||||
} elseif ($property === 'fluidAdditionalAttributes') {
|
||||
// "fluidAdditionalAttributes" is a globally available property and is used across all built-in
|
||||
// form templates. However, it's not necessarily defined in the form configuration. This can lead to
|
||||
// an empty string as default value, which is invalid. This check makes sure that an array is returned
|
||||
// even if the property is not defined.
|
||||
if (!is_array($defaultValue)) {
|
||||
$defaultValue = [];
|
||||
}
|
||||
foreach ($defaultValue as $propertyName => &$propertyValue) {
|
||||
if ($elementIsFormRuntime) {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForFormRuntimeProperty(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$propertyName,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
} else {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForElementProperty(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$propertyName,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
$translatedValue = $this->processTranslationChain($translationKeyChain, $locale, $arguments);
|
||||
$propertyValue = $this->isEmptyTranslatedValue($translatedValue) ? $propertyValue : $translatedValue;
|
||||
}
|
||||
$translatedValue = $defaultValue;
|
||||
} else {
|
||||
if ($elementIsFormRuntime) {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForFormRuntimeProperty(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$property,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
} else {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForElementProperty(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$elementIdentifier,
|
||||
$elementType,
|
||||
$propertyType,
|
||||
$property,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
$translatedValue = $this->processTranslationChain($translationKeyChain, $locale, $arguments);
|
||||
$translatedValue = $this->isEmptyTranslatedValue($translatedValue) ? $defaultValue : $translatedValue;
|
||||
}
|
||||
|
||||
return $translatedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @internal
|
||||
*/
|
||||
public function translateFormElementError(
|
||||
RootRenderableInterface $element,
|
||||
int $code,
|
||||
array $arguments,
|
||||
string $defaultValue,
|
||||
FormRuntime $formRuntime
|
||||
): string {
|
||||
if (empty($code)) {
|
||||
throw new \InvalidArgumentException('The argument "code" is empty', 1489272978);
|
||||
}
|
||||
|
||||
if ($element instanceof FormElementInterface) {
|
||||
$validationErrors = $element->getProperties()['validationErrorMessages'] ?? null;
|
||||
if (is_array($validationErrors)) {
|
||||
foreach ($validationErrors as $validationError) {
|
||||
if ((int)$validationError['code'] === $code) {
|
||||
return sprintf($validationError['message'], ...$arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$renderingOptions = $element->getRenderingOptions();
|
||||
$translationFiles = $renderingOptions['translation']['translationFiles'] ?? [];
|
||||
if (empty($translationFiles)) {
|
||||
$translationFiles = $formRuntime->getRenderingOptions()['translation']['translationFiles'];
|
||||
}
|
||||
|
||||
$translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFiles);
|
||||
|
||||
$locale = null;
|
||||
if (isset($renderingOptions['language'])) {
|
||||
$locale = $renderingOptions['language'];
|
||||
}
|
||||
|
||||
$originalFormIdentifier = null;
|
||||
if (isset($formRuntime->getRenderingOptions()['_originalIdentifier'])) {
|
||||
$originalFormIdentifier = $formRuntime->getRenderingOptions()['_originalIdentifier'];
|
||||
}
|
||||
|
||||
if ($element instanceof FormRuntime) {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForFormRuntimeValidationError(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$element->getIdentifier(),
|
||||
$code,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
} else {
|
||||
$translationKeyChain = $this->keychainBuilder->buildForValidationError(
|
||||
$translationFiles,
|
||||
$formRuntime->getIdentifier(),
|
||||
$element->getIdentifier(),
|
||||
$code,
|
||||
$originalFormIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
$translatedValue = $this->processTranslationChain($translationKeyChain, $locale, $arguments);
|
||||
$translatedValue = $this->isEmptyTranslatedValue($translatedValue) ? $defaultValue : $translatedValue;
|
||||
return $translatedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|\Stringable|null
|
||||
*/
|
||||
protected function processTranslationChain(
|
||||
array $translationKeyChain,
|
||||
Locale|string|null $locale = null,
|
||||
?array $arguments = null
|
||||
) {
|
||||
$request = $this->getRequest();
|
||||
$languageService = $this->createLanguageService($locale, $request);
|
||||
$appliedOverridesForFiles = [];
|
||||
|
||||
foreach ($translationKeyChain as $translationKey) {
|
||||
if ($request) {
|
||||
$fileRef = $this->extractFileReferenceFromKey($translationKey);
|
||||
if ($fileRef !== '' && !isset($appliedOverridesForFiles[$fileRef])) {
|
||||
$this->applyTypoScriptOverrides($languageService, $fileRef, $request);
|
||||
$appliedOverridesForFiles[$fileRef] = true;
|
||||
}
|
||||
}
|
||||
$translatedValue = $languageService->label($translationKey, $arguments ?? []);
|
||||
if (!$this->isEmptyTranslatedValue($translatedValue)) {
|
||||
return $translatedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the array contains numerical keys only, sort it in descending order
|
||||
*/
|
||||
protected function sortArrayWithIntegerKeysDescending(array $array): array
|
||||
{
|
||||
if (count(array_filter(array_keys($array), 'is_string')) === 0) {
|
||||
krsort($array);
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if given translated value is considered "empty".
|
||||
*
|
||||
* A translated value is considered "empty" if it's either NULL or
|
||||
* an empty string. This helper method exists to perform a less strict
|
||||
* check than the native {@see empty()} function, because it is too
|
||||
* strict in terms of supported translated values. For example, the
|
||||
* value "0" is valid, whereas {@see empty()} would handle it as "empty"
|
||||
* and therefore invalid.
|
||||
*/
|
||||
protected function isEmptyTranslatedValue(mixed $translatedValue): bool
|
||||
{
|
||||
if ($translatedValue === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_string($translatedValue)) {
|
||||
return trim($translatedValue) === '';
|
||||
}
|
||||
|
||||
if (is_bool($translatedValue)) {
|
||||
return !$translatedValue;
|
||||
}
|
||||
|
||||
if (is_array($translatedValue)) {
|
||||
return $translatedValue === [];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a LanguageService for the given locale or the locale from the current request.
|
||||
* Returns a LanguageService (which implements TranslatorInterface) rather than the interface
|
||||
* directly, since TypoScript label overrides require LanguageService-specific methods.
|
||||
*/
|
||||
private function createLanguageService(Locale|string|null $locale, ?ServerRequestInterface $request): LanguageService
|
||||
{
|
||||
if ($locale) {
|
||||
return $this->languageServiceFactory->create($locale);
|
||||
}
|
||||
return $this->languageServiceFactory->create($this->locales->createLocaleFromRequest($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies TypoScript label overrides (plugin.tx_form._LOCAL_LANG) to the given language
|
||||
* service for the specified file reference, if a frontend TypoScript setup is present.
|
||||
*/
|
||||
private function applyTypoScriptOverrides(LanguageService $languageService, string $fileRef, ServerRequestInterface $request): void
|
||||
{
|
||||
$typoScript = $request->getAttribute('frontend.typoscript');
|
||||
if ($typoScript instanceof FrontendTypoScript && $typoScript->hasSetup()) {
|
||||
$overrideLabels = $languageService->loadTypoScriptLabelsFromExtension('form', $typoScript);
|
||||
if ($overrideLabels !== []) {
|
||||
$languageService->overrideLabels($fileRef, $overrideLabels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the file reference (domain) part from a full translation key reference such as
|
||||
* 'EXT:my_ext/path/file.xlf:my.key' or 'LLL:EXT:my_ext/path/file.xlf:my.key'.
|
||||
* Returns an empty string when no file reference can be determined.
|
||||
*/
|
||||
private function extractFileReferenceFromKey(string $key): string
|
||||
{
|
||||
$strippedKey = str_starts_with($key, 'LLL:') ? substr($key, 4) : $key;
|
||||
$keyParts = explode(':', $strippedKey);
|
||||
if (PathUtility::isExtensionPath($strippedKey)) {
|
||||
// e.g. EXT:my_ext/path/file.xlf -> keyParts[0]='EXT', keyParts[1]='my_ext/path/file.xlf'
|
||||
return $keyParts[0] . ':' . ($keyParts[1] ?? '');
|
||||
}
|
||||
// Semantic domain, e.g. 'my.domain:my.key' -> 'my.domain'
|
||||
return $keyParts[0];
|
||||
}
|
||||
|
||||
private function getRequest(): ?ServerRequestInterface
|
||||
{
|
||||
return $GLOBALS['TYPO3_REQUEST'] ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user