TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:24 +02:00
commit aad9daaefd
1506 changed files with 94005 additions and 0 deletions
@@ -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;
}
}
+219
View File
@@ -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);
}
}
+40
View File
@@ -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;
}
}
+330
View File
@@ -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'];
}
}
+583
View File
@@ -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;
}
}