eca-1.0.x-dev/src/Token/TokenDecoratorTrait.php
src/Token/TokenDecoratorTrait.php
<?php
namespace Drupal\eca\Token;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\Core\Utility\Token;
use Drupal\eca\EcaEvents;
use Drupal\eca\Event\TokenGenerateEvent;
use Drupal\eca\Plugin\DataType\DataTransferObject;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* A trait for ECA-specific token service decorators.
*
* The token service is being extended this way to support collection of data
* which may be added on runtime, plus it allows the usage of aliases in order
* to use multiple data sets of the same type, e.g. you can use both the
* currently logged in user and the author user of a node to replace values
* in a given text.
*
* @see \Drupal\eca\Token\TokenInterface
*/
trait TokenDecoratorTrait {
/**
* An array of currently hold token data.
*
* @var array
*/
protected array $data = [];
/**
* A list of Token data providers.
*
* @var \Drupal\eca\Token\DataProviderInterface[]
*/
protected array $dataProviders = [];
/**
* The event dispatcher.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected EventDispatcherInterface $eventDispatcher;
/**
* The current recursion level.
*
* @var int
*/
protected int $recursionLevel = 0;
/**
* {@inheritdoc}
*/
public function getDataProviders(): array {
return $this->dataProviders;
}
/**
* Set the token service that is being decorated by this service.
*
* @param \Drupal\Core\Utility\Token $token
* The token service to decorate.
*/
public function setDecoratedToken(Token $token): void {
// @phpstan-ignore-next-line
$this->token = $token;
}
/**
* Set the event dispatcher.
*
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher.
*/
public function setEventDispatcher(EventDispatcherInterface $event_dispatcher): void {
$this->eventDispatcher = $event_dispatcher;
}
/**
* {@inheritdoc}
*/
public function addTokenData(string $key, $data): TokenInterface {
$key = $this->normalizeKey($key);
$parts = explode(':', $key);
$key = array_shift($parts);
if (is_string($data) && (trim($data) === '')) {
// Treat an empty string like a NULL value. This mostly happens when
// an empty value is used to set a Token, in order to remove a previously
// set value.
$data = NULL;
}
// Directly use the entity, instead of the wrapped adapter.
if ($data instanceof EntityAdapter) {
$data = $data->getEntity();
}
if (empty($parts) && (is_null($data) || $this->getTokenType($data))) {
$this->data[$key] = $data;
return $this;
}
// Either there is no known token type available, or the given key is a
// chained token. For both cases, wrap the data as Data Transfer Object.
if ($this->hasTokenData($key)) {
$current_data = $this->getTokenData($key);
$dto = $current_data instanceof DataTransferObject ? $current_data : DataTransferObject::create($current_data);
}
else {
$dto = DataTransferObject::create();
}
if ($this->getTokenData($key) !== $dto) {
$this->addTokenData($key, $dto);
}
while ($parts) {
$key = array_shift($parts);
if (!$parts) {
$dto->set($key, $data);
return $this;
}
if (!isset($dto->$key)) {
$dto->set($key, DataTransferObject::create(NULL, $dto, $key));
}
elseif (!($dto->get($key) instanceof DataTransferObject)) {
$dto->set($key, DataTransferObject::create($dto->get($key)->getValue(), $dto, $key));
}
/**
* @var \Drupal\eca\Plugin\DataType\DataTransferObject $dto
*/
$dto = $dto->get($key);
}
if (is_scalar($data)) {
$dto->setStringRepresentation($data);
}
else {
$dto->setValue($data);
}
return $this;
}
/**
* {@inheritdoc}
*/
public function addTokenDataProvider(DataProviderInterface $provider): TokenInterface {
if (!in_array($provider, $this->dataProviders, TRUE)) {
$this->dataProviders[] = $provider;
}
return $this;
}
/**
* {@inheritdoc}
*/
public function removeTokenDataProvider(DataProviderInterface $provider): TokenInterface {
$this->dataProviders = array_filter($this->dataProviders, static function ($added) use ($provider) {
return $added !== $provider;
});
return $this;
}
/**
* Gets the token type of the given data value if possible.
*
* @param mixed $value
* Data value for which to determine the token type.
*
* @return string|null
* The token type if available, NULL otherwise.
*/
public function getTokenType($value): ?string {
$tokenType = NULL;
if ($value instanceof EntityInterface) {
$tokenType = $this->getTokenTypeForEntityType($value->getEntityTypeId());
}
elseif ($value instanceof DataTransferObject) {
$tokenType = 'dto';
}
return $tokenType;
}
/**
* Get the token type for the given entity type ID.
*
* @param string $entity_type_id
* The entity type ID.
*
* @return string|null
* The token type or NULL if the entity type does not map to a token type.
*/
public function getTokenTypeForEntityType(string $entity_type_id): ?string {
$tokenType = NULL;
if (\Drupal::hasService('token.entity_mapper')) {
/**
* @var \Drupal\token\TokenEntityMapperInterface $token_entity_mapper
*/
$token_entity_mapper = \Drupal::service('token.entity_mapper');
$tokenType = $token_entity_mapper->getTokenTypeForEntityType($entity_type_id, TRUE);
}
elseif ($definition = \Drupal::entityTypeManager()->getDefinition($entity_type_id, FALSE)) {
$tokenType = $definition->get('token_type') ?: $entity_type_id;
}
return $tokenType;
}
/**
* Get the entity type ID for the given token type.
*
* @param string $token_type
* The token type.
*
* @return string|null
* The entity type ID, or NULL if the token type does not map to an entity
* type.
*/
public function getEntityTypeForTokenType(string $token_type): ?string {
$entity_type_id = NULL;
if (\Drupal::hasService('token.entity_mapper')) {
/**
* @var \Drupal\token\TokenEntityMapperInterface $token_entity_mapper
*/
$token_entity_mapper = \Drupal::service('token.entity_mapper');
$entity_type_id = $token_entity_mapper->getEntityTypeForTokenType($token_type) ?: NULL;
}
else {
$entity_type_manager = \Drupal::entityTypeManager();
if ($entity_type_manager->hasDefinition($token_type)) {
$entity_type_id = $token_type;
}
// Special handling for taxonomy.
elseif (in_array($token_type, ['term', 'vocabulary'])) {
$entity_type_id = 'taxonomy_' . $token_type;
}
// Go the painful road of looking at every type definition.
else {
foreach ($entity_type_manager->getDefinitions() as $plugin_id => $definition) {
if ($token_type === $definition->get('token_type')) {
$entity_type_id = $plugin_id;
break;
}
}
}
}
return $entity_type_id;
}
/**
* {@inheritdoc}
*/
public function hasTokenData(?string $key = NULL): bool {
if (isset($key)) {
return !is_null($this->getTokenData($key));
}
return !empty($this->data);
}
/**
* {@inheritdoc}
*/
public function getTokenData(?string $key = NULL) {
if (!isset($key)) {
return $this->data;
}
$key = $this->normalizeKey($key);
$parts = explode(':', $key);
$key = array_shift($parts);
$data = NULL;
if (isset($this->data[$key])) {
$data = $this->data[$key];
}
elseif (!empty($this->dataProviders)) {
foreach ($this->dataProviders as $provider) {
if ($provider->hasData($key)) {
$data = $provider->getData($key);
if ($data instanceof EntityAdapter) {
$data = $data->getEntity();
}
break;
}
}
}
foreach ($parts as $partKey) {
if (!is_object($data) || !isset($data->$partKey)) {
return NULL;
}
if ($data instanceof EntityInterface || $data instanceof ComplexDataInterface) {
$data = $data->get($partKey);
}
else {
$data = $data->$partKey;
}
}
if ($data instanceof TypedDataInterface && $data->getValue() instanceof EntityInterface) {
$data = $data->getValue();
}
return $data;
}
/**
* {@inheritdoc}
*/
public function clearTokenData(): void {
$this->data = [];
}
/**
* {@inheritdoc}
*/
public function generate($type, array $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
$this->recursionLevel++;
if ($type === '_eca_root_token') {
// Check for each item when generating values for root-level tokens.
foreach (array_keys($tokens) as $root_level_token) {
if (!isset($data[$root_level_token]) && $this->hasTokenData($root_level_token)) {
// Use previously set data in case it's not given otherwise.
$data[$root_level_token] = $this->getTokenData($root_level_token);
}
}
}
elseif (!isset($data[$type]) && $this->hasTokenData($type)) {
// Use previously set data in case it's not given otherwise.
$data[$type] = $this->getTokenData($type);
}
if (1 === $this->recursionLevel) {
foreach ($tokens as $name => $original) {
$token_event = new TokenGenerateEvent($type, $name, $original, $data, $options, $bubbleable_metadata);
$this->eventDispatcher->dispatch($token_event, EcaEvents::TOKEN);
$data = $token_event->getData() + $data;
}
}
if (isset($data[$type])) {
$hold_token_data = $data[$type];
$real_token_type = $this->getTokenType($hold_token_data);
// The following if-block is a band-aid for menu links when contrib Token
// is installed, as it wrongly makes use of the menu link content entity
// instead of its according plugin.
// @todo Remove this block once #3314427 got fixed.
// @see https://www.drupal.org/project/token/issues/3314427
// @see https://www.drupal.org/project/eca/issues/3314123
if ($type === 'menu-link') {
$real_token_type = 'menu-link';
}
// The token contrib module defines the book token type which is an alias
// for the node token type. We need to keep the alias, otherwise tokens
// would break.
// @see https://www.drupal.org/project/eca/issues/3340582
if ($type === 'book') {
$real_token_type = 'book';
}
// The webform_access contrib module defines the webform_access token type
// which is an alias for the node token type. We need to keep the alias,
// otherwise tokens would break.
// @see https://www.drupal.org/project/eca/issues/3378283
if ($type === 'webform_access') {
$real_token_type = 'webform_access';
}
// Check whether we hold aliased Token data. Exclude the alias mapping if
// the "token_type" key is set, which comes from the contrib Token module
// and is set within the scope of generic entity tokens. Otherwise, since
// we are also using "entity" as an alias - without checking for that key
// this method would cause an infinite loop.
// @todo Find a more reliable way to prevent infinite recursion.
if (isset($real_token_type) && $real_token_type !== $type && !isset($data['token_type'])) {
// Given $type argument is an alias, thus use its mapped token type.
$alias = $type;
$type = $real_token_type;
$data[$type] = $hold_token_data;
unset($data[$alias]);
}
}
// Now that we have mapped a possibly given alias to its type, we can let
// the decorated token service do its original job (again). That passthrough
// will not overwrite any other aliased data, because the returned token
// replacements are keyed by their "raw" original token input, and that
// always includes the alias as a prefix.
$replacements = $this->token->generate($type, $tokens, $data, $options, $bubbleable_metadata);
$this->recursionLevel--;
return $replacements;
}
/**
* {@inheritdoc}
*/
public function replace($text, array $data = [], array $options = [], ?BubbleableMetadata $bubbleable_metadata = NULL) {
// Replacement of aliased tokens can only work within the scope of this
// decorator. Thus we call it on its own.
$text = parent::replace($text, $data, $options, $bubbleable_metadata);
// Either the class of this decorator inherits from the Core token service
// or from the Contrib token service (if available). Just in case we
// actually received a decorated service that differs from these two
// implementation variants, give it a chance to execute its own logic.
if (!in_array(get_class($this->token), [
'Drupal\Core\Utility\Token',
'Drupal\token\Token',
], TRUE)) {
$text = $this->token->replace($text, $data, $options, $bubbleable_metadata);
}
return $text;
}
/**
* {@inheritdoc}
*/
public function replaceClear($text, array $data = [], array $options = [], ?BubbleableMetadata $bubbleable_metadata = NULL) {
$options['clear'] = TRUE;
return $this->replace($text, $data, $options, $bubbleable_metadata);
}
/**
* {@inheritdoc}
*
* Logical-wise, this behaves the same as ::replace(). See the comments there.
*/
public function replacePlain(string $plain, array $data = [], array $options = [], ?BubbleableMetadata $bubbleable_metadata = NULL): string {
$plain = parent::replacePlain($plain, $data, $options, $bubbleable_metadata);
if (!in_array(get_class($this->token), [
'Drupal\Core\Utility\Token',
'Drupal\token\Token',
], TRUE)) {
$plain = $this->token->replacePlain($plain, $data, $options, $bubbleable_metadata);
}
return $plain;
}
/**
* {@inheritdoc}
*/
public function getOrReplace($text, array $data = [], ?array $options = NULL, ?BubbleableMetadata $bubbleable_metadata = NULL) {
$string = (string) $text;
if ((mb_substr($string, 0, 1) === '[') && (mb_substr($string, -1, 1) === ']') && (mb_strlen($string) <= 255)) {
$string = mb_substr($string, 1, -1);
if (!empty($data) && ($value = NestedArray::getValue($data, explode(':', $string)))) {
return $value;
}
if ($this->hasTokenData($string)) {
return $this->getTokenData($string);
}
}
return isset($options) ? $this->replace($text, $data, $options, $bubbleable_metadata) : $this->replaceClear($text, $data, [], $bubbleable_metadata);
}
/**
* {@inheritdoc}
*/
public function scan($text) {
return $this->token->scan($text) + $this->scanRootLevelTokens($text);
}
/**
* {@inheritdoc}
*/
public function scanRootLevelTokens($text): array {
preg_match_all('/
\[ # [ - pattern start
([^\s\[\]:]+) # match $type not containing whitespace : [ or ]
\] # ] - pattern end
/x', $text, $matches);
$tokens = $matches[1];
$results = [];
$tokenCount = count($tokens);
for ($i = 0; $i < $tokenCount; $i++) {
$results['_eca_root_token'][$tokens[$i]] = $matches[0][$i];
}
return $results;
}
/**
* {@inheritdoc}
*/
public function findWithPrefix(array $tokens, $prefix, $delimiter = ':') {
return $this->token->findWithPrefix($tokens, $prefix, $delimiter);
}
/**
* {@inheritdoc}
*/
public function getInfo(): array {
return $this->token->getInfo();
}
/**
* {@inheritdoc}
*/
public function setInfo(array $tokens): void {
$this->token->setInfo($tokens);
}
/**
* {@inheritdoc}
*/
public function resetInfo(): void {
$this->token->resetInfo();
}
/**
* Normalizes the given key that may be provided by user input.
*
* @param string $key
* The key to normalize.
*
* @return string
* The normalized key.
*/
protected function normalizeKey(string $key): string {
$key = trim($key);
if (!empty($key)) {
if ((mb_substr($key, 0, 1) === '[') && (mb_substr($key, -1, 1) === ']')) {
// Remove the brackets coming from Token syntax.
$key = mb_substr($key, 1, -1);
}
if (mb_strpos($key, '.')) {
// User input may use "." instead of ":".
$key = str_replace('.', ':', $key);
}
}
return $key;
}
/**
* Implements the magic sleep method.
*/
public function __sleep() {
// Prevent serialization of any attached service and Token data.
// When a component actually tries to serialize this service (which
// normally must not happen), this object will not work properly and
// fail hard. When such situation occurs, the responsible component needs
// to be fixed so that it does not try to serialize the Token service.
return [];
}
}
