Newer
Older
* Add sublanguage handling functionality to Drupal.
James Williams
committed
use Drupal\Component\Utility\NestedArray;
James Williams
committed
use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Database\Query\SelectInterface;
James Williams
committed
use Drupal\Core\Entity\EntityInterface;
James Williams
committed
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Form\FormStateInterface;
Andrew Hughes-Onslow
committed
use Drupal\Core\Language\LanguageInterface;
James Williams
committed
use Drupal\Core\Render\Element;
James Williams
committed
use Drupal\Core\Url;
use Drupal\language\ConfigurableLanguageInterface;
use Drupal\language\Entity\ConfigurableLanguage;
James Williams
committed
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().
Andrew Hughes-Onslow
committed
function language_hierarchy_language_fallback_candidates_alter(array &$candidates, array $context) {
James Williams
committed
if (empty($context['langcode'])) {
return;
}
$attempted_langcode = $context['langcode'];
$candidates = [];
// Record which languages have been iterated over, so loops can be avoided.
$iterated = [];
Peter Droogmans
committed
/** @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.
Andrew Hughes-Onslow
committed
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;
Andrew Hughes-Onslow
committed
// 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;
}
}
James Williams
committed
/**
* 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']);
Steven Jones
committed
// 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;
James Williams
committed
}
/**
* 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();
Peter Droogmans
committed
$languages = Drupal::languageManager()->getLanguages();
$options = [];
foreach ($languages as $language) {
Andrew Hughes-Onslow
committed
// Only include this language if it's not itself.
if ($language->getId() != $this_language->getId()) {
$options[$language->getId()] = $language->getName();
Peter Droogmans
committed
}
$form['language_hierarchy_fallback_langcode'] = [
'#type' => 'select',
'#title' => t('Translation fallback language'),
Andrew Hughes-Onslow
committed
'#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.
James Williams
committed
'#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')
);
James Williams
committed
James Williams
committed
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
* 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);
James Williams
committed
foreach ($languages as $langcode => $language) {
$depth = language_hierarchy_calculate_depth($language);
$form['languages'][$langcode]['#weight'] = $weights[$langcode];
$form['languages'][$langcode]['label'] = [
[
James Williams
committed
'#theme' => 'indentation',
'#size' => $depth,
],
James Williams
committed
$form['languages'][$langcode]['label'],
];
James Williams
committed
$form['languages'][$langcode]['id'] = [
'#type' => 'hidden',
'#value' => $langcode,
'#attributes' => [
'class' => [
'language-id',
],
],
'#wrapper_attributes' => [
'class' => [
'hidden',
],
],
'#weight' => 10,
James Williams
committed
];
$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']);
James Williams
committed
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
}
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.
James Williams
committed
*
* @return int
* The number of ancestors this language has.
James Williams
committed
*/
function language_hierarchy_calculate_depth(ConfigurableLanguageInterface $language) {
$depth = count(language_hierarchy_get_ancestors($language));
return $depth;
}
James Williams
committed
/**
* Returns ancestors language code of the provided language.
*
* @param \Drupal\language\ConfigurableLanguageInterface $language
* The language to get ancestors for.
James Williams
committed
*
* @return array
James Williams
committed
* 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;
James Williams
committed
if ($ancestor = ConfigurableLanguage::load($ancestor_langcode)) {
$ancestors[$ancestor->getId()] = $ancestor;
$language = $ancestor;
}
}
return $ancestors;
}
James Williams
committed
/**
* 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();
}
}
James Williams
committed
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
/**
* 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();
}
James Williams
committed
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
/**
* 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 */
James Williams
committed
$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'],
]);
Martin Keereman
committed
// 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);
James Williams
committed
}
}
James Williams
committed
/**
* 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')) {
James Williams
committed
$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', '');
}
James Williams
committed
James Williams
committed
// 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);
James Williams
committed
James Williams
committed
// Record that the language on this link has now been fixed.
$url->setOption('language_hierarchy_fallback', TRUE);
}
James Williams
committed
}
}
}
James Williams
committed
James Williams
committed
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'];
James Williams
committed
if (is_string($url)) {
$url_obj = $term->toUrl();
if ($url_obj->toString() === $url) {
$url = $url_obj;
}
}
if ($url instanceof Url) {
James Williams
committed
$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'];
James Williams
committed
if (is_string($url)) {
$url_obj = $node->toUrl();
if ($url_obj->toString() === $url) {
$url = $url_obj;
}
}
if ($url instanceof Url) {
James Williams
committed
$variables['url'] = language_hierarchy_fix_url_from_fallback($url, $node)
->toString();
}
}
}
/**
* Implements hook_preprocess_image_formatter().
*/
function language_hierarchy_preprocess_image_formatter(&$variables) {
James Williams
committed
if (!empty($variables['url'])) {
James Williams
committed
$url = $variables['url'];
James Williams
committed
if ($url instanceof Url) {
James Williams
committed
// Check if the URL is for a translatable entity's canonical link. Do not
// change any other kind of link.
James Williams
committed
$entity = $url->getOption('entity');
if ($entity && $entity instanceof TranslatableInterface) {
$variables['url'] = language_hierarchy_fix_url_from_fallback($url, $entity);
James Williams
committed
}
}
}
}
/**
* 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);
}