language_hierarchy.module in Language Hierarchy 2.x
Same filename and directory in other branches
Add sublanguage handling functionality to Drupal.
File
language_hierarchy.moduleView source
<?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_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']);
$fields = [
$alias . '.priority' => 'DESC',
] + $fields;
// Limit the query as only the first result will be used anyway.
$query
->range(0, 1);
}
/**
* 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);
}