-
James Williams authored
This prepares for the alter hook approach for the path_alias module taken in https://www.drupal.org/project/drupal/issues/3091336
James Williams authoredThis prepares for the alter hook approach for the path_alias module taken in https://www.drupal.org/project/drupal/issues/3091336
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
language_hierarchy.module 21.72 KiB
<?php
/**
* @file
* Add sublanguage handling functionality to Drupal.
*/
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
use Drupal\language\ConfigurableLanguageInterface;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\views\ViewExecutable;
/**
* Implements hook_module_implements_alter().
*/
function language_hierarchy_module_implements_alter(&$implementations, $hook) {
switch ($hook) {
case 'language_fallback_candidates_path_alias_alter':
// Ensure language_hierarchy_language_fallback_candidates_alter() would
// run after path_alias_language_fallback_candidates_path_alias_alter().
if (isset($implementations['language_hierarchy']) && isset($implementations['path_alias'])) {
$group = $implementations['language_hierarchy'];
unset($implementations['language_hierarchy']);
$implementations['language_hierarchy'] = $group;
}
break;
}
}
/**
* Implements hook_language_fallback_candidates_alter().
*/
function language_hierarchy_language_fallback_candidates_alter(array &$candidates, array $context) {
if (empty($context['langcode'])) {
return;
}
$attempted_langcode = $context['langcode'];
$candidates = [];
// Record which languages have been iterated over, so loops can be avoided.
$iterated = [];
/** @var Drupal\language\Entity\ConfigurableLanguage $language */
$language = ConfigurableLanguage::load($attempted_langcode);
while (!empty($language) && !in_array($language->getId(), $iterated, TRUE)) {
$iterated[] = $language->getId();
$fallback_langcode = $language->getThirdPartySetting('language_hierarchy', 'fallback_langcode', '');
// Include this candidate if there was a fallback language and it was not
// the same as the original langcode (which LocaleLookup already tried) and
// if it is not already in the list. Avoid endless loops and fruitless work.
if (!empty($fallback_langcode) && $attempted_langcode != $fallback_langcode && !isset($candidates[$fallback_langcode])) {
$candidates[$fallback_langcode] = $fallback_langcode;
$language = ConfigurableLanguage::load($fallback_langcode);
}
else {
$language = NULL;
}
}
// If the fallback context is not locale_lookup, allow
// LanguageInterface::LANGCODE_NOT_SPECIFIED as a final candidate after the
// normal fallback chain, and put the attempted language as the top candidate.
// LocaleLookup would already have tried the attempted language, and should
// only be based on explicit configuration. Only languages within this
// fallback chain are allowed otherwise.
if (empty($context['operation']) || $context['operation'] != 'locale_lookup') {
$candidates = [$attempted_langcode => $attempted_langcode] + $candidates;
$candidates[LanguageInterface::LANGCODE_NOT_SPECIFIED] = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
}
/**
* Implements hook_query_TAG_alter().
*
* Order the fallback candidates to be used when querying path aliases.
*
* @see \Drupal\path_alias\AliasRepository::addLanguageFallback()
*/
function language_hierarchy_query_path_alias_language_fallback_alter(AlterableInterface $query) {
if (!$query instanceof SelectInterface) {
return;
}
$alias = $query->leftJoin('language_hierarchy_priority', 'lhp', "base_table.langcode = %alias.langcode");
// Replace the existing language code ordering.
$fields = &$query->getOrderBy();
unset($fields['base_table.langcode']);
// Sort by the priority, being careful to match the order of the id field on
// the base table so that results appear in the order that core is expecting.
$fields = [$alias . '.priority' => $fields['base_table.id']] + $fields;
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function language_hierarchy_form_language_admin_edit_form_alter(&$form, FormStateInterface $form_state) {
/** @var Drupal\language\Entity\ConfigurableLanguage $this_language */
$this_language = $form_state->getFormObject()->getEntity();
$languages = Drupal::languageManager()->getLanguages();
$options = [];
foreach ($languages as $language) {
// Only include this language if it's not itself.
if ($language->getId() != $this_language->getId()) {
$options[$language->getId()] = $language->getName();
}
}
$form['language_hierarchy_fallback_langcode'] = [
'#type' => 'select',
'#title' => t('Translation fallback language'),
'#description' => t('When a translation is not available for text, this fallback language is used. If that is not available either, the fallback continues onward.'),
'#options' => $options,
'#default_value' => $this_language->getThirdPartySetting('language_hierarchy', 'fallback_langcode', ''),
// Allow to not fall back on any other language.
'#empty_value' => '',
];
$form['#entity_builders'][] = 'language_hierarchy_form_language_admin_edit_form_builder';
}
/**
* Entity builder for the language form language_fallback options.
*
* @see language_fallback_form_language_admin_edit_form_alter()
*/
function language_hierarchy_form_language_admin_edit_form_builder($entity_type, ConfigurableLanguageInterface $this_language, &$form, FormStateInterface $form_state) {
$this_language->setThirdPartySetting(
'language_hierarchy',
'fallback_langcode',
$form_state->getValue('language_hierarchy_fallback_langcode')
);
}
/**
* Implements hook_form_FORM_ID_alter() for language_admin_overview_form().
*/
function language_hierarchy_form_language_admin_overview_form_alter(&$form, FormStateInterface $form_state) {
/** @var \Drupal\language\ConfigurableLanguageInterface[] $languages */
$languages = $form['languages']['#languages'];
$hierarchy = [];
foreach ($languages as $langcode => $language) {
$ancestors = [$langcode => []] + language_hierarchy_get_ancestors($language);
$location = array_reverse(array_keys($ancestors));
$hierarchy_element = [
'#weight' => $language->getWeight(),
'#title' => $language->getName(),
];
$existing = NestedArray::getValue($hierarchy, $location);
if (is_array($existing)) {
$hierarchy_element = $hierarchy_element + $existing;
}
NestedArray::setValue($hierarchy, $location, $hierarchy_element);
}
$flattened = language_hierarchy_get_sorted_flattened_hierarchy($hierarchy);
$weights = array_combine(array_keys($flattened), range(0, count($flattened) - 1));
$pos = array_search('weight', array_keys($form['languages']['#header']));
$insert = [
'parent' => [
'data' => t('Parent'),
],
'id' => [
'data' => t('ID'),
'class' => 'hidden',
],
];
$form['languages']['#header'] = array_slice($form['languages']['#header'], 0, $pos + 1) + $insert + array_slice($form['languages']['#header'], count($form['languages']['#header']) - $pos - 1);
foreach ($languages as $langcode => $language) {
$depth = language_hierarchy_calculate_depth($language);
$form['languages'][$langcode]['#weight'] = $weights[$langcode];
$form['languages'][$langcode]['label'] = [
[
'#theme' => 'indentation',
'#size' => $depth,
],
$form['languages'][$langcode]['label'],
];
$form['languages'][$langcode]['id'] = [
'#type' => 'hidden',
'#value' => $langcode,
'#attributes' => [
'class' => [
'language-id',
],
],
'#wrapper_attributes' => [
'class' => [
'hidden',
],
],
'#weight' => 10,
];
$form['languages'][$langcode]['parent'] = [
'#type' => 'select',
'#empty_value' => '',
'#options' => $flattened,
'#attributes' => [
'class' => [
'language-parent',
],
],
'#default_value' => $language->getThirdPartySetting('language_hierarchy', 'fallback_langcode', NULL),
];
$form['languages'][$langcode]['operations']['#weight'] = 11;
uasort($form['languages'][$langcode], ['\Drupal\Component\Utility\SortArray', 'sortByWeightProperty']);
}
uasort($form['languages'], ['\Drupal\Component\Utility\SortArray', 'sortByWeightProperty']);
$form['languages']['#tabledrag'] = [
[
'action' => 'match',
'relationship' => 'parent',
'group' => 'language-parent',
'subgroup' => 'language-parent',
'source' => 'language-id',
],
[
'action' => 'order',
'relationship' => 'sibling',
'group' => 'weight',
],
];
$form['#submit'][] = 'language_hierarchy_language_admin_overview_form_submit';
}
/**
* Helper function to recursively sort the hierarchy tree of languages.
*/
function language_hierarchy_get_sorted_flattened_hierarchy($element) {
$flattened = [];
foreach (Element::children($element, TRUE) as $langcode) {
$flattened = array_merge($flattened, [$langcode => $element[$langcode]['#title']], language_hierarchy_get_sorted_flattened_hierarchy($element[$langcode]));
}
return $flattened;
}
/**
* Get the depth of a language inside hierarchy.
*
* @param \Drupal\language\ConfigurableLanguageInterface $language
* The language to calculate depth for.
*
* @return int
* The number of ancestors this language has.
*/
function language_hierarchy_calculate_depth(ConfigurableLanguageInterface $language) {
$depth = count(language_hierarchy_get_ancestors($language));
return $depth;
}
/**
* Returns ancestors language code of the provided language.
*
* @param \Drupal\language\ConfigurableLanguageInterface $language
* The language to get ancestors for.
*
* @return array
* Ordered array with all ancestors, most specific on the top.
*/
function language_hierarchy_get_ancestors(ConfigurableLanguageInterface $language) {
$ancestors = [];
// Record which languages have been iterated over, so loops can be avoided.
$iterated = [];
while (($ancestor_langcode = $language->getThirdPartySetting('language_hierarchy', 'fallback_langcode')) && !in_array($ancestor_langcode, $iterated, TRUE)) {
$iterated[] = $ancestor_langcode;
if ($ancestor = ConfigurableLanguage::load($ancestor_langcode)) {
$ancestors[$ancestor->getId()] = $ancestor;
$language = $ancestor;
}
}
return $ancestors;
}
/**
* Form submission handler for language_admin_add_form().
*
* Store information about hidden languages.
*/
function language_hierarchy_language_admin_overview_form_submit($form, FormStateInterface $form_state) {
/** @var \Drupal\language\ConfigurableLanguageInterface[] $languages */
$languages = $form['languages']['#languages'];
foreach ($form_state->getValue('languages') as $langcode => $language_values) {
$language = $languages[$langcode];
if ($language_values['parent'] == $language->id()) {
$language_values['parent'] = '';
}
$language->setThirdPartySetting('language_hierarchy', 'fallback_langcode', $language_values['parent']);
$language->save();
}
}
/**
* Implements hook_ENTITY_TYPE_update().
*/
function language_hierarchy_configurable_language_update(EntityInterface $entity) {
language_hierarchy_update_priorities();
}
/**
* Implements hook_ENTITY_TYPE_insert().
*/
function language_hierarchy_configurable_language_insert(EntityInterface $entity) {
language_hierarchy_update_priorities();
}
/**
* Implements hook_ENTITY_TYPE_delete().
*/
function language_hierarchy_configurable_language_delete(EntityInterface $entity) {
language_hierarchy_update_priorities();
}
/**
* Updates the language_hierarchy_priority table based on current configuration.
*/
function language_hierarchy_update_priorities() {
$langcode_priorities = [];
$manager = Drupal::languageManager();
$languages = $manager->getLanguages();
// Start with ordering by weight. We frame these in the negative numbers so
// that any (later-discovered) ordering-by-hierarchy immediately trumps it.
uasort(
$languages,
function ($a, $b) {
return ($a->getWeight() < $b->getWeight()) ? -1 : 1;
}
);
foreach (array_values($languages) as $position_wrt_weight => $language) {
$langcode_priorities[$language->getId()] = -$position_wrt_weight - 1;
}
// Set each langcode's priority to its depth in its hierarchy.
foreach ($languages as $language) {
$langcode = $language->getId();
$context = [
'langcode' => $langcode,
'operation' => 'language_hierarchy_update_priorities',
];
$candidates = $manager->getFallbackCandidates($context);
$candidates = array_filter($candidates, function ($a) use ($langcode) {
return !in_array($a, [LanguageInterface::LANGCODE_NOT_SPECIFIED, $langcode]
);
});
if (!empty($candidates)) {
$langcode_priorities[$langcode] = count($candidates);
}
}
$database = Drupal::database();
$transaction = $database->startTransaction();
$database->truncate('language_hierarchy_priority')->execute();
$query = $database
->insert('language_hierarchy_priority')
->fields(['langcode', 'priority']);
foreach ($langcode_priorities as $langcode => $priority) {
$query->values(['langcode' => $langcode, 'priority' => $priority]);
}
$query->execute();
}
/**
* Implements hook_query_TAG_alter().
*
* The 'language_hierarchy_limit' tag can be used to limit results to only show
* the most specific translations for each base item, as if grouped by the base
* field on the base table.
*
* The limiting is accomplished by joining in a correlated sub-query onto the
* same base table, but with the addition of relative language priorities. This
* is an expensive operation and will likely not be tenable on queries over
* tables with many records.
*
* For this to work, there must be an array of metadata set on the query under
* the 'language_hierarchy_limit' key. That array must be keyed by the unaliased
* field, including the table alias (e.g. node_field_data.langcode), where the
* language codes are found. The inner array must include the following keys:
* - 'base_table', mapped to the unaliased name of the base table.
* - 'base_field', mapped to the unaliased name of the base item ID field (e.g.
* nid) on the base table.
* - 'lang_codes', mapped to an array of the language codes in the fallback
* chain (including the most specific).
*/
function language_hierarchy_query_language_hierarchy_limit_alter(AlterableInterface $query) {
if (!$query instanceof SelectInterface) {
return;
}
$lh_metadata = $query->getMetaData('language_hierarchy_limit');
$view = $query->getMetaData('view');
// Views plugins cannot set metadata directly on the query, but we support
// pulling it from the view build info.
if (!$lh_metadata) {
if ($view instanceof ViewExecutable) {
$lh_metadata = $view->build_info['language_hierarchy_limit'];
}
}
if (!$lh_metadata) {
return;
}
$database = \Drupal::database();
foreach ($lh_metadata as $qualified_field => $metadata) {
list($base_table_alias, $base_langcode_field) = explode('.', $qualified_field);
// Use a unique alias for the sub-query's base table.
$intended_alias = 'lhp_subquery_base';
$sq_base_alias = $intended_alias;
$count = 2;
$tables = $query->getTables();
while (!empty($tables[$sq_base_alias])) {
$sq_base_alias = $intended_alias . '_' . $count++;
}
$sub_query = $database->select($metadata['base_table'], $sq_base_alias);
$sub_query->addField($sq_base_alias, $base_langcode_field, 'lhp_subquery_langcode');
$lhp_sq_alias = $sub_query->addJoin('INNER', 'language_hierarchy_priority', 'lhp_subquery', "$sq_base_alias.$base_langcode_field = %alias.langcode");
// It is actually our parent query which handles the resolution of our
// placeholders (because we inject it as a string).
$lang_codes_placeholder = ':db_condition_placeholder_' . $query->nextPlaceholder() . '[]';
$sub_query->where("$sq_base_alias.langcode IN ($lang_codes_placeholder)");
$base_field = $metadata['base_field'];
$sub_query->where("$sq_base_alias.$base_field = $base_table_alias.$base_field");
$sub_query->orderBy($lhp_sq_alias . '.priority', 'DESC');
$sub_query->range(0, 1);
// MySQL does not support LIMIT ranges in correlated sub-queries within JOIN
// or WHERE IN conditions. However, it does support them in correlated sub-
// queries residing on the left-hand-side ON clause of another join. So we
// do a join on a trivial "SELECT 1" subquery, the single-value of which
// becomes our "language_priority.is_highest" flag.
//
// The Drupal Database API requires a table to be named so we cannot just
// specify "SELECT 1". Tiresome - yes, but we want to follow the API to
// guarantee cross-backend support. Hopefully the database engine is smart
// enough to optimise this. We use our own table because we know it exists.
/** @var \Drupal\Core\Database\Query\SelectInterface $join_flag_subquery */
$join_flag_subquery = $database->select('language_hierarchy_priority')
->range(0, 1);
$join_flag_subquery->addExpression('1', 'is_highest');
// Putting a sub-query in an ON condition requires us to stringify the
// query ourselves. There is precedent in core for this - see
// \Drupal\views\Plugin\views\relationship\GroupwiseMax::leftQuery().
$sub_query_string = (string) $sub_query;
$flag_alias = $query->addJoin('LEFT', $join_flag_subquery, 'language_priority', "($sub_query_string) = $qualified_field", [
$lang_codes_placeholder => $metadata['lang_codes'],
]);
// Don't exclude language neutral entities.
$or_group = $query->orConditionGroup()
->isNotNull("$flag_alias.is_highest")
->condition($qualified_field, LanguageInterface::LANGCODE_NOT_SPECIFIED);
$query->condition($or_group);
}
}
/**
* Replaces the URL language if it is just a fallback translation.
*/
function language_hierarchy_fix_url_from_fallback(Url $url, TranslatableInterface $translatable) {
/** @var \Drupal\Core\Language\LanguageInterface $url_language */
$url_language = $url->getOption('language');
// Respect a 'language_hierarchy_fallback' option, which flags that the URL
// language is set intentionally. This allows directly linking to a fallback
// language, as we would otherwise assume the link is intended for the current
// page content language.
if ($url_language && !$url->getOption('language_hierarchy_fallback')) {
$entity_type = $translatable->getEntityTypeId();
if ($url->isRouted() && $url->getRouteName() === 'entity.' . $entity_type . '.canonical') {
// Check if the linked translation is just the closest fallback candidate
// for the current page language.
$page_language = \Drupal::languageManager()
->getCurrentLanguage(LanguageInterface::TYPE_CONTENT);
$candidate_langcode = $page_language->getId();
$url_langcode = $url_language->getId();
// Only proceed if the URL language is something other than the current
// page content language.
if ($url_langcode !== $candidate_langcode) {
while ($candidate_langcode && ($candidate_langcode !== $url_langcode) && !$translatable->hasTranslation($candidate_langcode)) {
$language_config = ConfigurableLanguage::load($candidate_langcode);
$candidate_langcode = $language_config->getThirdPartySetting('language_hierarchy', 'fallback_langcode', '');
}
// If a fallback translation was found, which matches the URL language,
// replace the language on the link with the current page content
// language as it is just the fallback for the current page.
if ($candidate_langcode && $candidate_langcode === $url_langcode && $translatable->hasTranslation($candidate_langcode)) {
$url->setOption('language', $page_language);
// Record that the language on this link has now been fixed.
$url->setOption('language_hierarchy_fallback', TRUE);
}
}
}
}
return $url;
}
/**
* Implements hook_preprocess_taxonomy_term().
*/
function language_hierarchy_preprocess_taxonomy_term(&$variables) {
if (!empty($variables['url'])) {
/** @var \Drupal\taxonomy\TermInterface $term */
$term = $variables['term'];
$url = $variables['url'];
if (is_string($url)) {
$url_obj = $term->toUrl();
if ($url_obj->toString() === $url) {
$url = $url_obj;
}
}
if ($url instanceof Url) {
$variables['url'] = language_hierarchy_fix_url_from_fallback($url, $term)
->toString();
}
}
}
/**
* Implements hook_preprocess_node().
*/
function language_hierarchy_preprocess_node(&$variables) {
if (!empty($variables['url'])) {
/** @var \Drupal\node\NodeInterface $node */
$node = $variables['node'];
$url = $variables['url'];
if (is_string($url)) {
$url_obj = $node->toUrl();
if ($url_obj->toString() === $url) {
$url = $url_obj;
}
}
if ($url instanceof Url) {
$variables['url'] = language_hierarchy_fix_url_from_fallback($url, $node)
->toString();
}
}
}
/**
* Implements hook_preprocess_image_formatter().
*/
function language_hierarchy_preprocess_image_formatter(&$variables) {
if (!empty($variables['url'])) {
$url = $variables['url'];
if ($url instanceof Url) {
// Check if the URL is for a translatable entity's canonical link. Do not
// change any other kind of link.
$entity = $url->getOption('entity');
if ($entity && $entity instanceof TranslatableInterface) {
$variables['url'] = language_hierarchy_fix_url_from_fallback($url, $entity);
}
}
}
}
/**
* Implements hook_preprocess_image_formatter().
*/
function language_hierarchy_preprocess_responsive_image_formatter(&$variables) {
// Process links for responsive images in the same way as regular images.
language_hierarchy_preprocess_image_formatter($variables);
}