TYPO3 v15 dev-main snapshot ()
This commit is contained in:
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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user