Skip to content
Snippets Groups Projects
language_hierarchy.module 21.7 KiB
Newer Older
Jelle Sebreghts's avatar
Jelle Sebreghts committed
<?php

/**
 * @file
 * Add sublanguage handling functionality to Drupal.
Jelle Sebreghts's avatar
Jelle Sebreghts committed
 */

use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\language\ConfigurableLanguageInterface;
use Drupal\language\Entity\ConfigurableLanguage;
/**
 * 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;
  }
}

Jelle Sebreghts's avatar
Jelle Sebreghts committed
/**
 * Implements hook_language_fallback_candidates_alter().
Jelle Sebreghts's avatar
Jelle Sebreghts committed
 */
function language_hierarchy_language_fallback_candidates_alter(array &$candidates, array $context) {
  $attempted_langcode = $context['langcode'];
  // 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);
Jelle Sebreghts's avatar
Jelle Sebreghts committed
    }
Jelle Sebreghts's avatar
Jelle Sebreghts 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;
  }
Jelle Sebreghts's avatar
Jelle Sebreghts 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']);
  // 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();
Jelle Sebreghts's avatar
Jelle Sebreghts committed
  }
  $form['language_hierarchy_fallback_langcode'] = [
    '#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.'),
    '#default_value' => $this_language->getThirdPartySetting('language_hierarchy', 'fallback_langcode', ''),
    // Allow to not fall back on any other language.
  $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()
Jelle Sebreghts's avatar
Jelle Sebreghts committed
 */
function language_hierarchy_form_language_admin_edit_form_builder($entity_type, ConfigurableLanguageInterface $this_language, &$form, FormStateInterface $form_state) {
  $this_language->setThirdPartySetting(
    $form_state->getValue('language_hierarchy_fallback_langcode')
Jelle Sebreghts's avatar
Jelle Sebreghts committed
}
/**
 * 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'] = [
      [
    $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.
 *   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.
 *   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) {
      // 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);
}