You are here

language_hierarchy.module in Language Hierarchy 8

Same filename and directory in other branches
  1. 7 language_hierarchy.module
  2. 2.x language_hierarchy.module

Add sublanguage handling functionality to Drupal.

File

language_hierarchy.module
View 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));
  $form['languages']['#header']['weight'] = [
    'data' => $form['languages']['#header']['weight'],
    'colspan' => 2,
  ];
  $pos = array_search('weight', array_keys($form['languages']['#header']));
  $form['languages']['#header'] = array_slice($form['languages']['#header'], 0, $pos + 1) + [
    'parent' => t('Parent'),
  ] + 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',
        ],
      ],
    ];
    $form['languages'][$langcode]['parent'] = [
      '#type' => 'select',
      '#empty_value' => '',
      '#options' => $flattened,
      '#attributes' => [
        'class' => [
          'language-parent',
        ],
      ],
      '#default_value' => $language
        ->getThirdPartySetting('language_hierarchy', 'fallback_langcode', NULL),
    ];
  }
  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'],
    ]);
    $query
      ->isNotNull("{$flag_alias}.is_highest");
  }
}

/**
 * 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);
}

Functions

Namesort descending Description
language_hierarchy_calculate_depth Get the depth of a language inside hierarchy.
language_hierarchy_configurable_language_delete Implements hook_ENTITY_TYPE_delete().
language_hierarchy_configurable_language_insert Implements hook_ENTITY_TYPE_insert().
language_hierarchy_configurable_language_update Implements hook_ENTITY_TYPE_update().
language_hierarchy_fix_url_from_fallback Replaces the URL language if it is just a fallback translation.
language_hierarchy_form_language_admin_edit_form_alter Implements hook_form_FORM_ID_alter().
language_hierarchy_form_language_admin_edit_form_builder Entity builder for the language form language_fallback options.
language_hierarchy_form_language_admin_overview_form_alter Implements hook_form_FORM_ID_alter() for language_admin_overview_form().
language_hierarchy_get_ancestors Returns ancestors language code of the provided language.
language_hierarchy_get_sorted_flattened_hierarchy Helper function to recursively sort the hierarchy tree of languages.
language_hierarchy_language_admin_overview_form_submit Form submission handler for language_admin_add_form().
language_hierarchy_language_fallback_candidates_alter Implements hook_language_fallback_candidates_alter().
language_hierarchy_preprocess_image_formatter Implements hook_preprocess_image_formatter().
language_hierarchy_preprocess_node Implements hook_preprocess_node().
language_hierarchy_preprocess_responsive_image_formatter Implements hook_preprocess_image_formatter().
language_hierarchy_preprocess_taxonomy_term Implements hook_preprocess_taxonomy_term().
language_hierarchy_query_language_hierarchy_limit_alter Implements hook_query_TAG_alter().
language_hierarchy_query_path_alias_language_fallback_alter Implements hook_query_TAG_alter().
language_hierarchy_update_priorities Updates the language_hierarchy_priority table based on current configuration.