TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
<?php
/*
* 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\Core\DataHandling;
/**
* Interface for hook in \TYPO3\CMS\Core\DataHandling\DataHandler::checkModifyAccessList
*/
interface DataHandlerCheckModifyAccessListHookInterface
{
/**
* Hook that determines whether a user has access to modify a table.
*
* @param bool $accessAllowed Whether the user has access to modify a table
* @param string $table The name of the table to be modified
* @param DataHandler $parent The calling parent object
*/
public function checkModifyAccessList(&$accessAllowed, $table, DataHandler $parent);
}
@@ -0,0 +1,30 @@
<?php
/*
* 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\Core\DataHandling;
/**
* Interface for classes which hook into DataHandler and do additional processing
* after the upload of a file.
*/
interface DataHandlerProcessUploadHookInterface
{
/**
* Post-process a file upload.
*
* @param string $filename The uploaded file
*/
public function processUpload_postProcessAction(&$filename, DataHandler $parentObject);
}
@@ -0,0 +1,85 @@
<?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\Core\DataHandling\Event;
/**
* Event fired so listeners can intercept add elements when checking links within the SoftRef parser
*/
final class AppendLinkHandlerElementsEvent
{
private bool $isResolved = false;
public function __construct(
private array $linkParts,
private string $content,
private array $elements,
private readonly int $idx,
private readonly string $tokenId
) {}
public function getLinkParts(): array
{
return $this->linkParts;
}
public function getContent(): string
{
return $this->content;
}
public function getElements(): array
{
return $this->elements;
}
public function getIdx(): int
{
return $this->idx;
}
public function getTokenId(): string
{
return $this->tokenId;
}
public function setLinkParts(array $linkParts): void
{
$this->linkParts = $linkParts;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function setElements(array $elements): void
{
$this->elements = $elements;
}
public function addElements(array $elements)
{
$this->elements = array_replace_recursive($this->elements, $elements);
$this->isResolved = true;
}
public function isResolved(): bool
{
return $this->isResolved;
}
}
@@ -0,0 +1,61 @@
<?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\Core\DataHandling\Event;
/**
* Event is dispatched before non-copyable fields are removed from a record. Listener can modify the list of
* non-copyable fields (e.g. add custom fields, which should be ignored by DataHandler during copy/localization).
*
* @internal This event should only be used by experienced developers who understand the implications of
* DataHandler's field processing. Wrong usage will lead to data inconsistencies. The event is
* therefore declared as "use at your own risk, may change without notice".
*/
final class BeforeRemoveNonCopyableFieldsEvent
{
public function __construct(
private readonly string $table,
private readonly array $row,
private readonly string $callingOperation,
private array $nonCopyableFields
) {}
public function getTable(): string
{
return $this->table;
}
public function getCallingOperation(): string
{
return $this->callingOperation;
}
public function getRow(): array
{
return $this->row;
}
public function getNonCopyableFields(): array
{
return $this->nonCopyableFields;
}
public function setNonCopyableFields(array $nonCopyableFields): void
{
$this->nonCopyableFields = $nonCopyableFields;
}
}
@@ -0,0 +1,52 @@
<?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\Core\DataHandling\Event;
use Psr\EventDispatcher\StoppableEventInterface;
/**
* Event to intercept if a certain table should be excluded from the Reference Index.
* There is no need to add tables without a definition in $GLOBALS['TCA'] since
* ReferenceIndex only handles those.
*/
final class IsTableExcludedFromReferenceIndexEvent implements StoppableEventInterface
{
private bool $isExcluded = false;
public function __construct(private readonly string $table) {}
public function getTable(): string
{
return $this->table;
}
public function markAsExcluded()
{
$this->isExcluded = true;
}
public function isTableExcluded(): bool
{
return $this->isExcluded;
}
public function isPropagationStopped(): bool
{
return $this->isTableExcluded();
}
}
@@ -0,0 +1,249 @@
<?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\Core\DataHandling\History;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Used to save any history to a record
*
* @internal should only be used by the TYPO3 Core
*/
class RecordHistoryStore
{
public const ACTION_ADD = 1;
public const ACTION_MODIFY = 2;
public const ACTION_MOVE = 3;
public const ACTION_DELETE = 4;
public const ACTION_UNDELETE = 5;
public const ACTION_STAGECHANGE = 6;
public const ACTION_PUBLISH = 7;
public const USER_BACKEND = 'BE';
public const USER_FRONTEND = 'FE';
public const USER_ANONYMOUS = '';
/**
* @var int|null
*/
protected $userId;
/**
* @var string
*/
protected $userType;
/**
* @var int|null
*/
protected $originalUserId;
/**
* @var int|null
*/
protected $tstamp;
/**
* @var int
*/
protected $workspaceId;
public function __construct(string $userType = self::USER_BACKEND, ?int $userId = null, ?int $originalUserId = null, ?int $tstamp = null, int $workspaceId = 0)
{
$this->userType = $userType;
$this->userId = $userId;
$this->originalUserId = $originalUserId;
$this->tstamp = $tstamp ?: $GLOBALS['EXEC_TIME'];
$this->workspaceId = $workspaceId;
}
public function addRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
{
if ($this->workspaceId) {
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
}
$data = [
'actiontype' => self::ACTION_ADD,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
public function modifyRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
{
if ($this->workspaceId) {
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
}
$data = [
'actiontype' => self::ACTION_MODIFY,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
/**
* @param CorrelationId|null $correlationId
*/
public function deleteRecord(string $table, int $uid, ?CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_DELETE,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
/**
* @param CorrelationId|null $correlationId
*/
public function undeleteRecord(string $table, int $uid, ?CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_UNDELETE,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
/**
* @param CorrelationId|null $correlationId
*/
public function moveRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
{
if ($this->workspaceId) {
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
}
$data = [
'actiontype' => self::ACTION_MOVE,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
public function changeStageForRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_STAGECHANGE,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
public function publishRecord(string $table, int $uid, int $versionedId, array $payload, ?CorrelationId $correlationId = null): string
{
$this->migrateWorkspaceHistory($table, $versionedId, $uid);
$data = [
'actiontype' => self::ACTION_PUBLISH,
'usertype' => $this->userType,
'userid' => $this->userId,
'originaluserid' => $this->originalUserId,
'tablename' => $table,
'recuid' => $uid,
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => 0, // Published to live workspace
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId();
}
protected function migrateWorkspaceHistory(string $table, int $versionedId, int $liveUid): void
{
$connection = $this->getDatabaseConnection();
// Update all history entries from workspace record to point to live record
$connection->update(
'sys_history',
[
'recuid' => $liveUid,
],
['tablename' => $table, 'recuid' => $versionedId]
);
}
protected function createCorrelationId(string $tableName, int $uid, ?CorrelationId $correlationId): CorrelationId
{
if ($correlationId !== null && $correlationId->getSubject() !== null) {
return $correlationId;
}
$subject = md5($tableName . ':' . $uid);
return $correlationId !== null ? $correlationId->withSubject($subject) : CorrelationId::forSubject($subject);
}
protected function getDatabaseConnection(): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('sys_history');
}
}
@@ -0,0 +1,198 @@
<?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\Core\DataHandling;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Schema\Struct\SelectItemCollection;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Provides services around item processing
*/
#[Autoconfigure(public: true)]
readonly class ItemProcessingService
{
public function __construct(
protected SiteFinder $siteFinder,
protected TcaSchemaFactory $tcaSchemaFactory,
protected FlashMessageService $flashMessageService,
) {}
public function processItems(SelectItemCollection $items, ItemsProcessorContext $context): SelectItemCollection
{
$pageId = (int)($context->table === 'pages' ? ($context->row['uid'] ?? $context->realPid) : ($context->row['pid'] ?? $context->realPid));
$fieldTSconfig = $context->fieldTSconfig;
if ($fieldTSconfig === []) {
$TSconfig = BackendUtility::getPagesTSconfig($pageId);
$fieldTSconfig = $TSconfig['TCEFORM.'][$context->table . '.'][$context->field . '.'] ?? [];
}
$site = $context->site;
// Legacy itemsProcFunc support - convert to array for backwards compatibility
$itemsArray = $items->toArray();
$processorParameters = [
// Function manipulates $items directly and return nothing
'items' => &$itemsArray,
'config' => $context->fieldConfiguration,
'table' => $context->table,
'row' => $context->row,
'field' => $context->field,
'effectivePid' => $context->realPid,
'site' => $site,
];
$processorParameters = array_merge($processorParameters, $context->additionalParameters);
try {
// @todo: deprecate when the time is right
if (!empty($context->fieldConfiguration['itemsProcFunc'])) {
$processorParameters['TSconfig'] = $fieldTSconfig['itemsProcFunc.'] ?? null;
GeneralUtility::callUserFunction($context->fieldConfiguration['itemsProcFunc'], $processorParameters, $this);
// Recreate collection from potentially modified array
$items = SelectItemCollection::createFromArray($itemsArray, $context->fieldConfiguration['type']);
}
// "itemsProcessors" is the more modern version of "itemsProcFunc", which will eventually be deprecated
$itemsProcessors = $context->fieldConfiguration['itemsProcessors'] ?? [];
ksort($itemsProcessors);
foreach ($itemsProcessors as $key => $itemsProcessorConfiguration) {
$tsConfig = $fieldTSconfig['itemsProcessors.'][$key . '.'] ?? [];
if (empty($itemsProcessorConfiguration['class'])) {
throw new ItemsProcessorExecutionFailedException(
$itemsArray,
sprintf(
'Missing class for itemsProcessors %d, field %s, table %s',
$key,
$context->field,
$context->table
),
1761814167
);
}
$itemsProcessorObject = GeneralUtility::makeInstance($itemsProcessorConfiguration['class']);
if (!$itemsProcessorObject instanceof ItemsProcessorInterface) {
throw new ItemsProcessorExecutionFailedException(
$itemsArray,
sprintf(
'Class %s must implement %s',
$itemsProcessorConfiguration['class'],
ItemsProcessorInterface::class
),
1761753898
);
}
$processorContext = new ItemsProcessorContext(
table: $context->table,
field: $context->field,
row: $context->row,
fieldConfiguration: $context->fieldConfiguration,
processorParameters: $itemsProcessorConfiguration['parameters'] ?? [],
realPid: $context->realPid,
site: $site,
fieldTSconfig: $tsConfig,
additionalParameters: $context->additionalParameters
);
$items = $itemsProcessorObject->processItems($items, $processorContext);
}
} catch (\Exception $exception) {
// Catch anything here!
throw new ItemsProcessorExecutionFailedException($itemsArray, $exception->getMessage(), 1761588907, $exception);
}
return $items;
}
/**
* Executes an itemsProcFunc or itemsProcessors if defined in TCA and returns the combined result
* (predefined + processed items)
*
* @param string $table
* @param int $realPid Record pid. This is the pid of the record.
* @param string $field
* @param array $row
* @param array $tcaConfig The TCA configuration of $field
* @param array $selectedItems The items already defined in the TCA configuration
* @return array The processed items (including the predefined items)
* @throws \TYPO3\CMS\Core\Exception
* @throws \TYPO3\CMS\Core\Schema\Exception\UndefinedFieldException
* @throws \TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException
*/
public function getProcessingItems($table, $realPid, $field, $row, $tcaConfig, $selectedItems)
{
try {
$itemsCollection = SelectItemCollection::createFromArray($selectedItems, $tcaConfig['type']);
$context = new ItemsProcessorContext(
table: $table,
field: $field,
row: $row,
fieldConfiguration: $tcaConfig,
processorParameters: [],
realPid: $realPid,
site: $this->resolveSite((int)($table === 'pages' ? ($row['uid'] ?? $realPid) : ($row['pid'] ?? $realPid)))
);
$selectedItems = $this->processItems($itemsCollection, $context)->toArray();
} catch (ItemsProcessorExecutionFailedException $exception) {
$fieldLabel = '';
if ($this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
if ($schema->hasField($field)) {
$fieldLabel = $this->getLanguageService()->sL($schema->getField($field)->getLabel());
}
}
if (!$fieldLabel) {
$fieldLabel = $field;
}
$message = sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.items_proc_func_error'),
$fieldLabel,
$exception->getMessage()
);
$flashMessage = new FlashMessage(
$message,
'',
ContextualFeedbackSeverity::ERROR,
true
);
$defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$defaultFlashMessageQueue->enqueue($flashMessage);
}
return $selectedItems;
}
public function resolveSite(int $pageId): SiteInterface
{
try {
return $this->siteFinder->getSiteByPageId($pageId);
} catch (SiteNotFoundException $e) {
return new NullSite();
}
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,39 @@
<?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\Core\DataHandling;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
/**
* Context object for ItemsProcessor implementations.
* Encapsulates all parameters needed for item processing to avoid parameter bloat.
*/
final readonly class ItemsProcessorContext
{
public function __construct(
public string $table,
public string $field,
public array $row,
public array $fieldConfiguration,
public array $processorParameters,
public int $realPid,
public SiteInterface $site,
public array $fieldTSconfig = [],
public array $additionalParameters = [],
) {}
}
@@ -0,0 +1,26 @@
<?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\Core\DataHandling;
class ItemsProcessorExecutionFailedException extends \RuntimeException
{
public function __construct(public readonly array $items, string $message, int $code, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,28 @@
<?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\Core\DataHandling;
use TYPO3\CMS\Core\Schema\Struct\SelectItemCollection;
interface ItemsProcessorInterface
{
public function processItems(
SelectItemCollection $items,
ItemsProcessorContext $context,
): SelectItemCollection;
}
@@ -0,0 +1,350 @@
<?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\Core\DataHandling\Localization;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Entity for data-map item.
*
* @internal
*/
class DataMapItem
{
public const TYPE_PARENT = 'parent';
public const TYPE_DIRECT_CHILD = 'directChild';
public const TYPE_GRAND_CHILD = 'grandChild';
public const SCOPE_PARENT = State::STATE_PARENT;
public const SCOPE_SOURCE = State::STATE_SOURCE;
public const SCOPE_EXCLUDE = 'exclude';
protected string $tableName;
protected string|int $id;
protected int $liveId;
protected array $suggestedValues;
protected array $persistedValues;
protected array $configurationFieldNames;
protected bool $new;
protected ?string $type = null;
protected ?State $state = null;
protected string|int $language = 0;
protected string|int $parent = '';
protected string|int|null $source = null;
/** @var DataMapItem[][] */
protected array $dependencies = [];
/**
* Builds a data-map item. In addition to the constructor, the values
* for language, parent and source record pointers are assigned as well.
*/
public static function build(
string $tableName,
string|int $id,
array $suggestedValues,
array $persistedValues,
array $configurationFieldNames
): static {
$item = GeneralUtility::makeInstance(
static::class,
$tableName,
$id,
$suggestedValues,
$persistedValues,
$configurationFieldNames
);
// assign language specific settings of item
$item->language = (int)($suggestedValues[$item->getLanguageFieldName()] ?? $persistedValues[$item->getLanguageFieldName()] ?? 0);
$item->setParent($suggestedValues[$item->getParentFieldName()] ?? $persistedValues[$item->getParentFieldName()] ?? '');
if ($item->getSourceFieldName() !== null) {
$item->setSource($suggestedValues[$item->getSourceFieldName()] ?? $persistedValues[$item->getSourceFieldName()] ?? '');
}
// assign live-id of item if available
$versionSourceFieldName = $item->getVersionSourceFieldName();
if ($versionSourceFieldName !== null && !empty($persistedValues[$versionSourceFieldName])) {
$item->liveId = $persistedValues[$versionSourceFieldName];
}
return $item;
}
public function __construct(
string $tableName,
string|int $id,
array $suggestedValues,
array $persistedValues,
array $configurationFieldNames
) {
$this->tableName = $tableName;
$this->id = $id;
$this->suggestedValues = $suggestedValues;
$this->persistedValues = $persistedValues;
$this->configurationFieldNames = $configurationFieldNames;
$this->new = !MathUtility::canBeInterpretedAsInteger($id);
}
/**
* Gets the current table name of this data-map item.
*/
public function getTableName(): string
{
return $this->tableName;
}
/**
* Gets the id of this data-map item.
*/
public function getId(): string|int
{
return $this->id;
}
public function getLiveId(): int|string
{
return $this->liveId ?? $this->id;
}
/**
* Gets the suggested values that were initially
* submitted as the whole data-map to the DataHandler.
*/
public function getSuggestedValues(): array
{
return $this->suggestedValues;
}
/**
* Gets the persisted values that represent the persisted state
* of the record this data-map item is a surrogate for - does only
* contain relevant field values.
*/
public function getPersistedValues(): array
{
return $this->persistedValues;
}
public function getConfigurationFieldNames(): array
{
return $this->configurationFieldNames;
}
public function getLanguageFieldName(): string
{
return $this->configurationFieldNames['language'];
}
public function getParentFieldName(): string
{
return $this->configurationFieldNames['parent'];
}
public function getSourceFieldName(): ?string
{
return $this->configurationFieldNames['source'] ?? null;
}
protected function getVersionSourceFieldName(): ?string
{
return $this->configurationFieldNames['versionSource'] ?? null;
}
public function isNew(): bool
{
return $this->new;
}
public function getType(): string
{
if ($this->type === null) {
// implicit: default language, it's a parent
if ($this->language === 0) {
$this->type = static::TYPE_PARENT;
} elseif (
// implicit: having source value different to parent value, it's a 2nd or higher level translation
$this->source !== 0
&& $this->source !== null
&& $this->source !== $this->parent
) {
$this->type = static::TYPE_GRAND_CHILD;
} else {
// implicit: otherwise, it's a 1st level translation
$this->type = static::TYPE_DIRECT_CHILD;
}
}
return $this->type;
}
public function isParentType(): bool
{
return $this->getType() === static::TYPE_PARENT;
}
public function isDirectChildType(): bool
{
return $this->getType() === static::TYPE_DIRECT_CHILD;
}
public function isGrandChildType(): bool
{
return $this->getType() === static::TYPE_GRAND_CHILD;
}
public function getState(): ?State
{
if ($this->state === null && !$this->isParentType()) {
$this->state = $this->buildState();
}
return $this->state;
}
public function getLanguage(): string|int
{
return $this->language;
}
public function setLanguage(string|int $language): void
{
$this->language = $language;
}
public function getParent(): string|int
{
return $this->parent;
}
public function setParent(string|int $parent): void
{
$this->parent = $this->extractId($parent);
}
public function getSource(): string|int|null
{
return $this->source;
}
public function setSource(string|int $source): void
{
$this->source = $this->extractId($source);
}
public function getIdForScope(string $scope): string|int
{
if (
$scope === static::SCOPE_PARENT
|| $scope === static::SCOPE_EXCLUDE
) {
return $this->getParent();
}
if ($scope === static::SCOPE_SOURCE) {
// $source is guaranteed to be non-null when SCOPE_SOURCE is applicable:
// getApplicableScopes() only includes it when getSourceFieldName() !== null,
// which means setSource() was called during build().
return $this->source;
}
throw new \RuntimeException('Invalid scope', 1486325248);
}
/**
* @return DataMapItem[][]
*/
public function getDependencies(): array
{
return $this->dependencies;
}
/**
* @param DataMapItem[][] $dependencies
*/
public function setDependencies(array $dependencies): void
{
$this->dependencies = $dependencies;
}
/**
* @return DataMapItem[]
*/
public function findDependencies(string $scope): array
{
return $this->dependencies[$scope] ?? [];
}
/**
* @return string[]
*/
public function getApplicableScopes(): array
{
$scopes = [];
if (!empty($this->getSourceFieldName())) {
$scopes[] = static::SCOPE_SOURCE;
}
$scopes[] = static::SCOPE_PARENT;
$scopes[] = static::SCOPE_EXCLUDE;
return $scopes;
}
/**
* Extracts real id from provided id-value, which can either be a real
* integer value, a 'NEW...' id, or a combined identifier 'tt_content_13'.
*/
protected function extractId(int|string $idValue): int|string
{
if (MathUtility::canBeInterpretedAsInteger($idValue)) {
return $idValue;
}
$idValue = (string)$idValue;
if (str_starts_with($idValue, 'NEW')) {
return $idValue;
}
// @todo Handle if $tableName does not match $this->tableName
$id = BackendUtility::splitTable_Uid($idValue)[1];
return $id;
}
protected function buildState(): ?State
{
// build from persisted states
if (!$this->isNew()) {
$state = State::fromJSON(
$this->tableName,
$this->persistedValues['l10n_state'] ?? null
);
} elseif (is_string($this->suggestedValues['l10n_state'] ?? null)) {
// use provided states for a new and copied element
$state = State::fromJSON(
$this->tableName,
$this->suggestedValues['l10n_state']
);
} else {
// provide the default states
$state = State::create($this->tableName);
}
// switch "custom" to "source" state for 2nd level translations
if ($this->isNew() && $this->isGrandChildType()) {
$state->updateStates(State::STATE_CUSTOM, State::STATE_SOURCE);
}
// apply any provided updates to the states
if (is_array($this->suggestedValues['l10n_state'] ?? null)) {
$state->update($this->suggestedValues['l10n_state']);
}
return $state;
}
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
<?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\Core\DataHandling\Localization;
use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Value object for l10n_state field value.
*
* @internal
*/
class State
{
public const STATE_CUSTOM = 'custom';
public const STATE_PARENT = 'parent';
public const STATE_SOURCE = 'source';
protected const VALID_STATES = [
self::STATE_CUSTOM,
self::STATE_SOURCE,
self::STATE_PARENT,
];
protected string $tableName;
protected array $states;
protected array $originalStates;
public static function create(string $tableName): ?State
{
if (!static::isApplicable($tableName)) {
return null;
}
return GeneralUtility::makeInstance(
static::class,
$tableName
);
}
public static function fromJSON(string $tableName, ?string $json = null): ?State
{
if (!static::isApplicable($tableName)) {
return null;
}
$states = json_decode($json ?? '', true);
return GeneralUtility::makeInstance(
static::class,
$tableName,
$states ?? []
);
}
public static function isApplicable(string $tableName): bool
{
$schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
return $schemaFactory->has($tableName)
&& $schemaFactory->get($tableName)->isLanguageAware()
&& count(static::getFieldNames($tableName)) > 0;
}
/**
* @return string[]
*/
public static function getFieldNames(string $tableName): array
{
$schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
if (!$schemaFactory->has($tableName)) {
return [];
}
return array_map(
static fn(FieldTypeInterface $field) => $field->getName(),
iterator_to_array(
$schemaFactory->get($tableName)->getFields(
static fn(FieldTypeInterface $field): bool => !empty($field->getConfiguration()['behaviour']['allowLanguageSynchronization'])
)
)
);
}
public function __construct(string $tableName, array $states = [])
{
$this->tableName = $tableName;
$this->states = $states;
$this->originalStates = $states;
$this->states = $this->enrich(
$this->sanitize($states)
);
}
public function update(array $states): void
{
$this->states = array_merge(
$this->states,
$this->sanitize($states)
);
}
/**
* Updates field names having a particular state to a target state.
*/
public function updateStates(string $currentState, string $targetState): void
{
$states = [];
foreach ($this->filterFieldNames($currentState) as $fieldName) {
$states[$fieldName] = $targetState;
}
if (!empty($states)) {
$this->update($states);
}
}
public function export(): string|false|null
{
if (empty($this->states)) {
return null;
}
return json_encode($this->states);
}
public function toArray(): array
{
return $this->states;
}
/**
* @return string[]
*/
public function getModifiedFieldNames(): array
{
return array_keys(
array_diff_assoc(
$this->states,
$this->originalStates
)
);
}
public function isModified(): bool
{
return !empty($this->getModifiedFieldNames());
}
public function isUndefined(string $fieldName): bool
{
return !isset($this->states[$fieldName]);
}
public function isCustomState(string $fieldName): bool
{
return ($this->states[$fieldName] ?? null) === static::STATE_CUSTOM;
}
public function isParentState(string $fieldName): bool
{
return ($this->states[$fieldName] ?? null) === static::STATE_PARENT;
}
public function isSourceState(string $fieldName): bool
{
return ($this->states[$fieldName] ?? null) === static::STATE_SOURCE;
}
public function getState(string $fieldName): ?string
{
return $this->states[$fieldName] ?? null;
}
/**
* Filters field names having a desired state.
*
* @return string[]
*/
public function filterFieldNames(string $desiredState, bool $modified = false): array
{
if (!$modified) {
$fieldNames = array_keys($this->states);
} else {
$fieldNames = $this->getModifiedFieldNames();
}
return array_filter(
$fieldNames,
function (string $fieldName) use ($desiredState): bool {
return $this->states[$fieldName] === $desiredState;
}
);
}
/**
* Filter out field names that don't exist in TCA.
*
* @return string[]
*/
protected function sanitize(array $states): array
{
$fieldNames = static::getFieldNames($this->tableName);
return array_intersect_key(
$states,
array_combine($fieldNames, $fieldNames) ?: []
);
}
/**
* Add missing states for field names.
*/
protected function enrich(array $states): array
{
foreach (static::getFieldNames($this->tableName) as $fieldName) {
$isValid = in_array(
$states[$fieldName] ?? null,
static::VALID_STATES,
true
);
if ($isValid) {
continue;
}
$states[$fieldName] = static::STATE_PARENT;
}
return $states;
}
}
@@ -0,0 +1,112 @@
<?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\Core\DataHandling\Localization;
use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Version to live id map.
*
* This is on a per-workspace and per-table setup, so it is recommended
* to use a runtime cache to hold these instances.
*
* @internal
*/
class VersionToLiveIdMap
{
protected string $tableName;
protected int $workspaceId;
/**
* @var int[]
*/
protected array $map = [];
public function __construct(string $tableName, int $workspaceId)
{
$this->tableName = $tableName;
$this->workspaceId = $workspaceId;
}
/**
* @param int[] $ids
*/
public function update(array $ids): self
{
$ids = array_map(intval(...), $ids);
$candidateIds = array_diff(
$ids,
array_keys($this->map),
array_values($this->map)
);
if (empty($candidateIds)) {
return $this;
}
$candidateIdMap = array_combine($candidateIds, $candidateIds);
$schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
if (
!$schemaFactory->has($this->tableName)
|| $this->workspaceId === 0
|| !$schemaFactory->get($this->tableName)->isWorkspaceAware()
) {
$this->map += $candidateIdMap;
return $this;
}
$plainDataResolver = GeneralUtility::makeInstance(
PlainDataResolver::class,
$this->tableName,
[]
);
$plainDataResolver->setWorkspaceId($this->workspaceId);
$plainDataResolver->setKeepLiveIds(true);
$versionLiveIdMap = $plainDataResolver->applyLiveIds($candidateIdMap);
$this->map += $versionLiveIdMap;
return $this;
}
public function getVersionId(int $liveId): int
{
$versionId = array_search($liveId, $this->map, true);
return $versionId ?: $liveId;
}
public function getLiveId(int $versionId): string|int
{
return $this->map[$versionId] ?? $versionId;
}
/**
* @param int[] $versionIds
* @return int[]
*/
public function getLiveIds(array $versionIds): array
{
return array_map(
function (int $versionId) {
return $this->getLiveId($versionId);
},
$versionIds
);
}
}
@@ -0,0 +1,149 @@
<?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\Core\DataHandling\Model;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* CorrelationId representation
*
* @todo Check internal state during v10 development
* @internal
*/
class CorrelationId implements \JsonSerializable
{
protected const DEFAULT_VERSION = 1;
protected const PATTERN_V1 = '#^(?P<flags>[[:xdigit:]]{4})\$(?:(?P<scope>[[:alnum:]_-]+):)?(?P<subject>[[:alnum:]_-]+)(?P<aspects>(?:\/[[:alnum:]._-]+)*)$#';
protected int $version = self::DEFAULT_VERSION;
protected ?string $scope = null;
protected int $capabilities = 0;
protected ?string $subject = null;
/**
* @var string[]
*/
protected array $aspects = [];
public static function forScope(string $scope): self
{
$target = static::create();
$target->scope = $scope;
return $target;
}
public static function forSubject(string $subject, string ...$aspects): self
{
return static::create()
->withSubject($subject)
->withAspects(...$aspects);
}
public static function fromString(string $correlationId): self
{
if (!preg_match(self::PATTERN_V1, $correlationId, $matches, PREG_UNMATCHED_AS_NULL)) {
throw new \InvalidArgumentException('Unknown format', 1569620858);
}
$flags = hexdec($matches['flags']);
$aspects = $matches['aspects'] === '' ? [] : explode('/', ltrim($matches['aspects'], '/'));
$target = static::create()
->withSubject($matches['subject'])
->withAspects(...$aspects);
$target->scope = $matches['scope'] ?? null;
$target->version = $flags >> 10;
$target->capabilities = $flags & ((1 << 10) - 1);
return $target;
}
protected static function create(): self
{
return GeneralUtility::makeInstance(static::class);
}
public function __toString(): string
{
if ($this->subject === null) {
throw new \LogicException('Cannot serialize for empty subject', 1569668681);
}
return $this->serialize();
}
public function jsonSerialize(): string
{
return (string)$this;
}
public function withSubject(string $subject): self
{
if ($this->subject === $subject) {
return $this;
}
$target = clone $this;
$target->subject = $subject;
return $target;
}
public function withAspects(string ...$aspects): self
{
if ($this->aspects === $aspects) {
return $this;
}
$target = clone $this;
$target->aspects = $aspects;
return $target;
}
public function getScope(): ?string
{
return $this->scope;
}
public function getSubject(): ?string
{
return $this->subject;
}
/**
* @return string[]
*/
public function getAspects(): array
{
return $this->aspects;
}
/**
* v1 specs (eBNF)
* + FLAGS "$" [ SCOPE ":" ] SUBJECT { "/" ASPECT }
* + FLAGS ::= XDIGIT (* 16-bit integer big-endian)
* + SCOPE ::= ALNUM { ALNUM }
* + SUBJECT ::= ALNUM { ALNUM }
* + ASPECT ::= ( ALNUM | '.' | '_' | '-' ) { ( ALNUM | '.' | '_' | '-' ) }
*/
protected function serialize(): string
{
// 6-bit version 10-bit capabilities
$flags = $this->version << 10 + $this->capabilities;
return sprintf(
'%s$%s%s%s',
bin2hex(pack('n', $flags)),
$this->scope ? $this->scope . ':' : '',
$this->subject,
$this->aspects ? '/' . implode('/', $this->aspects) : ''
);
}
}
@@ -0,0 +1,73 @@
<?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\Core\DataHandling\Model;
/**
* Represents the context of an entity
*
* A context defines a "variant" of an entity, currently by its language and workspace assignment. The EntityContext
* is bound to a RecordState.
*/
class EntityContext
{
/**
* @var int
*/
protected $workspaceId = 0;
/**
* @var int
*/
protected $languageId = 0;
public function getWorkspaceId(): int
{
return $this->workspaceId;
}
/**
* @return static
*/
public function withWorkspaceId(int $workspaceId): self
{
if ($this->workspaceId === $workspaceId) {
return $this;
}
$target = clone $this;
$target->workspaceId = $workspaceId;
return $target;
}
public function getLanguageId(): int
{
return $this->languageId;
}
/**
* @return static
*/
public function withLanguageId(int $languageId): self
{
if ($this->languageId === $languageId) {
return $this;
}
$target = clone $this;
$target->languageId = $languageId;
return $target;
}
}
@@ -0,0 +1,32 @@
<?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\Core\DataHandling\Model;
/**
* Interface describing pointers to an entity
*/
interface EntityPointer
{
public function getName(): string;
public function getIdentifier(): string;
public function isNode(): bool;
public function isEqualTo(EntityPointer $other): bool;
}
@@ -0,0 +1,68 @@
<?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\Core\DataHandling\Model;
/**
* An EntityPointerLink is used to connect EntityPointer instances
*/
class EntityPointerLink
{
/**
* @var EntityPointer
*/
protected $subject;
/**
* @var EntityPointerLink|null
*/
protected $ancestor;
public function __construct(EntityPointer $subject)
{
$this->subject = $subject;
}
public function getSubject(): EntityPointer
{
return $this->subject;
}
public function getHead(): EntityPointerLink
{
$head = $this;
while ($head->ancestor !== null) {
$head = $head->ancestor;
}
return $head;
}
public function getAncestor(): ?EntityPointerLink
{
return $this->ancestor;
}
public function withAncestor(EntityPointerLink $ancestor): self
{
if ($this->ancestor === $ancestor) {
return $this;
}
$target = clone $this;
$target->ancestor = $ancestor;
return $target;
}
}
@@ -0,0 +1,74 @@
<?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\Core\DataHandling\Model;
/**
* The EntityUidPointer represents the concrete origin of the entity
*/
class EntityUidPointer implements EntityPointer
{
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $identifier;
public function __construct(string $name, string $identifier)
{
$this->name = $name;
$this->identifier = $identifier;
}
public function getName(): string
{
return $this->name;
}
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* @return static
*/
public function withUid(string $identifier): self
{
if ($this->identifier === $identifier) {
return $this;
}
$target = clone $this;
$target->identifier = $identifier;
return $target;
}
public function isNode(): bool
{
return $this->name === 'pages';
}
public function isEqualTo(EntityPointer $other): bool
{
return $this->identifier === $other->getIdentifier()
&& $this->name === $other->getName();
}
}
+178
View File
@@ -0,0 +1,178 @@
<?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\Core\DataHandling\Model;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* A RecordState is an abstract description of a record that consists of
*
* - an EntityContext describing the "variant" of a record
* - an EntityPointer that describes the node where the record is stored
* - an EntityUidPointer of the record the RecordState instance represents
*
* Instances of this class are created by the RecordStateFactory.
*/
class RecordState
{
/**
* @var EntityContext
*/
protected $context;
/**
* @var EntityPointer
*/
protected $node;
/**
* @var EntityUidPointer
*/
protected $subject;
/**
* @var EntityPointerLink|null
*/
protected $languageLink;
/**
* @var EntityPointerLink|null
*/
protected $versionLink;
public function __construct(EntityContext $context, EntityPointer $node, EntityUidPointer $subject)
{
$this->context = $context;
$this->node = $node;
$this->subject = $subject;
}
public function getContext(): EntityContext
{
return $this->context;
}
public function getNode(): EntityPointer
{
return $this->node;
}
public function getSubject(): EntityUidPointer
{
return $this->subject;
}
/**
* @return EntityPointerLink
*/
public function getLanguageLink(): ?EntityPointerLink
{
return $this->languageLink;
}
/**
* @return static
*/
public function withLanguageLink(?EntityPointerLink $languageLink): self
{
if ($this->languageLink === $languageLink) {
return $this;
}
$target = clone $this;
$target->languageLink = $languageLink;
return $target;
}
/**
* @return EntityPointerLink
*/
public function getVersionLink(): ?EntityPointerLink
{
return $this->versionLink;
}
/**
* @return static
*/
public function withVersionLink(?EntityPointerLink $versionLink): self
{
if ($this->versionLink === $versionLink) {
return $this;
}
$target = clone $this;
$target->versionLink = $versionLink;
return $target;
}
public function isNew(): bool
{
return !MathUtility::canBeInterpretedAsInteger(
$this->subject->getIdentifier()
);
}
/**
* Resolves node identifier (`pid`) of current subject. For translated pages
* that would result in the `uid` of the outer-most language parent page
* otherwise it's the `pid` of the current subject.
*
* Example:
* + pages: uid: 10, pid: 5, sys_language_uid: 0, l10n_parent: 0 -> returns 5
* + pages: uid: 11, pid: 5, sys_language_uid: 1, l10n_parent: 10 -> returns 10
* + other: uid: 12, pid: 10 -> returns 10
*/
public function resolveNodeIdentifier(): string
{
if ($this->subject->isNode()
&& $this->context->getLanguageId() > 0
&& $this->languageLink !== null
) {
return $this->languageLink->getHead()->getSubject()->getIdentifier();
}
return $this->node->getIdentifier();
}
/**
* Resolves node identifier used as aggregate for current subject. For translated
* pages that would result in the `uid` of the outer-most language parent page,
* for pages it's the identifier of the current subject, otherwise it's
* the `pid` of the current subject.
*
* Example:
* + pages: uid: 10, pid: 5, sys_language_uid: 0, l10n_parent: 0 -> returns 10
* + pages: uid: 11, pid: 5, sys_language_uid: 1, l10n_parent: 10 -> returns 10
* + pages in version, return online page ID
* + other: uid: 12, pid: 10 -> returns 10
*/
public function resolveNodeAggregateIdentifier(): string
{
if ($this->subject->isNode()
&& $this->context->getLanguageId() > 0
&& $this->languageLink !== null
) {
return $this->languageLink->getHead()->getSubject()->getIdentifier();
}
if ($this->subject->isNode() && $this->versionLink) {
return $this->versionLink->getHead()->getSubject()->getIdentifier();
}
if ($this->subject->isNode()) {
return $this->subject->getIdentifier();
}
return $this->node->getIdentifier();
}
}
@@ -0,0 +1,159 @@
<?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\Core\DataHandling\Model;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Factory class that creates a record state
*/
class RecordStateFactory
{
protected string $name;
public static function forName(string $name): self
{
return GeneralUtility::makeInstance(static::class, $name);
}
public function __construct(string $name)
{
$this->name = $name;
}
/**
* @param int|string|null $pageId
* @param int|string|null $recordId
*/
public function fromArray(array $data, $pageId = null, $recordId = null): RecordState
{
$pageId = $pageId ?? $data['pid'] ?? null;
$recordId = $recordId ?? $data['uid'] ?? null;
$aspectFieldValues = $this->resolveAspectFieldValues($data);
$context = GeneralUtility::makeInstance(EntityContext::class)
->withWorkspaceId($aspectFieldValues['workspace'])
->withLanguageId($aspectFieldValues['language']);
$node = $this->createEntityPointer($pageId, 'pages');
$subject = $this->createEntityPointer($recordId);
$target = GeneralUtility::makeInstance(
RecordState::class,
$context,
$node,
$subject
);
return $target
->withLanguageLink($this->resolveLanguageLink($aspectFieldValues))
->withVersionLink($this->resolveVersionLink($aspectFieldValues));
}
/**
* @return array<string, string|null>
*/
protected function resolveAspectFieldNames(): array
{
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->name);
$languageCapability = null;
if ($schema->isLanguageAware()) {
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
}
return [
'workspace' => 't3ver_wsid',
'versionParent' => 't3ver_oid',
'language' => $languageCapability?->getLanguageField()->getName(),
'languageParent' => $languageCapability?->getTranslationOriginPointerField()->getName(),
'languageSource' => $languageCapability?->getTranslationSourceField()?->getName(),
];
}
protected function resolveAspectFieldValues(array $data): array
{
return array_map(
static function (?string $aspectFieldName) use ($data): int {
return (int)($data[$aspectFieldName ?? ''] ?? 0);
},
$this->resolveAspectFieldNames()
);
}
protected function resolveLanguageLink(array $aspectFieldNames): ?EntityPointerLink
{
$languageSourceLink = null;
$languageParentLink = null;
if (!empty($aspectFieldNames['languageSource'])) {
$languageSourceLink = GeneralUtility::makeInstance(
EntityPointerLink::class,
$this->createEntityPointer($aspectFieldNames['languageSource'])
);
}
if (!empty($aspectFieldNames['languageParent'])) {
$languageParentLink = GeneralUtility::makeInstance(
EntityPointerLink::class,
$this->createEntityPointer($aspectFieldNames['languageParent'])
);
}
if (empty($languageSourceLink) || empty($languageParentLink)
|| $languageSourceLink->getSubject()->isEqualTo(
$languageParentLink->getSubject()
)
) {
return $languageSourceLink ?? $languageParentLink ?? null;
}
return $languageSourceLink->withAncestor($languageParentLink);
}
protected function resolveVersionLink(array $aspectFieldNames): ?EntityPointerLink
{
if (!empty($aspectFieldNames['versionParent'])) {
return GeneralUtility::makeInstance(
EntityPointerLink::class,
$this->createEntityPointer($aspectFieldNames['versionParent'])
);
}
return null;
}
/**
* @param string|int|null $identifier
* @param string|null $name
* @throws \LogicException
*/
protected function createEntityPointer($identifier, ?string $name = null): EntityPointer
{
if ($identifier === null) {
throw new \LogicException(
'Cannot create null pointer',
1536407967
);
}
$identifier = (string)$identifier;
return GeneralUtility::makeInstance(
EntityUidPointer::class,
$name ?? $this->name,
$identifier
);
}
}
@@ -0,0 +1,150 @@
<?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\Core\DataHandling;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Schema\Struct\SelectItem;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This object defines the various types of pages (field: doktype) the system
* can handle and what restrictions may apply to them when adding records.
* Here you can define which tables are allowed on a certain pagetype (doktype).
*
* NOTE: The 'default' entry array is the 'base' for all types, and for every type the
* entries simply overrides the entries in the 'default' type!
*
* You can fully use this once TCA is properly loaded.
*/
#[Autoconfigure(public: true)]
readonly class PageDoktypeRegistry
{
public function __construct(protected TcaSchemaFactory $tcaSchemaFactory) {}
/**
* Check if a record can be added on a page with a given $doktype.
*/
public function isRecordTypeAllowedForDoktype(string $type, int $doktype): bool
{
$allowedRecordTypes = $this->getAllowedTypesForDoktype($doktype);
if (in_array('*', $allowedRecordTypes, true)) {
return true;
}
return in_array($type, $allowedRecordTypes, true);
}
/**
* @internal only to be used within TYPO3 Core
* @return string[]
*/
public function getAllowedTypesForDoktype(int $doktype): array
{
$pagesSchema = $this->tcaSchemaFactory->get('pages');
if ($pagesSchema->hasSubSchema((string)$doktype)) {
$pageTypeSchema = $pagesSchema->getSubSchema((string)$doktype);
$allowedRecordTypes = $pageTypeSchema->getRawConfiguration()['allowedRecordTypes'] ?? [];
if ($allowedRecordTypes !== []) {
return $allowedRecordTypes;
}
}
$hardDefaults = ['pages', 'sys_category', 'sys_file_reference', 'sys_file_collection'];
$defaultAllowedRecordTypes = $pagesSchema->getRawConfiguration()['defaultAllowedRecordTypes'] ?? [];
$mergedDefault = array_merge($hardDefaults, $defaultAllowedRecordTypes);
return array_unique($mergedDefault);
}
/**
* @return SelectItem[]
*/
public function getAllDoktypes(): array
{
$doktypeLabelMap = [];
$schema = $this->tcaSchemaFactory->get('pages');
// @todo Does not work for dynamic items, in case SubSchemaDivisorField is no StaticSelectFieldType!
$subSchemaField = $schema->getSubSchemaTypeInformation()->getFieldName();
foreach ($schema->getField($subSchemaField)->getConfiguration()['items'] ?? [] as $doktypeItemConfig) {
$selectionItem = SelectItem::fromTcaItemArray($doktypeItemConfig);
if ($selectionItem->isDivider()) {
continue;
}
$doktypeLabelMap[] = $selectionItem;
}
return $doktypeLabelMap;
}
/**
* Check if a page type is viewable based on TCA configuration only.
* Does NOT consider pageTsConfig overrides.
*
* By default, all page types are viewable unless explicitly set to false
* via the TCA option "isViewable".
*/
public function isPageTypeViewable(int $doktype): bool
{
$pageSchema = $this->tcaSchemaFactory->get('pages');
if ($pageSchema->hasSubSchema((string)$doktype)) {
$subSchema = $pageSchema->getSubSchema((string)$doktype);
$config = $subSchema->getRawConfiguration();
if (isset($config['isViewable'])) {
return (bool)$config['isViewable'];
}
}
// Default: viewable
return true;
}
/**
* Check if a page is viewable, considering both TCA and pageTsConfig.
* Respects TCEMAIN.preview.disableButtonForDokType TSconfig.
*/
public function isPageViewable(int $doktype, int $pageId): bool
{
// check TSconfig (same logic as PreviewUriBuilder::isPreviewableDoktype)
$TSconfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.']['preview.'] ?? [];
if (isset($TSconfig['disableButtonForDokType'])) {
$excludeDokTypes = GeneralUtility::intExplode(',', (string)$TSconfig['disableButtonForDokType'], true);
return !in_array($doktype, $excludeDokTypes, true);
}
// fallback to check TCA
if (!$this->isPageTypeViewable($doktype)) {
return false;
}
return true;
}
/**
* Returns array of non-viewable doktype integers based on TCA only.
* Used for JavaScript tree configuration.
*
* @return int[]
*/
public function getNonViewableDoktypes(): array
{
$nonViewable = [];
foreach ($this->tcaSchemaFactory->get('pages')->getSubSchemata() as $doktype => $schema) {
$isViewable = $schema->getRawConfiguration()['isViewable'] ?? true;
if (!$isViewable) {
$nonViewable[] = (int)$doktype;
}
}
return $nonViewable;
}
}
@@ -0,0 +1,119 @@
<?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\Core\DataHandling;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Class to determine and set proper permissions to a new (or copied) page.
*
* The following order applies:
* - defaultPermissions as defined in this class.
* - TYPO3_CONF_VARS[BE][defaultPermissions]
* - Page TSconfig va TCEMAIN.permissions
*
* @internal Implements a DataHandler detail. Should only be used by the TYPO3 Core.
*/
readonly class PagePermissionAssembler
{
/**
* Set default permissions of a new page, considering defaults and pageTsConfig overrides.
*
* @param array $fieldArray the field array to be used
* @param int $pid the parent page ID
* @param int $backendUserId the owner of the page to be set
* @param int $backendUserGroupId the owner group of the page to be set
* @return array the enriched field array
*/
public function applyDefaults(array $fieldArray, int $pid, int $backendUserId, int $backendUserGroupId): array
{
$fieldArray['perms_userid'] = $backendUserId;
$fieldArray['perms_groupid'] = $backendUserGroupId;
$fieldArray['perms_user'] = $this->assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['user'] ?? 'show,edit,delete,new,editcontent');
$fieldArray['perms_group'] = $this->assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['group'] ?? 'show,edit,new,editcontent');
$fieldArray['perms_everybody'] = $this->assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['everybody'] ?? '');
// @todo: It's kinda ugly pageTS is fetched here on demand. Together with the 'fetch parent page' code in
// setTSconfigPermissions(), we should think about changing the API to have these things hand
// over instead.
$TSConfig = BackendUtility::getPagesTSconfig($pid)['TCEMAIN.'] ?? [];
if (isset($TSConfig['permissions.']) && is_array($TSConfig['permissions.'])) {
return $this->setTSconfigPermissions($fieldArray, $TSConfig['permissions.']);
}
return $fieldArray;
}
/**
* Setting up perms_* fields in $fieldArray based on TSconfig input
* Used for new pages and pages that are copied.
*
* @param array $fieldArray Field Array, returned with modifications
* @param array $tsconfig TSconfig properties
* @return array Modified Field Array
*/
protected function setTSconfigPermissions(array $fieldArray, array $tsconfig): array
{
$parentPermissions = [];
if (in_array('copyFromParent', $tsconfig, true)) {
// @todo: Dislocated! The API should be changed to have a potential parent record hand over.
$parentPermissions = BackendUtility::getRecordWSOL('pages', $fieldArray['pid'], 'uid,perms_userid,perms_groupid,perms_user,perms_group,perms_everybody') ?? [];
}
if ((string)($tsconfig['userid'] ?? '') !== '' && ($tsconfig['userid'] !== 'copyFromParent' || isset($parentPermissions['perms_userid']))) {
$fieldArray['perms_userid'] = $tsconfig['userid'] === 'copyFromParent' ? (int)$parentPermissions['perms_userid'] : (int)$tsconfig['userid'];
}
if ((string)($tsconfig['groupid'] ?? '') !== '' && ($tsconfig['groupid'] !== 'copyFromParent' || isset($parentPermissions['perms_groupid']))) {
$fieldArray['perms_groupid'] = $tsconfig['groupid'] === 'copyFromParent' ? (int)$parentPermissions['perms_groupid'] : (int)$tsconfig['groupid'];
}
if ((string)($tsconfig['user'] ?? '') !== '' && ($tsconfig['user'] !== 'copyFromParent' || isset($parentPermissions['perms_user']))) {
$fieldArray['perms_user'] = $tsconfig['user'] === 'copyFromParent' ? (int)$parentPermissions['perms_user'] : $this->assemblePermissions($tsconfig['user']);
}
if ((string)($tsconfig['group'] ?? '') !== '' && ($tsconfig['group'] !== 'copyFromParent' || isset($parentPermissions['perms_group']))) {
$fieldArray['perms_group'] = $tsconfig['group'] === 'copyFromParent' ? (int)$parentPermissions['perms_group'] : $this->assemblePermissions($tsconfig['group']);
}
if ((string)($tsconfig['everybody'] ?? '') !== '' && ($tsconfig['everybody'] !== 'copyFromParent' || isset($parentPermissions['perms_everybody']))) {
$fieldArray['perms_everybody'] = $tsconfig['everybody'] === 'copyFromParent' ? (int)$parentPermissions['perms_everybody'] : $this->assemblePermissions($tsconfig['everybody']);
}
return $fieldArray;
}
/**
* Calculates the bit value of the permissions given in a string, comma-separated.
*
* Even though not documented, it seems to be possible having int values in
* $GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['...'] as bit mask
* already. To not break anything, this is kept for now.
*/
protected function assemblePermissions(int|string $listOfPermissions): int
{
// Already set as integer, so this one is used.
if (MathUtility::canBeInterpretedAsInteger($listOfPermissions)) {
return (int)$listOfPermissions;
}
$keyArr = GeneralUtility::trimExplode(',', $listOfPermissions, true);
$value = 0;
$permissionMap = Permission::getMap();
foreach ($keyArr as $key) {
if ($key && isset($permissionMap[$key])) {
$value |= $permissionMap[$key];
}
}
return $value;
}
}
+403
View File
@@ -0,0 +1,403 @@
<?php
/*
* 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\Core\DataHandling;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Plain data resolving.
*
* This component resolves data constraints for given IDs of a
* particular table on a plain/raw database level. Thus, workspaces
* placeholders and overlay related resorting is applied automatically.
*/
class PlainDataResolver
{
/**
* @var string
*/
protected $tableName;
/**
* @var int[]
*/
protected $liveIds;
/**
* @var array|null
*/
protected $sortingStatement;
/**
* @var int
*/
protected $workspaceId;
/**
* @var bool
*/
protected $keepLiveIds = false;
/**
* @var bool
*/
protected $keepDeletePlaceholder = false;
/**
* @var bool
*/
protected $keepMovePlaceholder = true;
/**
* @param string $tableName
* @param int[] $liveIds
* @param array|null $sortingStatement
*/
public function __construct($tableName, array $liveIds, ?array $sortingStatement = null)
{
$this->tableName = $tableName;
$this->liveIds = $this->reindex($this->sanitizeIds($liveIds));
$this->sortingStatement = $sortingStatement;
}
/**
* Sets the target workspace ID the final result shall use.
*
* @param int $workspaceId
*/
public function setWorkspaceId($workspaceId)
{
$this->workspaceId = (int)$workspaceId;
}
/**
* Sets whether live IDs shall be kept in the final result set.
*
* @param bool $keepLiveIds
* @return PlainDataResolver
*/
public function setKeepLiveIds($keepLiveIds)
{
$this->keepLiveIds = (bool)$keepLiveIds;
return $this;
}
/**
* Sets whether delete placeholders shall be kept in the final result set.
*
* @param bool $keepDeletePlaceholder
* @return PlainDataResolver
*/
public function setKeepDeletePlaceholder($keepDeletePlaceholder)
{
$this->keepDeletePlaceholder = (bool)$keepDeletePlaceholder;
return $this;
}
/**
* Sets whether move placeholders shall be kept in case they cannot be substituted.
*
* @param bool $keepMovePlaceholder
* @return PlainDataResolver
*/
public function setKeepMovePlaceholder($keepMovePlaceholder)
{
$this->keepMovePlaceholder = (bool)$keepMovePlaceholder;
return $this;
}
/**
* @return int[]
*/
public function get()
{
$resolvedIds = $this->processVersionOverlays($this->liveIds);
if ($resolvedIds !== $this->liveIds) {
$resolvedIds = $this->reindex($resolvedIds);
}
$tempIds = $this->processSorting($resolvedIds);
if ($tempIds !== $resolvedIds) {
$resolvedIds = $this->reindex($tempIds);
}
$tempIds = $this->applyLiveIds($resolvedIds);
if ($tempIds !== $resolvedIds) {
$resolvedIds = $this->reindex($tempIds);
}
return $resolvedIds;
}
/**
* Processes version overlays on the final result set.
*
* @param int[] $ids
* @return int[]
* @internal
*/
public function processVersionOverlays(array $ids)
{
$ids = $this->sanitizeIds($ids);
if (empty($this->workspaceId) || !$this->isWorkspaceEnabled() || empty($ids)) {
return $ids;
}
$ids = $this->reindex(
$this->processVersionMovePlaceholders($ids)
);
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->tableName);
$queryBuilder->getRestrictions()->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$result = $queryBuilder
->select('uid', 't3ver_oid', 't3ver_state')
->from($this->tableName)
->where(
$queryBuilder->expr()->in(
't3ver_oid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
't3ver_wsid',
$queryBuilder->createNamedParameter($this->workspaceId, Connection::PARAM_INT)
)
)
->executeQuery();
while ($version = $result->fetchAssociative()) {
$liveReferenceId = (int)$version['t3ver_oid'];
$versionId = (int)$version['uid'];
if (isset($ids[$liveReferenceId])) {
if (!$this->keepDeletePlaceholder
&& VersionState::tryFrom((int)($version['t3ver_state'] ?? 0)) === VersionState::DELETE_PLACEHOLDER
) {
unset($ids[$liveReferenceId]);
} else {
$ids[$liveReferenceId] = $versionId;
}
}
}
return $ids;
}
/**
* Processes and resolves move placeholders on the final result set.
*
* @param int[] $ids
* @return int[]
* @internal
*/
public function processVersionMovePlaceholders(array $ids)
{
$ids = $this->sanitizeIds($ids);
// Early return on insufficient data-set
if (empty($this->workspaceId) || !$this->isWorkspaceEnabled() || empty($ids)) {
return $ids;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->tableName);
$queryBuilder->getRestrictions()->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$result = $queryBuilder
->select('uid', 't3ver_oid')
->from($this->tableName)
->where(
$queryBuilder->expr()->eq(
't3ver_state',
$queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
't3ver_wsid',
$queryBuilder->createNamedParameter($this->workspaceId, Connection::PARAM_INT)
),
$queryBuilder->expr()->in(
't3ver_oid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
)
)
->executeQuery();
while ($movedRecord = $result->fetchAssociative()) {
$liveReferenceId = (int)$movedRecord['t3ver_oid'];
$movedVersionId = (int)$movedRecord['uid'];
// Substitute moved record and purge live reference
if (isset($ids[$movedVersionId])) {
$ids[$movedVersionId] = $liveReferenceId;
unset($ids[$liveReferenceId]);
} elseif (!$this->keepMovePlaceholder) {
// Just purge live reference
unset($ids[$liveReferenceId]);
}
}
return $ids;
}
/**
* Processes sorting of the final result set, if
* a sorting statement (table column/expression) is given.
*
* @param int[] $ids
* @return int[]
* @internal
*/
public function processSorting(array $ids)
{
$ids = $this->sanitizeIds($ids);
// Early return on missing sorting statement or insufficient data-set
if (empty($this->sortingStatement) || count($ids) < 2) {
return $ids;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
// Never apply additional restrictions like 'deleted' to the incoming id list
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder
->select('uid')
->from($this->tableName)
->where(
$queryBuilder->expr()->in(
'uid',
// do not use named parameter here as the list can get too long
array_map(intval(...), $ids)
)
);
foreach ($this->sortingStatement as $sortingStatement) {
$queryBuilder->getConcreteQueryBuilder()->addOrderBy($sortingStatement);
}
// Always add explicit order by uid to have deterministic rows from dbms like postgres.
// Scenario (see workspace FAL/Modify/ActionTest modifyContentAndDeleteFileReference):
// A content element with two images - sys_file_reference uid=23 with sorting_foreign=2 (!)
// and sys_file_reference uid=42 with sorting_foreign=1. The references have been added
// and later changed their sorting that uid 42 is before 23.
// Then, in workspaces, image reference 42 is deleted and 23 is changed (eg. title). This
// creates two overlays: a 'delete placeholder' t3ver_state=2 with sorting_foreign=1 for 42,
// and a 'changed' record t3ver_state=0 with sorting_foreign=1 for 23.
// So both overlay records end up with sorting_foreign=1. This is technically ok since the
// 'delete placeholder' "does not exist" from a live relation point of view, so the next
// "real" record starts with 1 when published.
// BUT, this scenario makes the order of returned rows non-deterministic for dbms that
// do not implicitly order by uid (mysql does, postgres does not): The usual orderBy
// is 'sorting_foreign' but both are 1 now.
// We thus add a general explicit order by uid here to force deterministic row returns.
$queryBuilder->addOrderBy('uid');
$sortedIds = $queryBuilder->executeQuery()->fetchAllAssociative();
return array_map(intval(...), array_column($sortedIds, 'uid'));
}
/**
* Applies live IDs to the final result set, if
* the current table is enabled for workspaces and
* the keepLiveIds class member is enabled.
*
* @param int[] $ids
* @return int[]
* @internal
*/
public function applyLiveIds(array $ids)
{
$ids = $this->sanitizeIds($ids);
if (!$this->keepLiveIds || !$this->isWorkspaceEnabled() || empty($ids)) {
return $ids;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->tableName);
$queryBuilder->getRestrictions()->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$result = $queryBuilder
->select('uid', 't3ver_oid')
->from($this->tableName)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
)
)
->executeQuery();
$versionIds = [];
while ($record = $result->fetchAssociative()) {
$liveId = (int)$record['uid'];
$versionIds[$liveId] = (int)$record['t3ver_oid'];
}
foreach ($ids as $id) {
if (!empty($versionIds[$id])) {
$ids[$id] = $versionIds[$id];
}
}
return $ids;
}
/**
* Re-indexes the given IDs.
*
* @param int[] $ids
* @return int[]
*/
protected function reindex(array $ids)
{
if (empty($ids)) {
return $ids;
}
$ids = array_values($ids);
$ids = array_combine($ids, $ids);
return $ids;
}
/**
* Removes empty values (null, '0', 0, false).
*
* @param int[] $ids
*/
protected function sanitizeIds(array $ids): array
{
return array_filter($ids);
}
/**
* @return bool
*/
protected function isWorkspaceEnabled()
{
if (ExtensionManagementUtility::isLoaded('workspaces')) {
$schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
return $schemaFactory->has($this->tableName) && $schemaFactory->get($this->tableName)->hasCapability(TcaSchemaCapability::Workspace);
}
return false;
}
}
@@ -0,0 +1,255 @@
<?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\Core\DataHandling;
use Doctrine\DBAL\Types\Type;
use TYPO3\CMS\Core\Collection\LazyRecordCollection;
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Country\CountryProvider;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Domain\FlexFormFieldValues;
use TYPO3\CMS\Core\Domain\Persistence\RecordIdentityMap;
use TYPO3\CMS\Core\Domain\RawRecord;
use TYPO3\CMS\Core\Domain\RecordFactory;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Domain\RecordPropertyClosure;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\LinkHandling\TypolinkParameter;
use TYPO3\CMS\Core\Resource\Collection\LazyFileReferenceCollection;
use TYPO3\CMS\Core\Resource\Collection\LazyFolderCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Field\DateTimeFieldType;
use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;
use TYPO3\CMS\Core\Schema\Field\FileFieldType;
use TYPO3\CMS\Core\Schema\Field\FlexFormFieldType;
use TYPO3\CMS\Core\Schema\Field\RelationalFieldTypeInterface;
use TYPO3\CMS\Core\Schema\Field\StaticSelectFieldType;
use TYPO3\CMS\Core\Schema\FlexFormSchemaFactory;
use TYPO3\CMS\Core\Schema\RelationMap;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This generic mapper takes a field value of a record, and maps the value
* of a field with a specific type (TCA column type) to an expanded property
* (e.g. a type "file" field to a collection of FileReference objects).
*
* Common examples are \DateTimeImmutable objects for TCA fields of type=datetime.
*
* In general, this class is very inflexible, and not configurable,
* but we hope to extend this further in the future to add custom mappings.
*
* This class also calls the RelationResolver for any kind of resolved relations,
* but tries to handle most of the logic on its own when no lazy-loading is needed.
*
* About lazy-laading: For all relation types, which do not have a "toOne" relationship,
* a lazy collection is used. The relations in this collection are only resolved once
* they are accessed. For the "toOne" relations, a RecordPropertyClosure is used, which
* also initializes the corresponding record only when accessed. While a collection could
* be empty after being resolved, a single record might resolve to NULL, in case of an
* invalid relation value.
*
* @internal This class is not part of the TYPO3 Core API. It might get moved or changed.
*/
readonly class RecordFieldTransformer
{
public function __construct(
protected RelationResolver $relationResolver,
protected ResourceFactory $resourceFactory,
protected FlexFormTools $flexFormTools,
protected FlexFormSchemaFactory $flexFormSchemaFactory,
protected LinkService $linkService,
protected TypoLinkCodecService $typoLinkCodecService,
protected ConnectionPool $connectionPool,
protected CountryProvider $countryProvider,
) {}
public function transformField(
FieldTypeInterface $fieldInformation,
RawRecord $rawRecord,
Context $context,
RecordIdentityMap $recordIdentityMap,
): mixed {
$fieldValue = $rawRecord->get($fieldInformation->getName());
// type=file needs to be handled before RelationalFieldTypeInterface
if ($fieldInformation instanceof FileFieldType) {
if ($fieldInformation->getRelationshipType()->hasOne()) {
return new RecordPropertyClosure(
function () use ($rawRecord, $fieldInformation, $context): ?FileReference {
$fileReference = $this->relationResolver->resolveFileReferences($rawRecord, $fieldInformation, $context)[0] ?? null;
if ($fileReference === null) {
return null;
}
return new FileReference($fileReference->getProperties());
}
);
}
return new LazyFileReferenceCollection($fieldValue, function () use ($rawRecord, $fieldInformation, $context): array {
return $this->relationResolver->resolveFileReferences($rawRecord, $fieldInformation, $context);
});
}
if ($fieldInformation instanceof RelationalFieldTypeInterface) {
/** @var RecordFactory $recordFactory */
// @todo This method is called by RecordFactory -> instantiating the factory here again shows, that those classes should actually be somehow belong together.
$recordFactory = GeneralUtility::makeInstance(RecordFactory::class);
if ($fieldInformation->getRelationshipType()->hasOne()) {
return new RecordPropertyClosure(
function () use ($rawRecord, $fieldInformation, $context, $recordFactory, $recordIdentityMap): ?RecordInterface {
$recordData = $this->relationResolver->resolve($rawRecord, $fieldInformation, $context)[0] ?? null;
if ($recordData === null) {
return null;
}
$dbTable = $recordData['table'];
$row = $recordData['row'];
return $recordFactory->createResolvedRecordFromDatabaseRow($dbTable, $row, $context, $recordIdentityMap);
}
);
}
return new LazyRecordCollection(
$fieldValue,
function () use ($rawRecord, $fieldInformation, $context, $recordFactory, $recordIdentityMap): array {
$relationalRecords = [];
$recordData = $this->relationResolver->resolve($rawRecord, $fieldInformation, $context);
foreach ($recordData as $singleRecordData) {
$dbTable = $singleRecordData['table'];
$row = $singleRecordData['row'];
$relationalRecords[] = $recordFactory->createResolvedRecordFromDatabaseRow($dbTable, $row, $context, $recordIdentityMap);
}
return $relationalRecords;
}
);
}
if ($fieldInformation->isType(TableColumnType::FOLDER)) {
if (in_array((string)($fieldInformation->getConfiguration()['relationship'] ?? ''), ['oneToOne', 'manyToOne'], true)) {
return new RecordPropertyClosure(
function () use ($fieldValue): ?Folder {
$folder = $this->resolveFoldersRecursive(GeneralUtility::trimExplode(',', (string)$fieldValue, true, 1))[0] ?? null;
if ($folder === null) {
return null;
}
return new Folder($folder->getStorage(), $folder->getIdentifier(), $folder->getName());
}
);
}
return new LazyFolderCollection($fieldValue, function () use ($fieldValue): array {
return $this->resolveFoldersRecursive(GeneralUtility::trimExplode(',', (string)$fieldValue, true));
});
}
// Static select lists is transformed into an array of values
if ($fieldInformation instanceof StaticSelectFieldType) {
$selectForcedToSingle = (string)($fieldInformation->getConfiguration()['renderType'] ?? '') === 'selectSingle';
return $selectForcedToSingle ? $fieldValue : GeneralUtility::trimExplode(',', (string)$fieldValue, true);
}
if ($fieldInformation->isType(TableColumnType::FLEX)) {
/** @var FlexFormFieldType $fieldInformation */
return new RecordPropertyClosure(fn(): FlexFormFieldValues => $this->processFlexForm($rawRecord, $fieldInformation, (string)$fieldValue, $context, $recordIdentityMap));
}
if ($fieldInformation->isType(TableColumnType::JSON)) {
return new RecordPropertyClosure(
fn(): array|string|int|float|bool|null => Type::getType('json')->convertToPHPValue(
(string)$fieldValue,
$this->connectionPool->getConnectionForTable($rawRecord->getMainType())->getDatabasePlatform()
)
);
}
if ($fieldInformation instanceof DateTimeFieldType) {
return DateTimeFactory::createFromDatabaseValue($fieldValue, $fieldInformation);
}
if ($fieldInformation->isType(TableColumnType::LINK)) {
return new RecordPropertyClosure(
fn(): ?TypolinkParameter => $fieldValue === null && $fieldInformation->isNullable() ? null : TypolinkParameter::createFromTypolinkParts($this->typoLinkCodecService->decode((string)$fieldValue))
);
}
if ($fieldInformation->isType(TableColumnType::COUNTRY)) {
if ($fieldValue === null && $fieldInformation->isNullable()) {
return null;
}
return $this->countryProvider->getByIsoCode((string)$fieldValue) ?? '';
}
return $fieldValue;
}
/**
* @return Folder[]
*/
protected function resolveFoldersRecursive(array $folders): array
{
$foldersRecursive = [];
foreach ($folders as $singleFolder) {
if ($singleFolder instanceof Folder === false) {
$singleFolder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($singleFolder);
}
$foldersRecursive[] = $singleFolder;
array_push($foldersRecursive, ...$this->resolveFoldersRecursive($singleFolder->getSubfolders()));
}
return $foldersRecursive;
}
/**
* This method creates an array which contains all information which is valid from the
* selected Schema. Ideally, this should be "FlexRecord" objects, and also keep the original values.
* This functionality will likely change in the future.
*/
protected function processFlexForm(
RawRecord $record,
FlexFormFieldType $fieldInformation,
mixed $fieldValue,
Context $context,
RecordIdentityMap $recordIdentityMap,
): FlexFormFieldValues {
$plainValues = $this->flexFormTools->convertFlexFormContentToSheetsArray((string)$fieldValue);
// @todo: RelationMap does not work in FlexForm currently, as we do not have this information persisted somewhere
$usedSchema = $this->flexFormSchemaFactory->getSchemaForRecord($record, $fieldInformation, new RelationMap());
if ($usedSchema === null) {
return new FlexFormFieldValues($plainValues);
}
$recordFactory = GeneralUtility::makeInstance(RecordFactory::class);
$transformedValues = [];
foreach ($plainValues as $sheetName => $values) {
// Flatten keys (because we receive settings[mysetting] and we want settings.mysetting)
$values = ArrayUtility::flattenPlain($values);
foreach ($values as $fieldName => &$plainFieldValue) {
// That's a "fun" workaround: In order to allow to process e.g. "sDEF/header", we need
// to add this to the "rawRecord" (thus, we clone it), so it is within the array
// and then set "sDEF/header" even though this is not a DB field. Then we keep it in "$fieldName"
// which actually is the plain field name (in this case "header")
$fieldInformationOfFlexField = $usedSchema->getField($fieldName, $sheetName);
// No field given, we just skip the value, as it is not properly defined
if ($fieldInformationOfFlexField === null) {
continue;
}
$rawRecordValues = array_replace($record->toArray(), [$fieldInformationOfFlexField->getName() => $plainFieldValue]);
$fakeRawRecordWithFlexField = $recordFactory->createRawRecord($record->getMainType(), $rawRecordValues);
$transformedValue = $this->transformField($fieldInformationOfFlexField, $fakeRawRecordWithFlexField, $context, $recordIdentityMap);
$plainFieldValue = $transformedValue;
}
unset($plainFieldValue);
$transformedValues[$sheetName] = ArrayUtility::unflatten($values);
}
return new FlexFormFieldValues($transformedValues);
}
}
@@ -0,0 +1,215 @@
<?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\Core\DataHandling;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
/**
* Helper class for DataHandler to gather requests for reference index updates
* and perform them in one go after other operations have been done.
* This is used to suppress multiple reference index update calls for the same
* workspace/table/uid combination within one DataHandler main call.
*
* @internal should only be used by the TYPO3 Core
*/
#[Autoconfigure(public: true, shared: false)]
class ReferenceIndexUpdater
{
/**
* [ workspaceId => [ tableName => [ uid ] ] ]
*
* @var array<int, array<string, array<int, int>>>
*/
protected array $updateRegistry = [];
/**
* [ workspaceId => [ tableName => [
* 'uid' => uid,
* 'targetWorkspace' => $targetWorkspace
* ] ] ]
*
* @var array<int, array<string, array<int, array<string, string|int>>>>
*/
protected array $updateRegistryToItem = [];
/**
* [ workspaceId => [ tableName => [ uid ] ] ]
*
* @var array<int, array<string, array<int, int>>>
*/
protected array $dropRegistry = [];
public function __construct(
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ConnectionPool $connectionPool,
private readonly ReferenceIndex $referenceIndex,
) {}
/**
* Register a workspace/table/uid row for update
*
* @param string $table Table name
* @param int $uid Record uid
* @param int $workspace Workspace the record lives in
*/
public function registerForUpdate(string $table, int $uid, int $workspace): void
{
if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) {
// If a user is in some workspace and changes relations of not workspace aware
// records, the reference index update needs to be performed as if the user
// is in live workspace. This is detected here and the update is registered for live.
$workspace = 0;
}
if (!isset($this->updateRegistry[$workspace][$table])) {
$this->updateRegistry[$workspace][$table] = [];
}
if (!in_array($uid, $this->updateRegistry[$workspace][$table], true)) {
$this->updateRegistry[$workspace][$table][] = $uid;
}
}
/**
* Find reference index rows pointing to given table/uid combination and register them for update. Important in
* delete and publish scenarios where a child is deleted to make sure any references to this child are dropped, too.
* In publish scenarios reference index may exist for a non-live workspace, but should be updated for live workspace.
* The optional $targetWorkspace argument is used for this.
*
* @param string $table Table name, used as ref_table
* @param int $uid Record uid, used as ref_uid
* @param int $workspace The workspace given record lives in
* @param int|null $targetWorkspace The target workspace the record has been swapped to
*/
public function registerUpdateForReferencesToItem(string $table, int $uid, int $workspace, ?int $targetWorkspace = null): void
{
if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) {
// If a user is in some workspace and changes relations of not workspace aware
// records, the reference index update needs to be performed as if the user
// is in live workspace. This is detected here and the update is registered for live.
$workspace = 0;
}
if ($targetWorkspace === null) {
$targetWorkspace = $workspace;
}
if (!isset($this->updateRegistryToItem[$workspace][$table])) {
$this->updateRegistryToItem[$workspace][$table] = [];
}
$recordAndTargetWorkspace = [
'uid' => $uid,
'targetWorkspace' => $targetWorkspace,
];
if (!in_array($recordAndTargetWorkspace, $this->updateRegistryToItem[$workspace][$table], true)) {
$this->updateRegistryToItem[$workspace][$table][] = $recordAndTargetWorkspace;
}
}
/**
* Delete rows from sys_refindex a table / uid combination is involved in:
* Either on left side (tablename + recuid) OR right side (ref_table + ref_uid).
* Useful in scenarios like workspace-discard where parents or children are hard deleted: The
* expensive updateRefIndex() does not need to be called since we can just drop straight ahead.
*
* @param string $table Table name, used as tablename and ref_table
* @param int $uid Record uid, used as recuid and ref_uid
* @param int $workspace Workspace the record lives in
*/
public function registerForDrop(string $table, int $uid, int $workspace): void
{
if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) {
// If a user is in some workspace and changes relations of not workspace aware
// records, the reference index update needs to be performed as if the user
// is in live workspace. This is detected here and the update is registered for live.
$workspace = 0;
}
if (!isset($this->dropRegistry[$workspace][$table])) {
$this->dropRegistry[$workspace][$table] = [];
}
if (!in_array($uid, $this->dropRegistry[$workspace][$table], true)) {
$this->dropRegistry[$workspace][$table][] = $uid;
}
}
/**
* Perform the reference index update operations
*/
public function update(): void
{
// Register updates to an item for update
foreach ($this->updateRegistryToItem as $workspace => $tableArray) {
foreach ($tableArray as $table => $recordArray) {
foreach ($recordArray as $item) {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
$statement = $queryBuilder
->select('tablename', 'recuid')
->from('sys_refindex')
->where(
$queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
$queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($item['uid'], Connection::PARAM_INT)),
$queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspace, Connection::PARAM_INT))
)
->executeQuery();
while ($row = $statement->fetchAssociative()) {
$this->registerForUpdate($row['tablename'], (int)$row['recuid'], (int)$item['targetWorkspace']);
}
}
}
}
$this->updateRegistryToItem = [];
// Drop rows from reference index if requested. Note this is performed *after* update-to-item, to
// find rows pointing to a record and register updates before rows are dropped. Needed if a record
// changes the workspace during publish: In this case all records pointing to the record in a workspace
// need to be registered for update for live workspace and after that the workspace rows can be dropped.
foreach ($this->dropRegistry as $workspace => $tableArray) {
foreach ($tableArray as $table => $uidArray) {
foreach ($uidArray as $uid) {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex');
$queryBuilder->delete('sys_refindex')
->where(
$queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspace, Connection::PARAM_INT)),
$queryBuilder->expr()->or(
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($table)),
$queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))
),
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
$queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))
)
)
)
->executeStatement();
}
}
}
$this->dropRegistry = [];
// Perform reference index updates
foreach ($this->updateRegistry as $workspace => $tableArray) {
foreach ($tableArray as $table => $uidArray) {
foreach ($uidArray as $uid) {
$this->referenceIndex->updateRefIndexTable($table, $uid, false, $workspace);
}
}
}
$this->updateRegistry = [];
}
}
+155
View File
@@ -0,0 +1,155 @@
<?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\Core\DataHandling;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\RelationHandler;
use TYPO3\CMS\Core\Domain\Persistence\GreedyDatabaseBackend;
use TYPO3\CMS\Core\Domain\RawRecord;
use TYPO3\CMS\Core\Domain\Record;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Finds relations for a RelationalFieldType field such as:
* - inline
* - select with foreign_table or MM
* - group with allowed or MM
* - category
* - file
*
* Files are handled differently with specific File / FileReference objects
* instead of general Record objects. Therefore, resolveFileReferences() is used.
*
* What it does to the outside world:
* - You have a record and a field with relations and you get a collection of the related raw DB records.
*
* What it hides:
* - How the DB queries are made.
*
* The result is usually wrapped in a Closure, so it is only called when needed,
* however this piece of code does not care about how it is used.
*
* @internal not part of public API, as this needs to be streamlined and proven.
*/
readonly class RelationResolver
{
public function __construct(
#[Autowire(service: 'cache.runtime')]
protected FrontendInterface $runtimeCache,
protected ResourceFactory $resourceFactory,
protected GreedyDatabaseBackend $greedyDatabaseBackend
) {}
/**
* This method currently returns an array with "table" and "row" pairs,
* but will probably return something else in the future.
*
* @return list<array{table: string, row: array<string, mixed>}>
*/
public function resolve(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array
{
$sortedAndGroupedIds = $this->getGroupedRelationIds($record, $fieldInformation, $context);
$groupedByTable = [];
// group sorted items by table
foreach ($sortedAndGroupedIds as $item) {
$groupedByTable[$item['table']][] = (int)$item['id'];
}
$unorderedRows = $this->getRelationalRows($groupedByTable, $context);
$sortedRows = [];
// Sort the relation rows based on the field value
foreach ($sortedAndGroupedIds as $item) {
if (isset($unorderedRows[$item['table']][(int)$item['id']])) {
$sortedRows[] = $unorderedRows[$item['table']][(int)$item['id']];
}
}
return $sortedRows;
}
/**
* @return FileReference[]
*/
public function resolveFileReferences(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array
{
$sortedAndGroupedIds = $this->getGroupedRelationIds($record, $fieldInformation, $context);
$sortedFileReferenceIds = array_map(static fn(array $item) => (int)$item['id'], $sortedAndGroupedIds);
$unorderedRows = $this->greedyDatabaseBackend->getRows('sys_file_reference', $sortedFileReferenceIds, $context);
$unorderedRowsByUid = [];
foreach ($unorderedRows as $row) {
$unorderedRowsByUid[(int)$row['uid']] = $row;
}
$fileReferenceObjects = [];
foreach ($sortedAndGroupedIds as $item) {
if (isset($unorderedRowsByUid[(int)$item['id']])) {
$fileReferenceRow = $unorderedRowsByUid[(int)$item['id']];
$fileReferenceObjects[] = $this->resourceFactory->createFileReferenceObject($fileReferenceRow);
}
}
return $fileReferenceObjects;
}
/**
* We currently use the RelationHandler to resolve all records attached to a given field.
* @todo This will be replaced by querying the RefIndex directly in the future.
*
* @return array<int, array<string, mixed>>
*/
protected function getGroupedRelationIds(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array
{
$rawRecord = $record instanceof Record ? $record->getRawRecord() : $record;
$recordData = $rawRecord->toArray();
$relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
$relationHandler->setWorkspaceId($context->getPropertyFromAspect('workspace', 'id', 0));
if ($rawRecord instanceof RawRecord && $rawRecord->getComputedProperties()->getLocalizedUid() > 0) {
$relationHandler->initializeForField($record->getMainType(), $fieldInformation, $rawRecord->getComputedProperties()->getLocalizedUid(), $recordData[$fieldInformation->getName()] ?? null);
} else {
$relationHandler->initializeForField($record->getMainType(), $fieldInformation, $recordData, $recordData[$fieldInformation->getName()] ?? null);
}
$relationHandler->processDeletePlaceholder();
return $relationHandler->itemArray;
}
/**
* Find the relations relevant for this field. This could be multiple tables!
*
* Note: While $necessaryRelationsOfRequestedField is sorted, the result will be the plain unsorted database rows.
*
* @return array<string, array<int, array{table: string, row: array<string, mixed>}>>
*/
protected function getRelationalRows(array $necessaryRelationsOfRequestedField, Context $context): array
{
$finalRows = [];
foreach ($necessaryRelationsOfRequestedField as $dbTable => $uids) {
// Let's loop over all tables, and fetch all records of the PIDs of the given UIDs in a greedy way
$rows = $this->greedyDatabaseBackend->getRows($dbTable, $uids, $context);
foreach ($rows as $row) {
$finalRows[$dbTable][(int)$row['uid']] = [
'table' => $dbTable,
'row' => $row,
];
}
}
return $finalRows;
}
}
+605
View File
@@ -0,0 +1,605 @@
<?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\Core\DataHandling;
use TYPO3\CMS\Backend\Domain\Repository\Localization\LocalizationRepository;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\DataHandling\Model\RecordState;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Slug\SlugNormalizer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Generates, sanitizes and validates slugs for a TCA field
*/
class SlugHelper
{
/**
* @var string
*/
protected $tableName;
/**
* @var string
*/
protected $fieldName;
/**
* @var int
*/
protected $workspaceId;
/**
* @var array
*/
protected $configuration = [];
/**
* @var bool
*/
protected $workspaceEnabled;
/**
* Defines whether the slug field should start with "/".
* For pages (due to rootline functionality), this is a must have, otherwise the root level page
* would have an empty value.
*
* @var bool
*/
protected $prependSlashInSlug;
protected SlugNormalizer $slugNormalizer;
/**
* Slug constructor.
*
* @param string $tableName TCA table
* @param string $fieldName TCA field
* @param array $configuration TCA configuration of the field
* @param int $workspaceId the workspace ID to be working on.
*/
public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
{
$this->tableName = $tableName;
$this->fieldName = $fieldName;
$this->configuration = $configuration;
$this->workspaceId = $workspaceId;
if ($this->tableName === 'pages' && $this->fieldName === 'slug') {
$this->prependSlashInSlug = true;
} else {
$this->prependSlashInSlug = $this->configuration['prependSlash'] ?? false;
}
$schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
$this->workspaceEnabled = $schemaFactory->has($tableName) && $schemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace);
$this->slugNormalizer = GeneralUtility::makeInstance(SlugNormalizer::class);
}
/**
* Cleans a slug value so it is used directly in the path segment of a URL.
*/
public function sanitize(string $slug): string
{
$value = $this->slugNormalizer->normalize($slug, $this->configuration['fallbackCharacter'] ?? '-');
if (($value[0] ?? '') !== '/' && $this->prependSlashInSlug) {
$value = '/' . $value;
}
return $value;
}
/**
* Extracts payload of slug and removes wrapping delimiters,
* e.g. `/hello/world/` will become `hello/world`.
*/
public function extract(string $slug): string
{
// Convert some special tokens (space, "_" and "-") to the space character
$fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
return trim($slug, $fallbackCharacter . '/');
}
/**
* Used when no slug exists for a record
*
* @param int $pid The uid of the page to generate the slug for
*/
public function generate(array $recordData, int $pid): string
{
if ($this->tableName === 'pages' && ($pid === 0 || !empty($recordData['is_siteroot']))) {
return '/';
}
$prefix = '';
if ($this->tableName === 'pages' && ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false)) {
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName);
$languageFieldName = null;
if ($schema->isLanguageAware()) {
$languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
}
$languageId = (int)($recordData[$languageFieldName ?? ''] ?? 0);
$parentPageRecord = $this->resolveParentPageRecord($pid, $languageId);
if (is_array($parentPageRecord)) {
// If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
if (!empty($parentPageRecord['slug'])) {
$rootLineItemSlug = $parentPageRecord['slug'];
} else {
$rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
}
$rootLineItemSlug = trim($rootLineItemSlug, '/');
if (!empty($rootLineItemSlug)) {
$prefix = $rootLineItemSlug;
}
}
}
$fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
$slugParts = [];
$replaceConfiguration = $this->configuration['generatorOptions']['replacements'] ?? [];
$regexReplaceConfiguration = $this->configuration['generatorOptions']['regexReplacements'] ?? [];
foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldNameParts) {
if (is_string($fieldNameParts)) {
$fieldNameParts = GeneralUtility::trimExplode(',', $fieldNameParts);
}
foreach ($fieldNameParts as $fieldName) {
if (!empty($recordData[$fieldName])) {
$pieceOfSlug = (string)$recordData[$fieldName];
foreach ($regexReplaceConfiguration as $pattern => $replacement) {
$replacedPieceOfSlug = @preg_replace(
$pattern,
$replacement,
$pieceOfSlug
);
if (is_string($replacedPieceOfSlug)) {
$pieceOfSlug = $replacedPieceOfSlug;
}
}
$pieceOfSlug = str_replace(
array_keys($replaceConfiguration),
array_values($replaceConfiguration),
$pieceOfSlug
);
$slugParts[] = $pieceOfSlug;
break;
}
}
}
$slug = implode($fieldSeparator, $slugParts);
$slug = $this->sanitize($slug);
// No valid data found
if ($slug === '' || $slug === '/') {
$slug = 'default-' . md5((string)json_encode($recordData));
}
if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') {
$slug = '/' . $slug;
}
if (!empty($prefix)) {
$slug = $prefix . $slug;
}
// Hook for alternative ways of filling/modifying the slug data
foreach ($this->configuration['generatorOptions']['postModifiers'] ?? [] as $funcName) {
$hookParameters = [
'slug' => $slug,
'workspaceId' => $this->workspaceId,
'configuration' => $this->configuration,
'record' => $recordData,
'pid' => $pid,
'prefix' => $prefix,
'tableName' => $this->tableName,
'fieldName' => $this->fieldName,
];
$slug = GeneralUtility::callUserFunction($funcName, $hookParameters, $this);
}
return $this->sanitize($slug);
}
/**
* Checks if there are other records with the same slug that are located on the same PID.
*/
public function isUniqueInPid(string $slug, RecordState $state): bool
{
$pageId = (int)$state->resolveNodeIdentifier();
$recordId = $state->getSubject()->getIdentifier();
$languageId = $state->getContext()->getLanguageId();
$queryBuilder = $this->createPreparedQueryBuilder();
$this->applySlugConstraint($queryBuilder, $slug);
$this->applyPageIdConstraint($queryBuilder, $pageId);
$this->applyRecordConstraint($queryBuilder, $recordId);
$this->applyLanguageConstraint($queryBuilder, $languageId);
$this->applyWorkspaceConstraint($queryBuilder, $state);
$statement = $queryBuilder->executeQuery();
$records = $this->resolveVersionOverlays(
$statement->fetchAllAssociative()
);
return count($records) === 0;
}
/**
* Check if there are other records with the same slug that are located on the same site.
*
* @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
*/
public function isUniqueInSite(string $slug, RecordState $state): bool
{
$pageId = $state->resolveNodeAggregateIdentifier();
$recordId = $state->getSubject()->getIdentifier();
$languageId = $state->getContext()->getLanguageId();
if (!MathUtility::canBeInterpretedAsInteger($pageId)) {
// If this is a new page, we use the parent page to resolve the site
$pageId = $state->getNode()->getIdentifier();
}
$pageId = (int)$pageId;
$queryBuilder = $this->createPreparedQueryBuilder();
$this->applySlugConstraint($queryBuilder, $slug);
$this->applyRecordConstraint($queryBuilder, $recordId);
$this->applyLanguageConstraint($queryBuilder, $languageId);
$this->applyWorkspaceConstraint($queryBuilder, $state);
$statement = $queryBuilder->executeQuery();
$records = $this->resolveVersionOverlays(
$statement->fetchAllAssociative()
);
if (count($records) === 0) {
return true;
}
// The installation contains at least ONE other record with the same slug
// Now find out if it is the same root page ID
$this->flushRootLineCaches();
$siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
try {
$siteOfCurrentRecord = $siteFinder->getSiteByPageId($pageId);
} catch (SiteNotFoundException $e) {
// Not within a site, so nothing to do
// @todo: Rather than silently ignoring this misconfiguration,
// a warning should be thrown here, or maybe even let the
// exception bubble up and catch it in places that uses this API
return true;
}
foreach ($records as $record) {
try {
$recordState = RecordStateFactory::forName($this->tableName)->fromArray($record);
$siteOfExistingRecord = $siteFinder->getSiteByPageId(
(int)$recordState->resolveNodeAggregateIdentifier()
);
} catch (SiteNotFoundException $exception) {
// In case not site is found, the record is not
// organized in any site
continue;
}
if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
return false;
}
}
// Otherwise, everything is still fine
return true;
}
/**
* Check if there are other records with the same slug.
*
* @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
*/
public function isUniqueInTable(string $slug, RecordState $state): bool
{
$recordId = $state->getSubject()->getIdentifier();
$languageId = $state->getContext()->getLanguageId();
$queryBuilder = $this->createPreparedQueryBuilder();
$this->applySlugConstraint($queryBuilder, $slug);
$this->applyRecordConstraint($queryBuilder, $recordId);
$this->applyLanguageConstraint($queryBuilder, $languageId);
$this->applyWorkspaceConstraint($queryBuilder, $state);
$statement = $queryBuilder->executeQuery();
$records = $this->resolveVersionOverlays(
$statement->fetchAllAssociative()
);
return count($records) === 0;
}
/**
* Ensure root line caches are flushed to avoid any issue regarding moving of pages or dynamically creating
* sites while managing slugs at the same request
*/
protected function flushRootLineCaches(): void
{
$cacheManager = GeneralUtility::makeInstance(CacheManager::class);
$cacheManager->getCache('runtime')->flushByTag(RootlineUtility::RUNTIME_CACHE_TAG);
$cacheManager->getCache('rootline')->flush();
}
/**
* Generate a slug with a suffix "/mytitle-1" if that is in use already.
*
* @param string $slug proposed slug
* @param callable $isUnique Callback to check for uniqueness
* @throws SiteNotFoundException
*/
protected function buildSlug(string $slug, RecordState $state, callable $isUnique): string
{
$slug = $this->sanitize($slug);
$rawValue = $this->extract($slug);
$newValue = $slug;
$counter = 0;
while (
!$isUnique($newValue, $state)
&& ++$counter <= 100
) {
$newValue = $this->sanitize($rawValue . '-' . $counter);
}
if ($counter === 100) {
$uniqueId = StringUtility::getUniqueId();
$newValue = $this->sanitize($rawValue . '-' . md5($uniqueId));
}
return $newValue;
}
/**
* Generate a slug with a suffix "/mytitle-1" if that is in use already.
*
* @param string $slug proposed slug
* @throws SiteNotFoundException
*/
public function buildSlugForUniqueInSite(string $slug, RecordState $state): string
{
return $this->buildSlug($slug, $state, [$this, 'isUniqueInSite']);
}
/**
* Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
*
* @param string $slug proposed slug
*/
public function buildSlugForUniqueInPid(string $slug, RecordState $state): string
{
return $this->buildSlug($slug, $state, [$this, 'isUniqueInPid']);
}
/**
* Generate a slug with a suffix "/mytitle-1" if that is in use already.
*
* @param string $slug proposed slug
* @throws SiteNotFoundException
*/
public function buildSlugForUniqueInTable(string $slug, RecordState $state): string
{
return $this->buildSlug($slug, $state, [$this, 'isUniqueInTable']);
}
protected function createPreparedQueryBuilder(): QueryBuilder
{
$fieldNames = ['uid', 'pid', $this->fieldName];
if ($this->workspaceEnabled) {
$fieldNames[] = 't3ver_state';
$fieldNames[] = 't3ver_oid';
}
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName);
if ($schema->isLanguageAware()) {
$fieldNames[] = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$fieldNames[] = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$queryBuilder
->select(...$fieldNames)
->from($this->tableName);
return $queryBuilder;
}
protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder, RecordState $state)
{
if (!$this->workspaceEnabled) {
return;
}
$queryBuilder->getRestrictions()->add(
GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->workspaceId)
);
// Exclude the online record of a versioned record
if ($state->getVersionLink()) {
$queryBuilder->andWhere(
$queryBuilder->expr()->neq('uid', $state->getVersionLink()->getSubject()->getIdentifier())
);
}
}
/**
* Apply constraint to fetch records with same language (Slug / language should be unique).
* If language is -1 (all languages), there should not be any other records with the
* same slug of any language (or -1).
*/
protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
{
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName);
if (!$schema->isLanguageAware()) {
return;
}
if ($languageId === -1) {
// if language is -1 "all languages" we need to check against all languages, thus not adding
// any kind of language constraints.
return;
}
$languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
// Only check records of the given language or -1 (all languages)
$queryBuilder->andWhere(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq(
$languageFieldName,
$queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
$languageFieldName,
$queryBuilder->createNamedParameter(-1, Connection::PARAM_INT)
)
)
);
}
protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
{
$queryBuilder->where(
$queryBuilder->expr()->eq(
$this->fieldName,
$queryBuilder->createNamedParameter($slug)
)
);
}
protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
{
if ($pageId < 0) {
throw new \RuntimeException(
sprintf(
'Page id must be positive "%d"',
$pageId
),
1534962573
);
}
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
)
);
}
/**
* @param string|int $recordId
*/
protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
{
// Exclude the current record if it is an existing record
if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
return;
}
$queryBuilder->andWhere(
$queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, Connection::PARAM_INT))
);
if ($this->workspaceId > 0 && $this->workspaceEnabled) {
$liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, (int)$recordId) ?? $recordId;
$queryBuilder->andWhere(
$queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, Connection::PARAM_INT))
);
}
}
protected function resolveVersionOverlays(array $records): array
{
if (!$this->workspaceEnabled) {
return $records;
}
// filters out non-records (`null` or empty array `[]`)
return array_filter(
// performs workspace overlay and sanitization on each record
array_map(
function (array $record): ?array {
BackendUtility::workspaceOL(
$this->tableName,
$record,
$this->workspaceId,
true
);
if (!is_array($record)) {
return null;
}
if (VersionState::tryFrom($record['t3ver_state'] ?? 0)
=== VersionState::DELETE_PLACEHOLDER) {
return null;
}
return $record;
},
$records
)
);
}
/**
* Fetch a parent page, but exclude spacers and sys-folders
*/
protected function resolveParentPageRecord(int $pid, int $languageId): ?array
{
$rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
$excludeDokTypes = [
PageRepository::DOKTYPE_SPACER,
PageRepository::DOKTYPE_SYSFOLDER,
];
do {
$parentPageRecord = array_shift($rootLine);
// exclude spacers, recyclers and folders
} while (!empty($rootLine) && in_array((int)$parentPageRecord['doktype'], $excludeDokTypes, true));
if ($languageId > 0) {
$languageIds = [$languageId];
$siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
try {
$site = $siteFinder->getSiteByPageId($pid);
$siteLanguage = $site->getLanguageById($languageId);
$languageIds = array_merge($languageIds, $siteLanguage->getFallbackLanguageIds());
} catch (SiteNotFoundException|\InvalidArgumentException $e) {
// no site or requested language available - move on
}
/** @var LocalizationRepository $localizationRepository */
$localizationRepository = GeneralUtility::makeInstance(LocalizationRepository::class);
foreach ($languageIds as $languageId) {
$localizedParentPageRecord = $localizationRepository->getPageTranslations(
$parentPageRecord['uid'],
[$languageId],
$this->workspaceId
);
if ($localizedParentPageRecord !== []) {
$parentPageRecord = reset($localizedParentPageRecord)->toArray();
break;
}
}
}
return $parentPageRecord;
}
}
@@ -0,0 +1,59 @@
<?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\Core\DataHandling\SoftReference;
/**
* A generic parser class useful if tokenID prefixes are needed.
*/
abstract class AbstractSoftReferenceParser implements SoftReferenceParserInterface
{
protected string $tokenID_basePrefix = '';
protected string $parserKey = '';
protected array $parameters = [];
/**
* Make Token ID for input index.
*
* @param string $index Suffix value.
* @return string Token ID
*/
public function makeTokenID(string $index = ''): string
{
return md5($this->tokenID_basePrefix . ':' . $index);
}
/**
* @param string $parserKey The softref parser key.
* @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by ";
*/
public function setParserKey(string $parserKey, array $parameters): void
{
$this->parserKey = $parserKey;
$this->parameters = $parameters;
}
public function getParserKey(): string
{
return $this->parserKey;
}
protected function setTokenIdBasePrefix(string $table, string $uid, string $field, string $structurePath): void
{
$this->tokenID_basePrefix = implode(':', [$table, $uid, $field, $structurePath, $this->getParserKey()]);
}
}
@@ -0,0 +1,56 @@
<?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\Core\DataHandling\SoftReference;
/**
* Finding email addresses in content and making them substitutable.
*/
class EmailSoftReferenceParser extends AbstractSoftReferenceParser
{
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath);
$elements = [];
// Email:
$parts = preg_split('/([\s\'":<>]+)([A-Za-z0-9._-]+[^-][@][A-Za-z0-9._-]+[.].[A-Za-z0-9]+)/', ' ' . $content . ' ', 10000, PREG_SPLIT_DELIM_CAPTURE);
foreach ($parts as $idx => $value) {
if ($idx % 3 === 2) {
// Ignore invalid emails, which haven't been filtered out by regex.
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
continue;
}
$tokenID = $this->makeTokenID((string)$idx);
$elements[$idx] = [];
$elements[$idx]['matchString'] = $value;
if (in_array('subst', $this->parameters, true)) {
$parts[$idx] = '{softref:' . $tokenID . '}';
$elements[$idx]['subst'] = [
'type' => 'string',
'tokenID' => $tokenID,
'tokenValue' => $value,
];
}
}
}
return SoftReferenceParserResult::create(
substr(implode('', $parts), 1, -1),
$elements
);
}
}
@@ -0,0 +1,61 @@
<?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\Core\DataHandling\SoftReference;
/**
* Finding reference to files from extensions in content, but only to notify about their existence. No substitution
*/
class ExtensionPathSoftReferenceParser implements SoftReferenceParserInterface
{
private const string REGEXP = '/([^[:alnum:]]+)(EXT:[[:alnum:]_]+\\/[^[:space:]"\',]*)/';
protected string $parserKey = '';
protected array $parameters = [];
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$elements = [];
// Files starting with EXT:
$parts = preg_split(self::REGEXP, ' ' . $content . ' ', 10000, PREG_SPLIT_DELIM_CAPTURE) ?: [];
foreach ($parts as $idx => $value) {
if ($idx % 3 === 2) {
$elements[$idx] = [];
$elements[$idx]['matchString'] = $value;
}
}
return SoftReferenceParserResult::create(
substr(implode('', $parts), 1, -1),
$elements
);
}
/**
* @param string $parserKey The softref parser key.
* @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by ";
*/
public function setParserKey(string $parserKey, array $parameters): void
{
$this->parserKey = $parserKey;
$this->parameters = $parameters;
}
public function getParserKey(): string
{
return $this->parserKey;
}
}
@@ -0,0 +1,144 @@
<?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\Core\DataHandling\SoftReference;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
#[Autoconfigure(public: true)]
class SoftReferenceParserFactory
{
protected array $softReferenceParsers = [];
public function __construct(
#[Autowire(service: 'cache.runtime')]
protected readonly FrontendInterface $runtimeCache,
protected readonly LoggerInterface $logger,
) {}
/**
* Adds a parser via DI.
*
* @internal
*/
public function addParser(SoftReferenceParserInterface $softReferenceParser, string $parserKey): void
{
if (!isset($this->softReferenceParsers[$parserKey])) {
$this->softReferenceParsers[$parserKey] = $softReferenceParser;
}
}
/**
* Returns array of soft parser references
*
* @param string $parserList softRef parser list
* @return array|null Array where the parser key is the key and the value is the parameter string, FALSE if no parsers were found
*/
protected function explodeSoftRefParserList(string $parserList): ?array
{
// Return immediately if list is blank:
if ($parserList === '') {
return null;
}
$cacheId = 'backend-softRefList-' . md5($parserList);
$parserListCache = $this->runtimeCache->get($cacheId);
if ($parserListCache !== false) {
return $parserListCache;
}
// Otherwise parse the list:
$keyList = GeneralUtility::trimExplode(',', $parserList, true);
$output = [];
foreach ($keyList as $val) {
$reg = [];
if (preg_match('/^([[:alnum:]_-]+)\\[(.*)\\]$/', $val, $reg)) {
$output[$reg[1]] = GeneralUtility::trimExplode(';', $reg[2], true);
} else {
$output[$val] = '';
}
}
$this->runtimeCache->set($cacheId, $output);
return $output;
}
/**
* @param array|null $forcedParameters
* @return iterable<SoftReferenceParserInterface>
*/
public function getParsersBySoftRefParserList(string $softRefParserList, ?array $forcedParameters = null): iterable
{
foreach ($this->explodeSoftRefParserList($softRefParserList) ?? [] as $parserKey => $parameters) {
if (!is_array($parameters)) {
$parameters = $forcedParameters ?? [];
}
if (!$this->hasSoftReferenceParser($parserKey)) {
$this->logger->warning('No soft reference parser exists for the key "{parserKey}".', ['parserKey' => $parserKey]);
continue;
}
$parser = $this->getSoftReferenceParser($parserKey);
$parser->setParserKey($parserKey, $parameters);
yield $parser;
}
}
public function hasSoftReferenceParser(string $softReferenceParserKey): bool
{
return isset($this->softReferenceParsers[$softReferenceParserKey]);
}
/**
* Get a Soft Reference Parser by the given soft reference key.
* Implementation must be registered in Configuration/Services.yaml
*
* VENDOR\YourExtension\SoftReference\UserDefinedSoftReferenceParser:
* tags:
* - name: softreference.parser
* parserKey: userdefined
*/
public function getSoftReferenceParser(string $softReferenceParserKey): SoftReferenceParserInterface
{
if ($softReferenceParserKey === '') {
throw new \InvalidArgumentException(
'The soft reference parser key cannot be empty.',
1627899274
);
}
if (!$this->hasSoftReferenceParser($softReferenceParserKey)) {
throw new \OutOfRangeException(
sprintf('No soft reference parser found for "%s".', $softReferenceParserKey),
1627899342
);
}
return $this->softReferenceParsers[$softReferenceParserKey];
}
/**
* Get all registered soft reference parsers
*/
public function getSoftReferenceParsers(): array
{
return $this->softReferenceParsers;
}
}
@@ -0,0 +1,58 @@
<?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\Core\DataHandling\SoftReference;
/**
* Soft Reference parsing interface
*
* "Soft References" are references to database elements, files, email addresses, URLs etc.
* which are found in-text in content. The <a href="t3://page?[page_id]> tag from typical bodytext fields
* is an example of this.
* This interface defines the "parse" method, which parsers have to implement.
* TYPO3 has already implemented parsers for the most well-known types. Soft Reference Parsers can also be user-defined.
* The Soft Reference Parsers are used by the system to find these references and process them accordingly in import/export actions and copy operations.
*/
interface SoftReferenceParserInterface
{
/**
* Main function through which can parse content for a specific field.
*
* @param string $table Database table name
* @param string $field Field name for which processing occurs
* @param int $uid UID of the record
* @param string $content The content/value of the field
* @param string $structurePath If running from inside a FlexForm structure, this is the path of the tag.
* @return SoftReferenceParserResult Result object on positive matches, see description above.
* @see SoftReferenceParserResult
*/
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult;
/**
* The two properties parserKey and parameters may be set to generate a unique token ID from them.
* This is not needed for every parser, but useful if a parser can deal with multiple parser keys.
*
* @param string $parserKey The softref parser key.
* @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by ";
*/
public function setParserKey(string $parserKey, array $parameters): void;
/**
* Returns the parser key, which was previously set by "setParserKey"
*/
public function getParserKey(): string;
}
@@ -0,0 +1,87 @@
<?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\Core\DataHandling\SoftReference;
/**
* The result object has two properties: "content" and matched elements.
*
* content:
* Is a string containing the input content but possibly with tokens inside.
* Tokens are strings like {softref:[tokenID]}, which is a placeholder for a value extracted by a softref parser.
* For each token there MUST be an entry in the "elements" key, which has a "subst" key defining the tokenID and the tokenValue. See below.
*
* matched elements:
* is an array where the keys are insignificant, but the values are arrays with these keys:
* "matchString" => // The value of the match. This is only for informational purposes to show what was found.
* "error" => // An error message can be set here, like "file not found" etc.
* "subst" => [ // If this array is found there MUST be a token in the output content as well!
* "tokenID" => // The tokenID string corresponding to the token in output content, {softref:[tokenID]}. This is typically an md5 hash of a string defining uniquely the position of the element.
* "tokenValue" => // The value that the token substitutes in the text. Basically, if this value is inserted instead of the token the content should match what was inputted originally.
* "type" => // file / db / string = the type of substitution. "file" means it is a relative file [automatically mapped], "db" means a database record reference [automatically mapped], "string" means it is manually modified string content (eg. an email address)
* "relFileName" => // (for "file" type): Relative filename. May not necessarily exist. This could be noticed in the error key.
* "recordRef" => // (for "db" type) : Reference to DB record on the form [table]:[uid]. May not necessarily exist.
* "title" => // Title of element (for backend information)
* "description" => // Description of element (for backend information)
* ]
*/
final class SoftReferenceParserResult
{
private string $content = '';
private array $elements = [];
private bool $hasMatched = false;
public static function create(string $content, array $elements): self
{
if ($elements === []) {
return self::createWithoutMatches();
}
$obj = new self();
$obj->content = $content;
$obj->elements = $elements;
$obj->hasMatched = true;
return $obj;
}
public static function createWithoutMatches(): self
{
// @todo: set protected, use create() with empty elements instead.
return new self();
}
public function hasMatched(): bool
{
return $this->hasMatched;
}
public function hasContent(): bool
{
return $this->content !== '';
}
public function getContent(): string
{
return $this->content;
}
public function getMatchedElements(): array
{
return $this->elements;
}
}
@@ -0,0 +1,44 @@
<?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\Core\DataHandling\SoftReference;
/**
* A full field value targeted for manual substitution (for import /export features)
*/
class SubstituteSoftReferenceParser extends AbstractSoftReferenceParser
{
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath);
$tokenID = $this->makeTokenID();
return SoftReferenceParserResult::create(
'{softref:' . $tokenID . '}',
[
[
'matchString' => $content,
'subst' => [
'type' => 'string',
'tokenID' => $tokenID,
'tokenValue' => $content,
],
],
]
);
}
}
@@ -0,0 +1,299 @@
<?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\Core\DataHandling\SoftReference;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\Event\AppendLinkHandlerElementsEvent;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Resource\AbstractFile;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* TypoLink value processing.
* Will process input value as a TypoLink value.
* References to page id or file, possibly with anchor/target, possibly commaseparated list.
*/
class TypolinkSoftReferenceParser extends AbstractSoftReferenceParser
{
protected EventDispatcherInterface $eventDispatcher;
public function __construct(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath);
// First, split the input string by a comma if the "linkList" parameter is set.
// An example: the link field for images in content elements of type "textpic" or "image". This field CAN be configured to define a link per image, separated by comma.
if (in_array('linkList', $this->parameters, true)) {
// Preserving whitespace on purpose.
$linkElement = explode(',', $content);
} else {
// If only one element, just set in this array to make it easy below.
$linkElement = [$content];
}
// Traverse the links now:
$elements = [];
foreach ($linkElement as $k => $typolinkValue) {
$tLP = $this->getTypoLinkParts($typolinkValue, $table, $uid);
$linkElement[$k] = $this->setTypoLinkPartsElement($tLP, $elements, $typolinkValue, $k);
}
return SoftReferenceParserResult::create(
implode(',', $linkElement),
$elements
);
}
/**
* Analyze content as a TypoLink value and return an array with properties.
* TypoLinks format is: <link [typolink] [browser target] [css class] [title attribute] [additionalParams]>.
* See TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::typolink()
* The syntax of the [typolink] part is: [typolink] = [page id][,[type value]][#[anchor, if integer = tt_content uid]]
* The extraction is based on how \TYPO3\CMS\Frontend\ContentObject::typolink() behaves.
*
* @param string $typolinkValue TypoLink value.
* @param string $referenceTable The reference table
* @param int $referenceUid The UID of the reference record
* @return array Array with the properties of the input link specified. The key "type" will reveal the type. If that is blank it could not be determined.
* @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::typolink()
* @see setTypoLinkPartsElement()
*/
protected function getTypoLinkParts(string $typolinkValue, string $referenceTable, int $referenceUid)
{
$finalTagParts = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode($typolinkValue);
$link_param = $finalTagParts['url'];
// we define various keys below, "url" might be misleading
unset($finalTagParts['url']);
if (stripos(rawurldecode(trim($link_param)), 'phar://') === 0) {
throw new \RuntimeException(
'phar scheme not allowed as soft reference target',
1530030672
);
}
$linkService = GeneralUtility::makeInstance(LinkService::class);
try {
$linkData = $linkService->resolve($link_param);
switch ($linkData['type']) {
case LinkService::TYPE_RECORD:
$referencePageId = $referenceTable === 'pages'
? $referenceUid
: (int)(BackendUtility::getRecord($referenceTable, $referenceUid)['pid'] ?? 0);
if ($referencePageId) {
$pageTsConfig = BackendUtility::getPagesTSconfig($referencePageId);
$table = $pageTsConfig['TCEMAIN.']['linkHandler.'][$linkData['identifier'] . '.']['configuration.']['table'] ?? $linkData['identifier'];
} else {
// Backwards compatibility for the old behaviour, where the identifier was saved as the table.
$table = $linkData['identifier'];
}
$finalTagParts['table'] = $table;
$finalTagParts['uid'] = $linkData['uid'];
break;
case LinkService::TYPE_PAGE:
$linkData['pageuid'] = (int)($linkData['pageuid'] ?? 0);
if (isset($linkData['pagetype'])) {
$linkData['pagetype'] = (int)$linkData['pagetype'];
}
if (isset($linkData['fragment'])) {
$finalTagParts['anchor'] = $linkData['fragment'];
}
break;
case LinkService::TYPE_FILE:
case LinkService::TYPE_UNKNOWN:
if (isset($linkData['file'])) {
$finalTagParts['type'] = LinkService::TYPE_FILE;
$linkData['file'] = $linkData['file'] instanceof AbstractFile ? $linkData['file']->getUid() : $linkData['file'];
} else {
$pU = parse_url($link_param);
parse_str($pU['query'] ?? '', $query);
if (isset($query['uid'])) {
$finalTagParts['type'] = LinkService::TYPE_FILE;
$finalTagParts['file'] = (int)$query['uid'];
}
}
break;
}
return array_merge($finalTagParts, $linkData);
} catch (UnknownLinkHandlerException $e) {
// Cannot handle anything
return $finalTagParts;
}
}
/**
* Recompile a TypoLink value from the array of properties made with getTypoLinkParts() into an elements array
*
* @param array $tLP TypoLink properties
* @param array $elements Array of elements to be modified with substitution / information entries.
* @param string $content The content to process.
* @param int $idx Index value of the found element - user to make unique but stable tokenID
* @return string The input content, possibly containing tokens now according to the added substitution entries in $elements
* @see getTypoLinkParts()
*/
protected function setTypoLinkPartsElement($tLP, &$elements, $content, $idx)
{
// Initialize, set basic values. In any case a link will be shown
$tokenID = $this->makeTokenID('setTypoLinkPartsElement:' . $idx);
$elements[$tokenID . ':' . $idx] = [];
$elements[$tokenID . ':' . $idx]['matchString'] = $content;
// Based on link type, maybe do more:
switch ((string)($tLP['type'] ?? '')) {
case LinkService::TYPE_EMAIL:
// Mail addresses can be substituted manually:
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'string',
'tokenID' => $tokenID,
'tokenValue' => (string)($tLP['email'] ?? ''),
];
// Output content will be the token instead:
$content = '{softref:' . $tokenID . '}';
break;
case LinkService::TYPE_TELEPHONE:
// phone number can be substituted manually:
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'string',
'tokenID' => $tokenID,
'tokenValue' => (string)($tLP['telephone'] ?? ''),
];
// Output content will be the token instead:
$content = '{softref:' . $tokenID . '}';
break;
case LinkService::TYPE_URL:
// URLs can be substituted manually
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'external',
'tokenID' => $tokenID,
'tokenValue' => (string)($tLP['url'] ?? ''),
];
// Output content will be the token instead:
$content = '{softref:' . $tokenID . '}';
break;
case LinkService::TYPE_FOLDER:
// This is a link to a folder...
unset($elements[$tokenID . ':' . $idx]);
return $content;
case LinkService::TYPE_FILE:
// Process files referenced by their FAL uid
if (isset($tLP['file'])) {
$fileId = $tLP['file'] instanceof AbstractFile ? $tLP['file']->getUid() : $tLP['file'];
// Token and substitute value
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'db',
'recordRef' => 'sys_file:' . $fileId,
'tokenID' => $tokenID,
'tokenValue' => 'file:' . $fileId,
];
// Output content will be the token instead:
$content = '{softref:' . $tokenID . '}';
} elseif ($tLP['identifier'] ?? false) {
$linkHandlerValue = explode(':', trim($tLP['identifier']), 2)[1];
if (MathUtility::canBeInterpretedAsInteger($linkHandlerValue)) {
// Token and substitute value
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'db',
'recordRef' => 'sys_file:' . $linkHandlerValue,
'tokenID' => $tokenID,
'tokenValue' => (string)$tLP['identifier'],
];
// Output content will be the token instead:
$content = '{softref:' . $tokenID . '}';
} else {
// This is a link to a folder...
return $content;
}
} else {
return $content;
}
break;
case LinkService::TYPE_PAGE:
// Rebuild page reference typolink part:
$content = '';
// Set page id:
if ($tLP['pageuid']) {
$content .= '{softref:' . $tokenID . '}';
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'db',
'recordRef' => 'pages:' . $tLP['pageuid'],
'tokenID' => $tokenID,
'tokenValue' => (string)$tLP['pageuid'],
];
}
// Add type if applicable
if ((string)($tLP['pagetype'] ?? '') !== '') {
$content .= ',' . $tLP['pagetype'];
}
// Add anchor if applicable
if ((string)($tLP['anchor'] ?? '') !== '') {
// Anchor is assumed to point to a content elements:
if (MathUtility::canBeInterpretedAsInteger($tLP['anchor'])) {
// Initialize a new entry because we have a new relation:
$newTokenID = $this->makeTokenID('setTypoLinkPartsElement:anchor:' . $idx);
$elements[$newTokenID . ':' . $idx] = [];
$elements[$newTokenID . ':' . $idx]['matchString'] = 'Anchor Content Element: ' . $tLP['anchor'];
$content .= '#{softref:' . $newTokenID . '}';
$elements[$newTokenID . ':' . $idx]['subst'] = [
'type' => 'db',
'recordRef' => 'tt_content:' . $tLP['anchor'],
'tokenID' => $newTokenID,
'tokenValue' => (string)$tLP['anchor'],
];
} else {
// Anchor is a hardcoded string
$content .= '#' . $tLP['anchor'];
}
}
break;
case LinkService::TYPE_RECORD:
$elements[$tokenID . ':' . $idx]['subst'] = [
'type' => 'db',
'recordRef' => $tLP['table'] . ':' . $tLP['uid'],
'tokenID' => $tokenID,
'tokenValue' => (string)$content,
];
$content = '{softref:' . $tokenID . '}';
break;
default:
$event = new AppendLinkHandlerElementsEvent($tLP, $content, $elements, $idx, $tokenID);
$this->eventDispatcher->dispatch($event);
$elements = $event->getElements();
$tLP = $event->getLinkParts();
$content = $event->getContent();
if (!$event->isResolved()) {
$elements[$tokenID . ':' . $idx]['error'] = 'Couldn\'t decide typolink mode.';
return $content;
}
}
// Finally, for all entries that was rebuild with tokens, add target, class, title and additionalParams in the end
$tLP['url'] = $content;
// Return rebuilt typolink value
return GeneralUtility::makeInstance(TypoLinkCodecService::class)->encode($tLP);
}
}
@@ -0,0 +1,144 @@
<?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\Core\DataHandling\SoftReference;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\DataHandling\Event\AppendLinkHandlerElementsEvent;
use TYPO3\CMS\Core\Html\HtmlParser;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* TypoLink tag processing.
* Will search for <link ...> and <a> tags in the content string and process any found.
*/
class TypolinkTagSoftReferenceParser extends AbstractSoftReferenceParser
{
protected EventDispatcherInterface $eventDispatcher;
public function __construct(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath);
// Parse string for special TYPO3 <link> tag:
$htmlParser = GeneralUtility::makeInstance(HtmlParser::class);
$linkService = GeneralUtility::makeInstance(LinkService::class);
$linkTags = $htmlParser->splitTags('a', $content);
// Traverse result:
$elements = [];
foreach ($linkTags as $key => $foundValue) {
if ($key % 2 && preg_match('/href="([^"]+)"/', $foundValue, $matches)) {
try {
$linkDetails = $linkService->resolve($matches[1]);
if ($linkDetails['type'] === LinkService::TYPE_FILE && preg_match('/file\?uid=(\d+)/', $matches[1], $fileIdMatch)) {
$token = $this->makeTokenID((string)$key);
$elements[$key]['matchString'] = $foundValue;
$linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue);
$elements[$key]['subst'] = [
'type' => 'db',
'recordRef' => 'sys_file:' . $fileIdMatch[1],
'tokenID' => $token,
'tokenValue' => 'file:' . ($linkDetails['file'] instanceof File ? $linkDetails['file']->getUid() : $fileIdMatch[1]),
];
} elseif ($linkDetails['type'] === LinkService::TYPE_PAGE && preg_match('/page\?[^#]*\buid=(\d+)(?:[^#]*#(\d+))?/', $matches[1], $pageAndAnchorMatches)) {
$token = $this->makeTokenID((string)$key);
$content = '{softref:' . $token . '}';
$elements[$key]['matchString'] = $foundValue;
$elements[$key]['subst'] = [
'type' => 'db',
'recordRef' => 'pages:' . ($linkDetails['pageuid'] ?? 0),
'tokenID' => $token,
'tokenValue' => $linkDetails['pageuid'] ?? '',
];
if (isset($pageAndAnchorMatches[2])) {
// Anchor is assumed to point to a content elements:
if (MathUtility::canBeInterpretedAsInteger($pageAndAnchorMatches[2])) {
// Initialize a new entry because we have a new relation:
$newTokenID = $this->makeTokenID('setTypoLinkPartsElement:anchor:' . $key);
$elements[$newTokenID . ':' . $key] = [];
$elements[$newTokenID . ':' . $key]['matchString'] = 'Anchor Content Element: ' . $pageAndAnchorMatches[2];
$content .= '#{softref:' . $newTokenID . '}';
$elements[$newTokenID . ':' . $key]['subst'] = [
'type' => 'db',
'recordRef' => 'tt_content:' . $pageAndAnchorMatches[2],
'tokenID' => $newTokenID,
'tokenValue' => $pageAndAnchorMatches[2],
];
} else {
// Anchor is a hardcoded string
$content .= '#' . $pageAndAnchorMatches[2];
}
}
$linkTags[$key] = str_replace($matches[1], $content, $foundValue);
} elseif ($linkDetails['type'] === LinkService::TYPE_URL) {
$token = $this->makeTokenID((string)$key);
$elements[$key]['matchString'] = $foundValue;
$linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue);
$elements[$key]['subst'] = [
'type' => 'external',
'tokenID' => $token,
'tokenValue' => (string)($linkDetails['url'] ?? ''),
];
} elseif ($linkDetails['type'] === LinkService::TYPE_EMAIL) {
$token = $this->makeTokenID((string)$key);
$elements[$key]['matchString'] = $foundValue;
$linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue);
$elements[$key]['subst'] = [
'type' => 'string',
'tokenID' => $token,
'tokenValue' => (string)($linkDetails['email'] ?? ''),
];
} elseif ($linkDetails['type'] === LinkService::TYPE_TELEPHONE) {
$token = $this->makeTokenID((string)$key);
$elements[$key]['matchString'] = $foundValue;
$linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue);
$elements[$key]['subst'] = [
'type' => 'string',
'tokenID' => $token,
'tokenValue' => (string)($linkDetails['telephone'] ?? ''),
];
} else {
$token = $this->makeTokenID((string)$key);
$event = new AppendLinkHandlerElementsEvent($linkDetails, $content, $elements, $key, $token);
$this->eventDispatcher->dispatch($event);
if (!$event->isResolved()) {
continue;
}
$elements = $event->getElements();
}
} catch (\Exception $e) {
// skip invalid links
}
}
}
// Return output:
return SoftReferenceParserResult::create(
implode('', $linkTags),
$elements
);
}
}
@@ -0,0 +1,71 @@
<?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\Core\DataHandling\SoftReference;
/**
* Finding URLs in content
*/
class UrlSoftReferenceParser extends AbstractSoftReferenceParser
{
/**
* We do not use a-z for letters, so we can also allow unicode characters. For this purpose we use \p{L}, for example.
* And we must use the modifier "u" in the regex.
* Domains may contain umlauts (ä,ö,ü).
*
* PHP regex with unicode character properties:
* \p{L}: letter
* \p{Ll}: lower case letter
* @see https://www.php.net/manual/en/regexp.reference.unicode.php
*/
protected const REGEXP = '/([^[:alnum:]\'"=]+|\\s+)((https?|ftp):\\/\\/[!#$&-;=?-\[\]_\\p{L}~](?:[!#$&-;=?-\[\]_\\p{L}~]+|%[0-9\\p{L}]{2})*)/u';
public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult
{
$elements = [];
$modifiedContent = ' ' . $content . ' ';
// Find all URLs using preg_match_all
$matches = [];
if (preg_match_all(self::REGEXP, $modifiedContent, $matches, PREG_SET_ORDER)) {
// Process each match
foreach ($matches as $idx => $match) {
$prefix = $match[1];
$url = $match[2];
$tokenID = $this->makeTokenID((string)$idx);
$elements[$idx] = [];
$elements[$idx]['matchString'] = $url;
if (in_array('subst', $this->parameters, true)) {
// Replace the URL with a token in the content
$modifiedContent = str_replace($prefix . $url, $prefix . '{softref:' . $tokenID . '}', $modifiedContent);
$elements[$idx]['subst'] = [
'type' => 'string',
'tokenID' => $tokenID,
'tokenValue' => $url,
];
}
}
}
return SoftReferenceParserResult::create(
substr($modifiedContent, 1, -1),
$elements
);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?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\Core\DataHandling;
/**
* Enumeration object for tca type
*/
enum TableColumnType: string
{
case INPUT = 'input';
case TEXT = 'text';
case CHECK = 'check';
case RADIO = 'radio';
case SELECT = 'select';
case GROUP = 'group';
case FOLDER = 'folder';
case NONE = 'none';
case LANGUAGE = 'language';
case PASSTHROUGH = 'passthrough';
case USER = 'user';
case FLEX = 'flex';
case INLINE = 'inline';
case IMAGEMANIPULATION = 'imageManipulation';
case SLUG = 'slug';
case CATEGORY = 'category';
case EMAIL = 'email';
case LINK = 'link';
case PASSWORD = 'password';
case DATETIME = 'datetime';
case COLOR = 'color';
case NUMBER = 'number';
case FILE = 'file';
case JSON = 'json';
case UUID = 'uuid';
case COUNTRY = 'country';
}