EntityLinkSuggestionsController.php
Namespace
Drupal\ckeditor5\ControllerFile
-
core/
modules/ ckeditor5/ src/ Controller/ EntityLinkSuggestionsController.php
View source
<?php
declare (strict_types=1);
namespace Drupal\ckeditor5\Controller;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\editor\EditorInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Returns responses for entity link suggestions autocomplete route.
*
* @see \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface
* @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection
*
* @internal
*/
class EntityLinkSuggestionsController extends ControllerBase {
use StringTranslationTrait;
/**
* The default limit for matches.
*/
const DEFAULT_LIMIT = 100;
/**
* Constructs a EntityLinkSuggestionsController.
*/
public function __construct(protected readonly SelectionPluginManagerInterface $selectionPluginManager, protected readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo, protected readonly EntityRepositoryInterface $entityRepository, protected readonly DateFormatterInterface $dateFormatter) {
}
/**
* Checks access based on entity_links filter status on the text format.
*
* Note that access to the filter format is not checked here because the route
* is configured to check entity access to the filter format.
*
* @param \Drupal\editor\Entity\Editor $editor
* The text editor for which to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public static function formatUsesEntityLinksFilter(EditorInterface $editor) : AccessResultInterface {
$filters = $editor->getFilterFormat()
->filters();
return AccessResult::allowedIf($filters->has('entity_links') && $filters->get('entity_links')->status)
->addCacheableDependency($editor);
}
/**
* Generates entity link suggestions for use by an autocomplete.
*
* Like other autocomplete functions, this function inspects the 'q' query
* parameter for the string to use to search for suggestions.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
* @param \Drupal\editor\EditorInterface $editor
* The text editor whose drupalEntityLinkSuggestions configuration to use.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response containing the autocomplete suggestions.
*/
public function suggestions(Request $request, EditorInterface $editor) : JsonResponse {
$input = mb_strtolower($request->query
->get('q'));
$host_entity_type_id = $request->query
->get('hostEntityTypeId');
$host_entity_langcode = $request->query
->get('hostEntityLangcode');
$suggestions = [];
if ($input) {
$allowed_bundles = [];
$all_bundle_info = $this->entityTypeBundleInfo
->getAllBundleInfo();
foreach ($all_bundle_info as $entity_type => $bundles) {
foreach ($bundles as $key => $bundle) {
if (!empty($bundle['ckeditor5_link_suggestions'])) {
$allowed_bundles[$entity_type][$key] = $key;
}
}
}
if (in_array($host_entity_type_id, array_keys($allowed_bundles), TRUE)) {
$suggestions = $this->getSuggestions($host_entity_type_id, $allowed_bundles[$host_entity_type_id], $input, $host_entity_langcode);
}
// Second, find suggestions for all other entity types, in the specified
// order.
$allowed_entity_type_ids = array_keys($allowed_bundles);
foreach ($allowed_bundles as $entity_type_id => $bundles) {
if ($host_entity_type_id === $entity_type_id) {
continue;
}
if (in_array($entity_type_id, $allowed_entity_type_ids, TRUE)) {
$suggestions = array_merge($suggestions, $this->getSuggestions($entity_type_id, $bundles, $input, $host_entity_langcode));
}
}
// If no suggestions were found, add a special suggestion that has the
// same path as the given string so users can select it and use it anyway.
// This typically occurs when entering external links.
if (!$suggestions) {
$suggestions = [
[
'description' => $this->t('No content suggestions found. This URL will be used as is.'),
'group' => $this->t('No results'),
'label' => Html::escape($input),
'href' => UrlHelper::isValid($input) ? $input : '',
],
];
}
}
// Note that we intentionally:
// - do not use \Drupal\Core\Cache\CacheableJsonResponse because caching it
// on the server side is wasteful, hence there is no need for cacheability
// metadata.
// - mark the response as private, because the suggestions include only the
// ones accessible by the current user.
return (new JsonResponse([
'suggestions' => $suggestions,
]))->setPrivate()
->setMaxAge(300);
}
/**
* Gets the suggestions.
*
* @param string $target_entity_type_id
* An entity type to get suggestions for.
* @param null|string[] $target_bundles
* NULL to allow all bundles, a list of bundle names to restrict to those
* bundles.
* @param string $string
* The string to search.
* @param string $host_entity_langcode
* The langcode of the host entity.
*
* @return array
* An array of suggestion objects with populated entity data.
*
* @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection::defaultConfiguration()
*/
public function getSuggestions(string $target_entity_type_id, ?array $target_bundles, string $string, string $host_entity_langcode) : array {
// If the user input is a current entity URL, don't get more suggestions.
if ($entity_id = static::findEntityIdByUrl($target_entity_type_id, $string)) {
$entity = $this->entityTypeManager()
->getStorage($target_entity_type_id)
->load($entity_id);
if ($entity?->language()->getId() === $host_entity_langcode) {
return [
$this->createSuggestion($entity),
];
}
}
// Do not call ::getPluginId() or ::getInstance() because this favors a
// "link_target" variant of the default selection plugin for the given
// entity type, if it exists.
$selection_handler_groups = $this->selectionPluginManager
->getSelectionGroups($target_entity_type_id);
if (!array_key_exists('default', $selection_handler_groups)) {
return [];
}
// Sort the selection plugins by weight and select the best match.
uasort($selection_handler_groups['default'], [
'Drupal\\Component\\Utility\\SortArray',
'sortByWeightElement',
]);
end($selection_handler_groups['default']);
// Select the link_target variant of the default selection plugin for the
// entity type, if it exists. Otherwise, select the next best match.
$link_target_selection_plugin_id = "default:{$target_entity_type_id}_link_target";
$plugin_id = array_key_exists($link_target_selection_plugin_id, $selection_handler_groups['default']) ? $link_target_selection_plugin_id : key($selection_handler_groups['default']);
$selection = $this->selectionPluginManager
->createInstance($plugin_id, [
'target_type' => $target_entity_type_id,
'target_bundles' => $target_bundles,
]);
$entities_by_bundle = $selection->getReferenceableEntities($string, 'CONTAINS', static::DEFAULT_LIMIT);
// DefaultSelection::getReferenceableEntities() loads entities and even
// their translation but then only keeps bundle, entity ID and label. Reload
// them to generate rich results. Note that performance overhead of this is
// minimal because all this data is statically cached already anyway.
$entity_ids = array_reduce($entities_by_bundle, function ($flattened, $bundle_entities) {
return array_merge($flattened, array_keys($bundle_entities));
}, []);
$entities = $this->entityTypeManager()
->getStorage($target_entity_type_id)
->loadMultiple($entity_ids);
$suggestions = [];
foreach ($entities as $entity) {
$entity_translation = $entity->getEntityType()
->isTranslatable() && $entity->hasTranslation($host_entity_langcode) ? $entity->getTranslation($host_entity_langcode) : $entity;
if ($entity_translation->language()
->getId() === $host_entity_langcode) {
$suggestions[] = $this->createSuggestion($entity_translation);
}
}
return $suggestions;
}
/**
* Creates a suggestion.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The matched entity.
*
* @return array
* A suggestion object with populated entity data.
*/
protected function createSuggestion(EntityInterface $entity) : array {
return [
'description' => $this->computeDescription($entity) ?? '',
'entity_type_id' => $entity->getEntityTypeId(),
'entity_uuid' => $entity->uuid(),
'group' => $this->computeGroup($entity),
'label' => $entity->label(),
// Use the canonical URI as a valid fallback for the href. The
// text_format filter will transform this to the final URL (e.g., alias).
'path' => $entity->toUrl('canonical')
->toString(),
];
}
/**
* Computes a suggestion description.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The suggested entity for which to compute a description.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup|null
* A suggestion description.
*/
protected function computeDescription(EntityInterface $entity) : ?TranslatableMarkup {
$entity_type = $entity->getEntityType();
$owner = $entity_type->hasKey('owner') && $entity->getOwner() ? $entity->getOwner()
->getDisplayName() : NULL;
$creation_datetime = method_exists($entity, 'getCreatedTime') ? $this->dateFormatter
->format($entity->getCreatedTime(), 'medium') : NULL;
$arg_owner = [
'@owner' => $owner,
];
$arg_creation_datetime = [
'@creation-datetime' => $creation_datetime,
];
if ($owner && $creation_datetime) {
return $this->t('by @owner on @creation-datetime', $arg_owner + $arg_creation_datetime);
}
elseif ($owner) {
return $this->t('by @owner', $arg_owner);
}
elseif ($creation_datetime) {
return $this->t('on @creation-datetime', $arg_creation_datetime);
}
else {
return NULL;
}
}
/**
* Computers a suggestion group.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The suggested entity for which to compute the group.
*
* @return string
* A suggestion group.
*/
protected function computeGroup(EntityInterface $entity) : string {
// If the entity type does not have bundles, the group is very simple.
if ($entity->getEntityType()
->getBundleEntityType() === NULL) {
return $entity->getEntityType()
->getLabel();
}
$bundles = $this->entityTypeBundleInfo
->getBundleInfo($entity->getEntityTypeId());
return $entity->getEntityType()
->getLabel() . ' - ' . $bundles[$entity->bundle()]['label'];
}
/**
* Finds entity ID from the given input.
*
* @param string $target_entity_type_id
* An entity type to get suggestions for.
* @param string $user_input
* The string to url parse.
*
* @return string|null
* An entity ID parsed from the user input, otherwise NULL.
*/
protected static function findEntityIdByUrl(string $target_entity_type_id, string $user_input) : ?string {
$expected_url_prefix = "/{$target_entity_type_id}/";
if (str_starts_with($user_input, $expected_url_prefix)) {
return substr($user_input, strlen($expected_url_prefix));
}
return NULL;
}
}
Classes
| Title | Deprecated | Summary |
|---|---|---|
| EntityLinkSuggestionsController | Returns responses for entity link suggestions autocomplete route. |
Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.