diff --git a/composer.json b/composer.json index eb28ea2f63458db99b4096d3f0dde4945fb554dd..cffc562227bfaf2f9454616812318db486efb850 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "source": "http://cgit.drupalcode.org/linkit" }, "require" : { - "drupal/core": "^8.7.7 || ^9" + "drupal/core": "^8.7.7 || ^9", + "codeinc/strip-accents": "^1.1" }, "license": "GPL-2.0-or-later" } diff --git a/config/schema/linkit.schema.yml b/config/schema/linkit.schema.yml index c6e3f1d10c38df8719df05c3339946cda6988c70..7f40966451a9f29ff6aa566dae32194c3acbb7d4 100644 --- a/config/schema/linkit.schema.yml +++ b/config/schema/linkit.schema.yml @@ -47,6 +47,10 @@ linkit.matcher.entity: type: string limit: type: integer + translated_entities: + type: integer + hide_untranslated_entities: + type: boolean linkit.matcher.entity:*: type: linkit.matcher.entity diff --git a/js/linkit.autocomplete.js b/js/linkit.autocomplete.js index 558278ddff22d60181ea2edc96ab8c11befe1112..0d8642926894a04eaea55b1fc48300b281a81467 100644 --- a/js/linkit.autocomplete.js +++ b/js/linkit.autocomplete.js @@ -3,7 +3,7 @@ * Linkit Autocomplete based on jQuery UI. */ -(function ($, Drupal, _) { +(function ($, Drupal, _, drupalSettings) { 'use strict'; @@ -47,6 +47,11 @@ success: sourceCallbackHandler, data: {q: term} }, autocomplete.ajax); + + if (drupalSettings.path.hasOwnProperty('currentQuery') && drupalSettings.path.currentQuery.hasOwnProperty('language_content_entity')) { + options.data.language_content_entity = drupalSettings.path.currentQuery.language_content_entity; + } + $.ajax(this.element.attr('data-autocomplete-path'), options); } } @@ -212,4 +217,4 @@ } }; -})(jQuery, Drupal, _); +})(jQuery, Drupal, _, drupalSettings); diff --git a/src/Plugin/Filter/LinkitFilter.php b/src/Plugin/Filter/LinkitFilter.php index f272303855bec983c8f5b39498a89d44ee1a031d..72ee5a0425248efe372b1244f2d283f2ce3f83fe 100644 --- a/src/Plugin/Filter/LinkitFilter.php +++ b/src/Plugin/Filter/LinkitFilter.php @@ -5,6 +5,7 @@ namespace Drupal\linkit\Plugin\Filter; use Drupal\Component\Utility\Html; use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\filter\FilterProcessResult; use Drupal\filter\Plugin\FilterBase; @@ -40,6 +41,13 @@ class LinkitFilter extends FilterBase implements ContainerFactoryPluginInterface */ protected $substitutionManager; + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + /** * Constructs a LinkitFilter object. * @@ -54,11 +62,12 @@ class LinkitFilter extends FilterBase implements ContainerFactoryPluginInterface * @param \Drupal\linkit\SubstitutionManagerInterface $substitution_manager * The substitution manager. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, SubstitutionManagerInterface $substitution_manager) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, SubstitutionManagerInterface $substitution_manager, LanguageManagerInterface $language_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityRepository = $entity_repository; $this->substitutionManager = $substitution_manager; + $this->languageManager = $language_manager; } /** @@ -70,7 +79,8 @@ class LinkitFilter extends FilterBase implements ContainerFactoryPluginInterface $plugin_id, $plugin_definition, $container->get('entity.repository'), - $container->get('plugin.manager.linkit.substitution') + $container->get('plugin.manager.linkit.substitution'), + $container->get('language_manager') ); } @@ -107,6 +117,18 @@ class LinkitFilter extends FilterBase implements ContainerFactoryPluginInterface $entity = $this->entityRepository->loadEntityByUuid($entity_type, $uuid); if ($entity) { + // Parse link href as url, extract query and fragment from it. + $href_url = parse_url($element->getAttribute('href')); + $anchor = empty($href_url["fragment"]) ? '' : '#' . $href_url["fragment"]; + $query = empty($href_url["query"]) ? '' : '?' . $href_url["query"]; + + // Extract the langcode from each URL, if available. + // Fallback to the langcode applicable to the whole element. + $entityInternalPath = $entity->toUrl('canonical')->getInternalPath(); + $entityInternalPath = preg_replace('/\/edit$/', '', $entityInternalPath); + $langcodeFromUrl = trim(str_replace($entityInternalPath, '', $href_url["path"]), '/'); + $langcode = $this->languageManager->getLanguage($langcode) ? $langcodeFromUrl : $langcode; + $entity = $this->entityRepository->getTranslationFromContext($entity, $langcode); /** @var \Drupal\Core\GeneratedUrl $url */ @@ -114,11 +136,6 @@ class LinkitFilter extends FilterBase implements ContainerFactoryPluginInterface ->createInstance($substitution_type) ->getUrl($entity); - // Parse link href as url, extract query and fragment from it. - $href_url = parse_url($element->getAttribute('href')); - $anchor = empty($href_url["fragment"]) ? '' : '#' . $href_url["fragment"]; - $query = empty($href_url["query"]) ? '' : '?' . $href_url["query"]; - $element->setAttribute('href', $url->getGeneratedUrl() . $query . $anchor); // Set the appropriate title attribute. diff --git a/src/Plugin/Linkit/Matcher/EntityMatcher.php b/src/Plugin/Linkit/Matcher/EntityMatcher.php index 787c4095b8f52c97b2934179b0a45a6d41b929db..699d2c95fdd007f69d6feed85592d604bce47c9b 100644 --- a/src/Plugin/Linkit/Matcher/EntityMatcher.php +++ b/src/Plugin/Linkit/Matcher/EntityMatcher.php @@ -9,9 +9,12 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\TranslatableInterface; use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Url; use Drupal\linkit\ConfigurableMatcherBase; @@ -22,6 +25,7 @@ use Drupal\linkit\Suggestion\SuggestionCollection; use Drupal\linkit\Utility\LinkitXss; use Exception; use Symfony\Component\DependencyInjection\ContainerInterface; +use CodeInc\StripAccents\StripAccents; /** * Provides default linkit matchers for all entity types. @@ -41,6 +45,14 @@ class EntityMatcher extends ConfigurableMatcherBase { */ const DEFAULT_LIMIT = 100; + /** + * The default values for the translated_entities configuration. + */ + const TRANSLATED_ENTITIES_MATCHING_NO = 0; + const TRANSLATED_ENTITIES_MATCHING_UI_LANG = 1; + const TRANSLATED_ENTITIES_MATCHING_CONTENT_LANG = 2; + const TRANSLATED_ENTITIES_MATCHING_ACTUAL_STRING_MATCH = 3; + /** * The database connection. * @@ -90,6 +102,13 @@ class EntityMatcher extends ConfigurableMatcherBase { */ protected $targetType; + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + /** * The substitution manager. * @@ -100,7 +119,7 @@ class EntityMatcher extends ConfigurableMatcherBase { /** * {@inheritdoc} */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityRepositoryInterface $entity_repository, ModuleHandlerInterface $module_handler, AccountInterface $current_user, SubstitutionManagerInterface $substitution_manager) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityRepositoryInterface $entity_repository, ModuleHandlerInterface $module_handler, AccountInterface $current_user, SubstitutionManagerInterface $substitution_manager, LanguageManagerInterface $language_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); if (empty($plugin_definition['target_entity'])) { @@ -114,6 +133,7 @@ class EntityMatcher extends ConfigurableMatcherBase { $this->currentUser = $current_user; $this->targetType = $plugin_definition['target_entity']; $this->substitutionManager = $substitution_manager; + $this->languageManager = $language_manager; } /** @@ -130,7 +150,8 @@ class EntityMatcher extends ConfigurableMatcherBase { $container->get('entity.repository'), $container->get('module_handler'), $container->get('current_user'), - $container->get('plugin.manager.linkit.substitution') + $container->get('plugin.manager.linkit.substitution'), + $container->get('language_manager') ); } @@ -174,6 +195,26 @@ class EntityMatcher extends ConfigurableMatcherBase { } } + $translated_entities_option = $this->t('No'); + if ($this->configuration['translated_entities'] == 1) { + $translated_entities_option = $this->t('Current UI language'); + } + if ($this->configuration['translated_entities'] == 2) { + $translated_entities_option = $this->t('Current content language'); + } + if ($this->configuration['translated_entities'] == 3) { + $translated_entities_option = $this->t('Actual match'); + } + $summery[] = $this->t('Use translated entities: @translated_entities', [ + '@translated_entities' => $translated_entities_option, + ]); + + if (in_array($this->configuration['translated_entities'], [1, 2])) { + $summery[] = $this->t('Hide untranslated entities: @hide_untranslated_entities', [ + '@hide_untranslated_entities' => $this->configuration['hide_untranslated_entities'] ? $this->t('Yes') : $this->t('No'), + ]); + } + return $summery; } @@ -185,6 +226,8 @@ class EntityMatcher extends ConfigurableMatcherBase { 'metadata' => '', 'bundles' => [], 'group_by_bundle' => FALSE, + 'translated_entities' => self::TRANSLATED_ENTITIES_MATCHING_UI_LANG, + 'hide_untranslated_entities' => FALSE, 'substitution_type' => SubstitutionManagerInterface::DEFAULT_SUBSTITUTION, 'limit' => static::DEFAULT_LIMIT, ] + parent::defaultConfiguration(); @@ -288,6 +331,40 @@ class EntityMatcher extends ConfigurableMatcherBase { '#description' => $this->t('Limit the amount of results displayed when searching.'), '#default_value' => $this->configuration['limit'], ]; + + $form['translations'] = [ + '#type' => 'details', + '#title' => $this->t('Translations'), + '#open' => TRUE, + ]; + + $form['translations']['translated_entities'] = [ + '#type' => 'select', + '#title' => $this->t('Use translated entities'), + '#description' => $this->t('The translated entities will be used to create suggestions (if possible)'), + '#default_value' => $this->configuration['translated_entities'], + '#options' => [ + self::TRANSLATED_ENTITIES_MATCHING_NO => $this->t('No'), + self::TRANSLATED_ENTITIES_MATCHING_UI_LANG => $this->t('Current UI language'), + self::TRANSLATED_ENTITIES_MATCHING_CONTENT_LANG => $this->t('Current content language'), + self::TRANSLATED_ENTITIES_MATCHING_ACTUAL_STRING_MATCH => $this->t('Actual match'), + ], + ]; + + $form['translations']['hide_untranslated_entities'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Hide untranslated entities'), + '#default_value' => $this->configuration['hide_untranslated_entities'], + '#description' => $this->t('The entity will be omitted in suggestions, if it has no translation in current language.'), + '#states' => [ + 'visible' => [ + [':input[name="translated_entities"]' => ['value' => 1]], + 'or', + [':input[name="translated_entities"]' => ['value' => 2]], + ], + ], + ]; + return $form; } @@ -304,6 +381,8 @@ class EntityMatcher extends ConfigurableMatcherBase { $this->configuration['metadata'] = $form_state->getValue('metadata'); $this->configuration['bundles'] = $form_state->getValue('bundles'); $this->configuration['group_by_bundle'] = $form_state->getValue('group_by_bundle'); + $this->configuration['translated_entities'] = $form_state->getValue('translated_entities'); + $this->configuration['hide_untranslated_entities'] = !empty($form_state->getValue('hide_untranslated_entities')); $this->configuration['substitution_type'] = $form_state->getValue('substitution_type'); $this->configuration['limit'] = $form_state->getValue('limit'); } @@ -342,11 +421,59 @@ class EntityMatcher extends ConfigurableMatcherBase { continue; } - $entity = $this->entityRepository->getTranslationFromContext($entity); + if ($this->configuration['translated_entities'] + && $entity instanceof TranslatableInterface + && !in_array($entity->language()->getId(), [ + LanguageInterface::LANGCODE_NOT_APPLICABLE, + LanguageInterface::LANGCODE_NOT_SPECIFIED, + ]) + ) { + if (in_array($this->configuration['translated_entities'], [ + self::TRANSLATED_ENTITIES_MATCHING_UI_LANG, + self::TRANSLATED_ENTITIES_MATCHING_CONTENT_LANG, + ])) { + // If we need to show an entity in current content/UI language, just + // try to retrieve it. + $type = $this->configuration['translated_entities'] == self::TRANSLATED_ENTITIES_MATCHING_CONTENT_LANG + ? LanguageInterface::TYPE_CONTENT + : LanguageInterface::TYPE_INTERFACE; + $langcode = $this->languageManager->getCurrentLanguage($type)->getId(); + if ($entity->hasTranslation($langcode)) { + $entity = $entity->getTranslation($langcode); + } + elseif ($this->configuration['hide_untranslated_entities']) { + continue; + } + } + elseif ($this->configuration['translated_entities'] == 3) { + // If we need to show actually matched entity, we can just retrieve + // them from the entity query results since it returns only ids. + // So we will iterate over all existing translations and looking + // for matched label. + $languages = $entity->getTranslationLanguages(); + foreach ($languages as $language) { + $langcode = $language->getid(); + if ($entity->hasTranslation($langcode)) { + $entity_translation = $entity->getTranslation($langcode); + $entity_label_unaccented = StripAccents::strip($entity_translation->label()); + $string_unaccented = StripAccents::strip($string); + if (stripos($entity_label_unaccented, $string_unaccented) !== FALSE) { + $entity = $entity_translation; + $suggestion = $this->createSuggestion($entity); + $suggestions->addSuggestion($suggestion); + } + } + } + } + } + // Default case. + else { + $entity = $this->entityRepository->getTranslationFromContext($entity); + } + $suggestion = $this->createSuggestion($entity); $suggestions->addSuggestion($suggestion); } - return $suggestions; } @@ -492,6 +619,9 @@ class EntityMatcher extends ConfigurableMatcherBase { */ protected function buildPath(EntityInterface $entity) { $path = $entity->toUrl('canonical', ['path_processing' => FALSE])->toString(); + $type = $this->configuration['translated_entities'] == self::TRANSLATED_ENTITIES_MATCHING_CONTENT_LANG + ? LanguageInterface::TYPE_CONTENT + : LanguageInterface::TYPE_INTERFACE; // For media entities, check if standalone URLs are allowed. If not, then // strip '/edit' from the end of the canonical URL returned // by $entity->toUrl(). @@ -502,6 +632,21 @@ class EntityMatcher extends ConfigurableMatcherBase { $path = substr($path, 0, -5); } } + elseif ($entity->getEntityTypeId() == 'node') { + // Look for actual prefixes used in site. + $prefixes = \Drupal::config('language.negotiation')->get('url.prefixes'); + $langcode = $entity->language()->getId(); + // Use correct language prefix if set. + $prefix = isset($prefixes[$langcode]) ? $prefixes[$langcode] : ''; + if ($prefix) { + // If Drupal as been installed in a subdirectory, attach it to the path. + if (base_path() === '/') { + $path = '/' . $prefix . $path; + } else { + $path = str_replace(base_path(), base_path() . $prefix . '/', $path); + } + } + } return $path; } @@ -532,3 +677,4 @@ class EntityMatcher extends ConfigurableMatcherBase { } } + diff --git a/src/Suggestion/SuggestionCollection.php b/src/Suggestion/SuggestionCollection.php index de20548a37d14870bbb62391f288042fb4a5f86b..b43ddc6ff1c9cb34c950e7c6f8448c1a6d991cad 100644 --- a/src/Suggestion/SuggestionCollection.php +++ b/src/Suggestion/SuggestionCollection.php @@ -31,7 +31,7 @@ class SuggestionCollection implements \JsonSerializable { * The suggestion to add to the collection. */ public function addSuggestion(SuggestionInterface $suggestion) { - $this->suggestions[] = $suggestion; + $this->suggestions[$suggestion->getPath()] = $suggestion; } /**