locale.module

Same filename in other branches
  1. 7.x modules/locale/locale.module
  2. 9 core/modules/locale/locale.module
  3. 8.9.x core/modules/locale/locale.module
  4. 10 core/modules/locale/locale.module

File

core/modules/locale/locale.module

View source
<?php


/**
 * @file
 */
use Drupal\Component\Gettext\PoItem;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Component\Utility\Xss;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Site\Settings;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Component\Utility\Crypt;
use Drupal\locale\LocaleEvent;
use Drupal\locale\LocaleEvents;

/**
 * Regular expression pattern used to localize JavaScript strings.
 */
const LOCALE_JS_STRING = '(?:(?:\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*")(?:\\s*\\+\\s*)?)+';

/**
 * Regular expression pattern used to match simple JS object literal.
 *
 * This pattern matches a basic JS object, but will fail on an object with
 * nested objects. Used in JS file parsing for string arg processing.
 */
const LOCALE_JS_OBJECT = '\\{.*?\\}';

/**
 * Regular expression to match an object containing a key 'context'.
 *
 * Pattern to match a JS object containing a 'context key' with a string value,
 * which is captured. Will fail if there are nested objects.
 */
define('LOCALE_JS_OBJECT_CONTEXT', '
  \\{              # match object literal start
  .*?             # match anything, non-greedy
  (?:             # match a form of "context"
    \'context\'
    |
    "context"
    |
    context
  )
  \\s*:\\s*         # match key-value separator ":"
  (' . LOCALE_JS_STRING . ')  # match context string
  .*?             # match anything, non-greedy
  \\}              # match end of object literal
');

/**
 * Flag for locally not customized interface translation.
 *
 * Such translations are imported from .po files downloaded from
 * localize.drupal.org for example.
 */
const LOCALE_NOT_CUSTOMIZED = 0;

/**
 * Flag for locally customized interface translation.
 *
 * Such translations are edited from their imported originals on the user
 * interface or are imported as customized.
 */
const LOCALE_CUSTOMIZED = 1;

/**
 * Translation update mode: Use local files only.
 *
 * When checking for available translation updates, only local files will be
 * used. Any remote translation file will be ignored. Also custom modules and
 * themes which have set a "server pattern" to use a remote translation server
 * will be ignored.
 */
const LOCALE_TRANSLATION_USE_SOURCE_LOCAL = 'local';

/**
 * Translation update mode: Use both remote and local files.
 *
 * When checking for available translation updates, both local and remote files
 * will be checked.
 */
const LOCALE_TRANSLATION_USE_SOURCE_REMOTE_AND_LOCAL = 'remote_and_local';

/**
 * Default location of gettext file on the translation server.
 *
 * @see locale_translation_default_translation_server().
 */
const LOCALE_TRANSLATION_DEFAULT_SERVER_PATTERN = 'https://ftp.drupal.org/files/translations/%core/%project/%project-%version.%language.po';

/**
 * The number of seconds that the translations status entry should be considered.
 */
const LOCALE_TRANSLATION_STATUS_TTL = 600;

/**
 * UI option for override of existing translations. Override any translation.
 */
const LOCALE_TRANSLATION_OVERWRITE_ALL = 'all';

/**
 * UI option for override of existing translations.
 *
 * Only override non-customized translations.
 */
const LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED = 'non_customized';

/**
 * UI option for override of existing translations.
 *
 * Don't override existing translations.
 */
const LOCALE_TRANSLATION_OVERWRITE_NONE = 'none';

/**
 * Translation source is a remote file.
 */
const LOCALE_TRANSLATION_REMOTE = 'remote';

/**
 * Translation source is a local file.
 */
const LOCALE_TRANSLATION_LOCAL = 'local';

/**
 * Translation source is the current translation.
 */
const LOCALE_TRANSLATION_CURRENT = 'current';

/**
 * Returns list of translatable languages.
 *
 * @return array
 *   Array of installed languages keyed by language name. English is omitted
 *   unless it is marked as translatable.
 */
function locale_translatable_language_list() {
    $languages = \Drupal::languageManager()->getLanguages();
    if (!locale_is_translatable('en')) {
        unset($languages['en']);
    }
    return $languages;
}

/**
 * Returns plural form index for a specific number.
 *
 * The index is computed from the formula of this language.
 *
 * @param int $count
 *   Number to return plural for.
 * @param string|null $langcode
 *   (optional) Language code to translate to a language other than what is used
 *   to display the page, or NULL for current language. Defaults to NULL.
 *
 * @return int
 *   The numeric index of the plural variant to use for this $langcode and
 *   $count combination or -1 if the language was not found or does not have a
 *   plural formula.
 */
function locale_get_plural($count, $langcode = NULL) {
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
    // Used to store precomputed plural indexes corresponding to numbers
    // individually for each language.
    $plural_indexes =& drupal_static(__FUNCTION__ . ':plurals', []);
    $langcode = $langcode ? $langcode : $language_interface->getId();
    if (!isset($plural_indexes[$langcode][$count])) {
        // Retrieve and statically cache the plural formulas for all languages.
        $plural_formulas = \Drupal::service('locale.plural.formula')->getFormula($langcode);
        // If there is a plural formula for the language, evaluate it for the given
        // $count and statically cache the result for the combination of language
        // and count, since the result will always be identical.
        if (!empty($plural_formulas)) {
            // Plural formulas are stored as an array for 0-199. 100 is the highest
            // modulo used but storing 0-99 is not enough because below 100 we often
            // find exceptions (1, 2, etc).
            $index = $count > 199 ? 100 + $count % 100 : $count;
            $plural_indexes[$langcode][$count] = $plural_formulas[$index] ?? $plural_formulas['default'];
        }
        elseif ($langcode == 'en') {
            $plural_indexes[$langcode][$count] = (int) ($count != 1);
        }
        else {
            $plural_indexes[$langcode][$count] = -1;
        }
    }
    return $plural_indexes[$langcode][$count];
}

/**
 * Updates default configuration when new modules or themes are installed.
 */
function locale_system_set_config_langcodes() {
    \Drupal::service('locale.config_manager')->updateDefaultConfigLangcodes();
}

/**
 * Imports translations when new modules or themes are installed.
 *
 * This function will start a batch to import translations for the added
 * components.
 *
 * @param array $components
 *   An array of arrays of component (theme and/or module) names to import
 *   translations for, indexed by type.
 */
function locale_system_update(array $components) {
    $components += [
        'module' => [],
        'theme' => [],
    ];
    $list = array_merge($components['module'], $components['theme']);
    // Skip running the translation imports if in the installer,
    // because it would break out of the installer flow. We have
    // built-in support for translation imports in the installer.
    if (!InstallerKernel::installationAttempted() && locale_translatable_language_list()) {
        $module_handler = \Drupal::moduleHandler();
        if (\Drupal::config('locale.settings')->get('translation.import_enabled')) {
            $module_handler->loadInclude('locale', 'inc', 'locale.compare');
            // Update the list of translatable projects and start the import batch.
            // Only when new projects are added the update batch will be triggered.
            // Not each enabled module will introduce a new project. E.g. sub modules.
            $projects = array_keys(locale_translation_build_projects());
            if ($list = array_intersect($list, $projects)) {
                $module_handler->loadInclude('locale', 'inc', 'locale.fetch');
                // Get translation status of the projects, download and update
                // translations.
                $options = _locale_translation_default_update_options();
                $batch = locale_translation_batch_update_build($list, [], $options);
                batch_set($batch);
            }
        }
        // Construct a batch to update configuration for all components. Installing
        // this component may have installed configuration from any number of other
        // components. Do this even if import is not enabled because parsing new
        // configuration may expose new source strings.
        $module_handler->loadInclude('locale', 'inc', 'locale.bulk');
        if ($batch = locale_config_batch_update_components([], [], [], TRUE)) {
            batch_set($batch);
        }
    }
}

/**
 * Delete translation history of modules and themes.
 *
 * Only the translation history is removed, not the source strings or
 * translations. This is not possible because strings are shared between
 * modules and we have no record of which string is used by which module.
 *
 * @param array $components
 *   An array of arrays of component (theme and/or module) names to import
 *   translations for, indexed by type.
 */
function locale_system_remove($components) {
    $components += [
        'module' => [],
        'theme' => [],
    ];
    $list = array_merge($components['module'], $components['theme']);
    if (locale_translatable_language_list()) {
        $module_handler = \Drupal::moduleHandler();
        $module_handler->loadInclude('locale', 'inc', 'locale.compare');
        $module_handler->loadInclude('locale', 'inc', 'locale.bulk');
        // Only when projects are removed, the translation files and records will be
        // deleted. Not each disabled module will remove a project, e.g., sub
        // modules.
        $projects = array_keys(locale_translation_get_projects());
        if ($list = array_intersect($list, $projects)) {
            locale_translation_file_history_delete($list);
            // Remove translation files.
            locale_translate_delete_translation_files($list, []);
            // Remove translatable projects.
            // Follow-up issue https://www.drupal.org/node/1842362 to replace the
            // {locale_project} table. Then change this to a function call.
            \Drupal::service('locale.project')->deleteMultiple($list);
            // Clear the translation status.
            locale_translation_status_delete_projects($list);
        }
    }
}

/**
 * Returns a list of translation files given a list of JavaScript files.
 *
 * This function checks all JavaScript files passed and invokes parsing if they
 * have not yet been parsed for Drupal.t() and Drupal.formatPlural() calls.
 * Also refreshes the JavaScript translation files if necessary, and returns
 * the filepath to the translation file (if any).
 *
 * @param array $files
 *   An array of local file paths.
 * @param \Drupal\Core\Language\LanguageInterface $language_interface
 *   The interface language the files should be translated into.
 *
 * @return string|null
 *   The filepath to the translation file or NULL if no translation is
 *   applicable.
 */
function locale_js_translate(array $files = [], $language_interface = NULL) {
    if (!isset($language_interface)) {
        $language_interface = \Drupal::languageManager()->getCurrentLanguage();
    }
    $dir = 'public://' . \Drupal::config('locale.settings')->get('javascript.directory');
    $parsed = \Drupal::state()->get('system.javascript_parsed', []);
    $new_files = FALSE;
    foreach ($files as $filepath) {
        if (!in_array($filepath, $parsed)) {
            // Don't parse our own translations files.
            if (!str_starts_with($filepath, $dir)) {
                _locale_parse_js_file($filepath);
                $parsed[] = $filepath;
                $new_files = TRUE;
            }
        }
    }
    // If there are any new source files we parsed, invalidate existing
    // JavaScript translation files for all languages, adding the refresh
    // flags into the existing array.
    if ($new_files) {
        $parsed += _locale_invalidate_js();
    }
    // If necessary, rebuild the translation file for the current language.
    if (!empty($parsed['refresh:' . $language_interface->getId()])) {
        // Don't clear the refresh flag on failure, so that another try will
        // be performed later.
        if (_locale_rebuild_js()) {
            unset($parsed['refresh:' . $language_interface->getId()]);
        }
        // Store any changes after refresh was attempted.
        \Drupal::state()->set('system.javascript_parsed', $parsed);
    }
    elseif ($new_files) {
        \Drupal::state()->set('system.javascript_parsed', $parsed);
    }
    // Add the translation JavaScript file to the page.
    $locale_javascripts = \Drupal::state()->get('locale.translation.javascript', []);
    $translation_file = NULL;
    if (!empty($files) && !empty($locale_javascripts[$language_interface->getId()])) {
        // Add the translation JavaScript file to the page.
        $translation_file = $dir . '/' . $language_interface->getId() . '_' . $locale_javascripts[$language_interface->getId()] . '.js';
    }
    return $translation_file;
}

/**
 * Form submission handler for language_admin_add_form().
 *
 * Set a batch for a newly-added language.
 */
function locale_form_language_admin_add_form_alter_submit($form, FormStateInterface $form_state) {
    \Drupal::moduleHandler()->loadInclude('locale', 'fetch.inc');
    $options = _locale_translation_default_update_options();
    if ($form_state->isValueEmpty('predefined_langcode') || $form_state->getValue('predefined_langcode') == 'custom') {
        $langcode = $form_state->getValue('langcode');
    }
    else {
        $langcode = $form_state->getValue('predefined_langcode');
    }
    if (\Drupal::config('locale.settings')->get('translation.import_enabled')) {
        // Download and import translations for the newly added language.
        $batch = locale_translation_batch_update_build([], [
            $langcode,
        ], $options);
        batch_set($batch);
    }
    // Create or update all configuration translations for this language. If we
    // are adding English then we need to run this even if import is not enabled,
    // because then we extract English sources from shipped configuration.
    if (\Drupal::config('locale.settings')->get('translation.import_enabled') || $langcode == 'en') {
        \Drupal::moduleHandler()->loadInclude('locale', 'bulk.inc');
        if ($batch = locale_config_batch_update_components($options, [
            $langcode,
        ])) {
            batch_set($batch);
        }
    }
}

/**
 * Form submission handler for language_admin_edit_form().
 */
function locale_form_language_admin_edit_form_alter_submit($form, FormStateInterface $form_state) {
    \Drupal::configFactory()->getEditable('locale.settings')
        ->set('translate_english', intval($form_state->getValue('locale_translate_english')))
        ->save();
}

/**
 * Checks whether $langcode is a language supported as a locale target.
 *
 * @param string $langcode
 *   The language code.
 *
 * @return bool
 *   Whether $langcode can be translated to in locale.
 */
function locale_is_translatable($langcode) {
    return $langcode != 'en' || \Drupal::config('locale.settings')->get('translate_english');
}

/**
 * Submit handler for the file system settings form.
 *
 * Clears the translation status when the Interface translations directory
 * changes. Without a translations directory local po files in the directory
 * should be ignored. The old translation status is no longer valid.
 */
function locale_system_file_system_settings_submit(&$form, FormStateInterface $form_state) {
    if ($form['translation_path']['#default_value'] != $form_state->getValue('translation_path')) {
        locale_translation_clear_status();
    }
    \Drupal::configFactory()->getEditable('locale.settings')
        ->set('translation.path', $form_state->getValue('translation_path'))
        ->save();
}

/**
 * Implements hook_preprocess_HOOK() for node templates.
 */
function locale_preprocess_node(&$variables) : void {
    
    /** @var \Drupal\node\NodeInterface $node */
    $node = $variables['node'];
    if ($node->language()
        ->getId() != LanguageInterface::LANGCODE_NOT_SPECIFIED) {
        $interface_language = \Drupal::languageManager()->getCurrentLanguage();
        $node_language = $node->language();
        if ($node_language->getId() != $interface_language->getId()) {
            // If the node language was different from the page language, we should
            // add markup to identify the language. Otherwise the page language is
            // inherited.
            $variables['attributes']['lang'] = $node_language->getId();
            if ($node_language->getDirection() != $interface_language->getDirection()) {
                // If text direction is different form the page's text direction, add
                // direction information as well.
                $variables['attributes']['dir'] = $node_language->getDirection();
            }
        }
    }
}

/**
 * Gets current translation status from the {locale_file} table.
 *
 * @return array
 *   Array of translation file objects.
 */
function locale_translation_get_file_history() {
    $history =& drupal_static(__FUNCTION__, []);
    if (empty($history)) {
        // Get file history from the database.
        $result = \Drupal::database()->select('locale_file')
            ->fields('locale_file', [
            'project',
            'langcode',
            'filename',
            'version',
            'uri',
            'timestamp',
            'last_checked',
        ])
            ->execute()
            ->fetchAll();
        foreach ($result as $file) {
            $file->type = $file->timestamp ? LOCALE_TRANSLATION_CURRENT : '';
            $history[$file->project][$file->langcode] = $file;
        }
    }
    return $history;
}

/**
 * Updates the {locale_file} table.
 *
 * @param object $file
 *   Object representing the file just imported.
 *
 * @return int
 *   FALSE on failure. Otherwise SAVED_NEW or SAVED_UPDATED.
 */
function locale_translation_update_file_history($file) {
    $status = \Drupal::database()->merge('locale_file')
        ->keys([
        'project' => $file->project,
        'langcode' => $file->langcode,
    ])
        ->fields([
        'version' => $file->version,
        'timestamp' => $file->timestamp,
        'last_checked' => $file->last_checked,
    ])
        ->execute();
    // The file history has changed, flush the static cache now.
    // @todo Can we make this more fine grained?
    drupal_static_reset('locale_translation_get_file_history');
    return $status;
}

/**
 * Deletes the history of downloaded translations.
 *
 * @param array $projects
 *   Project name(s) to be deleted from the file history. If both project(s) and
 *   language code(s) are specified the conditions will be ANDed.
 * @param array $langcodes
 *   Language code(s) to be deleted from the file history.
 */
function locale_translation_file_history_delete($projects = [], $langcodes = []) {
    $query = \Drupal::database()->delete('locale_file');
    if (!empty($projects)) {
        $query->condition('project', $projects, 'IN');
    }
    if (!empty($langcodes)) {
        $query->condition('langcode', $langcodes, 'IN');
    }
    $query->execute();
}

/**
 * Gets the current translation status.
 *
 * @todo What is 'translation status'?
 */
function locale_translation_get_status($projects = NULL, $langcodes = NULL) {
    $result = [];
    $status = \Drupal::keyValue('locale.translation_status')->getAll();
    \Drupal::moduleHandler()->loadInclude('locale', 'inc', 'locale.translation');
    $projects = $projects ? $projects : array_keys(locale_translation_get_projects());
    $langcodes = $langcodes ? $langcodes : array_keys(locale_translatable_language_list());
    // Get the translation status of each project-language combination. If no
    // status was stored, a new translation source is created.
    foreach ($projects as $project) {
        foreach ($langcodes as $langcode) {
            if (isset($status[$project][$langcode])) {
                $result[$project][$langcode] = $status[$project][$langcode];
            }
            else {
                $sources = locale_translation_build_sources([
                    $project,
                ], [
                    $langcode,
                ]);
                if (isset($sources[$project][$langcode])) {
                    $result[$project][$langcode] = $sources[$project][$langcode];
                }
            }
        }
    }
    return $result;
}

/**
 * Saves the status of translation sources in static cache.
 *
 * @param string $project
 *   Machine readable project name.
 * @param string $langcode
 *   Language code.
 * @param string $type
 *   Type of data to be stored.
 * @param object $data
 *   File object also containing timestamp when the translation is last updated.
 */
function locale_translation_status_save($project, $langcode, $type, $data) {
    // Load the translation status or build it if not already available.
    \Drupal::moduleHandler()->loadInclude('locale', 'inc', 'locale.translation');
    $status = locale_translation_get_status([
        $project,
    ]);
    if (empty($status)) {
        $projects = locale_translation_get_projects([
            $project,
        ]);
        if (isset($projects[$project])) {
            $status[$project][$langcode] = locale_translation_source_build($projects[$project], $langcode);
        }
    }
    // Merge the new status data with the existing status.
    if (isset($status[$project][$langcode])) {
        $request_time = \Drupal::time()->getRequestTime();
        switch ($type) {
            case LOCALE_TRANSLATION_REMOTE:
            case LOCALE_TRANSLATION_LOCAL:
                // Add the source data to the status array.
                $status[$project][$langcode]->files[$type] = $data;
                // Check if this translation is the most recent one. Set timestamp and
                // data type of the most recent translation source.
                if (isset($data->timestamp) && $data->timestamp) {
                    if ($data->timestamp > $status[$project][$langcode]->timestamp) {
                        $status[$project][$langcode]->timestamp = $data->timestamp;
                        $status[$project][$langcode]->last_checked = $request_time;
                        $status[$project][$langcode]->type = $type;
                    }
                }
                break;
            case LOCALE_TRANSLATION_CURRENT:
                $data->last_checked = $request_time;
                $status[$project][$langcode]->timestamp = $data->timestamp;
                $status[$project][$langcode]->last_checked = $data->last_checked;
                $status[$project][$langcode]->type = $type;
                locale_translation_update_file_history($data);
                break;
        }
        \Drupal::keyValue('locale.translation_status')->set($project, $status[$project]);
        \Drupal::state()->set('locale.translation_last_checked', $request_time);
    }
}

/**
 * Delete language entries from the status cache.
 *
 * @param array $langcodes
 *   Language code(s) to be deleted from the cache.
 */
function locale_translation_status_delete_languages($langcodes) {
    if ($status = locale_translation_get_status()) {
        foreach ($status as $project => $languages) {
            foreach ($languages as $langcode => $source) {
                if (in_array($langcode, $langcodes)) {
                    unset($status[$project][$langcode]);
                }
            }
            \Drupal::keyValue('locale.translation_status')->set($project, $status[$project]);
        }
    }
}

/**
 * Delete project entries from the status cache.
 *
 * @param array $projects
 *   Project name(s) to be deleted from the cache.
 */
function locale_translation_status_delete_projects($projects) {
    \Drupal::keyValue('locale.translation_status')->deleteMultiple($projects);
}

/**
 * Clear the translation status cache.
 */
function locale_translation_clear_status() {
    \Drupal::keyValue('locale.translation_status')->deleteAll();
    \Drupal::state()->delete('locale.translation_last_checked');
}

/**
 * Checks whether remote translation sources are used.
 *
 * @return bool
 *   Returns TRUE if remote translations sources should be taken into account
 *   when checking or importing translation files, FALSE otherwise.
 */
function locale_translation_use_remote_source() {
    return \Drupal::config('locale.settings')->get('translation.use_source') == LOCALE_TRANSLATION_USE_SOURCE_REMOTE_AND_LOCAL;
}

/**
 * Check that a string is safe to be added or imported as a translation.
 *
 * This test can be used to detect possibly bad translation strings. It should
 * not have any false positives. But it is only a test, not a transformation,
 * as it destroys valid HTML. We cannot reliably filter translation strings
 * on import because some strings are irreversibly corrupted. For example,
 * an &amp; in the translation would get encoded to &amp;amp; by
 * \Drupal\Component\Utility\Xss::filter() before being put in the database,
 * and thus would be displayed incorrectly.
 *
 * The allowed tag list is like \Drupal\Component\Utility\Xss::filterAdmin(),
 * but omitting div and img as not needed for translation and likely to cause
 * layout issues (div) or a possible attack vector (img).
 */
function locale_string_is_safe($string) {
    // Some strings have tokens in them. For tokens in the first part of href or
    // src HTML attributes, \Drupal\Component\Utility\Xss::filter() removes part
    // of the token, the part before the first colon.
    // \Drupal\Component\Utility\Xss::filter() assumes it could be an attempt to
    // inject javascript. When \Drupal\Component\Utility\Xss::filter() removes
    // part of tokens, it causes the string to not be translatable when it should
    // be translatable.
    // @see \Drupal\Tests\locale\Kernel\LocaleStringIsSafeTest::testLocaleStringIsSafe()
    //
    // We can recognize tokens since they are wrapped with brackets and are only
    // composed of alphanumeric characters, colon, underscore, and dashes. We can
    // be sure these strings are safe to strip out before the string is checked in
    // \Drupal\Component\Utility\Xss::filter() because no dangerous javascript
    // will match that pattern.
    //
    // Strings with tokens should not be assumed to be dangerous because even if
    // we evaluate them to be safe here, later replacing the token inside the
    // string will automatically mark it as unsafe as it is not the same string
    // anymore.
    //
    // @todo Do not strip out the token. Fix
    //   \Drupal\Component\Utility\Xss::filter() to not incorrectly alter the
    //   string. https://www.drupal.org/node/2372127
    $string = preg_replace('/\\[[a-z0-9_-]+(:[a-z0-9_-]+)+\\]/i', '', $string);
    return Html::decodeEntities($string) == Html::decodeEntities(Xss::filter($string, [
        'a',
        'abbr',
        'acronym',
        'address',
        'b',
        'bdo',
        'big',
        'blockquote',
        'br',
        'caption',
        'cite',
        'code',
        'col',
        'colgroup',
        'dd',
        'del',
        'dfn',
        'dl',
        'dt',
        'em',
        'h1',
        'h2',
        'h3',
        'h4',
        'h5',
        'h6',
        'hr',
        'i',
        'ins',
        'kbd',
        'li',
        'ol',
        'p',
        'pre',
        'q',
        'samp',
        'small',
        'span',
        'strong',
        'sub',
        'sup',
        'table',
        'tbody',
        'td',
        'tfoot',
        'th',
        'thead',
        'tr',
        'tt',
        'ul',
        'var',
    ]));
}

/**
 * Refresh related information after string translations have been updated.
 *
 * The information that will be refreshed includes:
 * - JavaScript translations.
 * - Locale cache.
 * - Render cache.
 *
 * @param array $langcodes
 *   Language codes for updated translations.
 * @param array $lids
 *   (optional) List of string identifiers that have been updated / created.
 *   If not provided, all caches for the affected languages are cleared.
 */
function _locale_refresh_translations($langcodes, $lids = []) {
    if (!empty($langcodes)) {
        // Update javascript translations if any of the strings has a javascript
        // location, or if no string ids were provided, update all languages.
        if (empty($lids) || !empty(\Drupal::service('locale.storage')->getStrings([
            'lid' => $lids,
            'type' => 'javascript',
        ]))) {
            array_map('_locale_invalidate_js', $langcodes);
        }
    }
    // Throw locale.save_translation event.
    \Drupal::service('event_dispatcher')->dispatch(new LocaleEvent($langcodes, $lids), LocaleEvents::SAVE_TRANSLATION);
}

/**
 * Refreshes configuration after string translations have been updated.
 *
 * @param array $langcodes
 *   Language codes for updated translations.
 * @param array $lids
 *   List of string identifiers that have been updated / created.
 */
function _locale_refresh_configuration(array $langcodes, array $lids) {
    $locale_config_manager = \Drupal::service('locale.config_manager');
    if ($lids && $langcodes && ($names = $locale_config_manager->getStringNames($lids))) {
        $locale_config_manager->updateConfigTranslations($names, $langcodes);
    }
}

/**
 * Removes the quotes and string concatenations from the string.
 *
 * @param string $string
 *   Single or double quoted strings, optionally concatenated by plus (+) sign.
 *
 * @return string
 *   String with leading and trailing quotes removed.
 */
function _locale_strip_quotes($string) {
    return implode('', preg_split('~(?<!\\\\)[\'"]\\s*\\+\\s*[\'"]~s', substr($string, 1, -1)));
}

/**
 * Parses a JavaScript file, extracts translatable strings, and saves them.
 *
 * Strings are extracted from both Drupal.t() and Drupal.formatPlural().
 *
 * @param string $filepath
 *   File name to parse.
 *
 * @throws Exception
 *   If a non-local file is attempted to be parsed.
 */
function _locale_parse_js_file($filepath) {
    // The file path might contain a query string, so make sure we only use the
    // actual file.
    $parsed_url = UrlHelper::parse($filepath);
    $filepath = $parsed_url['path'];
    // If there is still a protocol component in the path, reject that.
    if (strpos($filepath, ':')) {
        throw new Exception('Only local files should be passed to _locale_parse_js_file().');
    }
    // Load the JavaScript file.
    $file = file_get_contents($filepath);
    // Match all calls to Drupal.t() in an array.
    // Note: \s also matches newlines with the 's' modifier.
    preg_match_all('~
    [^\\w]Drupal\\s*\\.\\s*t\\s*                       # match "Drupal.t" with whitespace
    \\(\\s*                                         # match "(" argument list start
    (' . LOCALE_JS_STRING . ')\\s*                 # capture string argument
    (?:,\\s*' . LOCALE_JS_OBJECT . '\\s*            # optionally capture str args
      (?:,\\s*' . LOCALE_JS_OBJECT_CONTEXT . '\\s*) # optionally capture context
    ?)?                                           # close optional args
    [,\\)]                                         # match ")" or "," to finish
    ~sx', $file, $t_matches);
    // Match all Drupal.formatPlural() calls in another array.
    preg_match_all('~
    [^\\w]Drupal\\s*\\.\\s*formatPlural\\s*  # match "Drupal.formatPlural" with whitespace
    \\(                                  # match "(" argument list start
    \\s*.+?\\s*,\\s*                       # match count argument
    (' . LOCALE_JS_STRING . ')\\s*,\\s*   # match singular string argument
    (                             # capture plural string argument
      (?:                         # non-capturing group to repeat string pieces
        (?:
          \'(?:\\\\\'|[^\'])*\'   # match single-quoted string with any character except unescaped single-quote
          |
          "(?:\\\\"|[^"])*"       # match double-quoted string with any character except unescaped double-quote
        )
        (?:\\s*\\+\\s*)?             # match "+" with possible whitespace, for str concat
      )+                          # match multiple because we supports concatenating strs
    )\\s*                          # end capturing of plural string argument
    (?:,\\s*' . LOCALE_JS_OBJECT . '\\s*          # optionally capture string args
      (?:,\\s*' . LOCALE_JS_OBJECT_CONTEXT . '\\s*)?  # optionally capture context
    )?
    [,\\)]
    ~sx', $file, $plural_matches);
    $matches = [];
    // Add strings from Drupal.t().
    foreach ($t_matches[1] as $key => $string) {
        $matches[] = [
            'source' => _locale_strip_quotes($string),
            'context' => _locale_strip_quotes($t_matches[2][$key]),
        ];
    }
    // Add string from Drupal.formatPlural().
    foreach ($plural_matches[1] as $key => $string) {
        $matches[] = [
            'source' => _locale_strip_quotes($string) . PoItem::DELIMITER . _locale_strip_quotes($plural_matches[2][$key]),
            'context' => _locale_strip_quotes($plural_matches[3][$key]),
        ];
    }
    // Loop through all matches and process them.
    foreach ($matches as $match) {
        $source = \Drupal::service('locale.storage')->findString($match);
        if (!$source) {
            // We don't have the source string yet, thus we insert it into the
            // database.
            $source = \Drupal::service('locale.storage')->createString($match);
        }
        // Besides adding the location this will tag it for current version.
        $source->addLocation('javascript', $filepath);
        $source->save();
    }
}

/**
 * Force the JavaScript translation file(s) to be refreshed.
 *
 * This function sets a refresh flag for a specified language, or all
 * languages except English, if none specified. JavaScript translation
 * files are rebuilt (with locale_update_js_files()) the next time a
 * request is served in that language.
 *
 * @param string|null $langcode
 *   (optional) The language code for which the file needs to be refreshed, or
 *   NULL to refresh all languages. Defaults to NULL.
 *
 * @return array
 *   New content of the 'system.javascript_parsed' variable.
 */
function _locale_invalidate_js($langcode = NULL) {
    $parsed = \Drupal::state()->get('system.javascript_parsed', []);
    if (empty($langcode)) {
        // Invalidate all languages.
        $languages = locale_translatable_language_list();
        foreach ($languages as $language_code => $data) {
            $parsed['refresh:' . $language_code] = 'waiting';
        }
    }
    else {
        // Invalidate single language.
        $parsed['refresh:' . $langcode] = 'waiting';
    }
    \Drupal::state()->set('system.javascript_parsed', $parsed);
    return $parsed;
}

/**
 * (Re-)Creates the JavaScript translation file for a language.
 *
 * @param string|null $langcode
 *   (optional) The language that the translation file should be (re)created
 *   for, or NULL for the current language. Defaults to NULL.
 *
 * @return bool
 *   TRUE if translation file exists, FALSE otherwise.
 */
function _locale_rebuild_js($langcode = NULL) {
    $config = \Drupal::config('locale.settings');
    if (!isset($langcode)) {
        $language = \Drupal::languageManager()->getCurrentLanguage();
    }
    else {
        // Get information about the locale.
        $languages = \Drupal::languageManager()->getLanguages();
        $language = $languages[$langcode];
    }
    // Construct the array for JavaScript translations.
    // Only add strings with a translation to the translations array.
    $conditions = [
        'type' => 'javascript',
        'language' => $language->getId(),
        'translated' => TRUE,
    ];
    $translations = [];
    foreach (\Drupal::service('locale.storage')->getTranslations($conditions) as $data) {
        $translations[$data->context][$data->source] = $data->translation;
    }
    // Include custom string overrides.
    $custom_strings = Settings::get('locale_custom_strings_' . $language->getId(), []);
    foreach ($custom_strings as $context => $strings) {
        foreach ($strings as $source => $translation) {
            $translations[$context][$source] = $translation;
        }
    }
    // Construct the JavaScript file, if there are translations.
    $data_hash = NULL;
    $data = $status = '';
    if (!empty($translations)) {
        $data = [
            'strings' => $translations,
        ];
        $locale_plurals = \Drupal::service('locale.plural.formula')->getFormula($language->getId());
        if ($locale_plurals) {
            $data['pluralFormula'] = $locale_plurals;
        }
        $data = 'window.drupalTranslations = ' . Json::encode($data) . ';';
        $data_hash = Crypt::hashBase64($data);
    }
    // Construct the filepath where JS translation files are stored.
    // There is (on purpose) no front end to edit that variable.
    $dir = 'public://' . $config->get('javascript.directory');
    // Delete old file, if we have no translations anymore, or a different file to
    // be saved.
    $locale_javascripts = \Drupal::state()->get('locale.translation.javascript', []);
    $changed_hash = !isset($locale_javascripts[$language->getId()]) || $locale_javascripts[$language->getId()] != $data_hash;
    
    /** @var \Drupal\Core\File\FileSystemInterface $file_system */
    $file_system = \Drupal::service('file_system');
    if (!empty($locale_javascripts[$language->getId()]) && (!$data || $changed_hash)) {
        try {
            $file_system->delete($dir . '/' . $language->getId() . '_' . $locale_javascripts[$language->getId()] . '.js');
        } catch (FileException) {
            // Ignore.
        }
        $locale_javascripts[$language->getId()] = '';
        $status = 'deleted';
    }
    // Only create a new file if the content has changed or the original file got
    // lost.
    $dest = $dir . '/' . $language->getId() . '_' . $data_hash . '.js';
    if ($data && ($changed_hash || !file_exists($dest))) {
        // Ensure that the directory exists and is writable, if possible.
        $file_system->prepareDirectory($dir, FileSystemInterface::CREATE_DIRECTORY);
        // Save the file.
        try {
            if ($file_system->saveData($data, $dest)) {
                $locale_javascripts[$language->getId()] = $data_hash;
                // If we deleted a previous version of the file and we replace it with a
                // new one we have an update.
                if ($status == 'deleted') {
                    $status = 'updated';
                }
                elseif ($changed_hash) {
                    $status = 'created';
                }
                else {
                    $status = 'rebuilt';
                }
            }
            else {
                $locale_javascripts[$language->getId()] = '';
                $status = 'error';
            }
        } catch (FileException) {
            // Do nothing.
        }
    }
    // Save the new JavaScript hash (or an empty value if the file just got
    // deleted). Act only if some operation was executed that changed the hash
    // code.
    if ($status && $changed_hash) {
        \Drupal::state()->set('locale.translation.javascript', $locale_javascripts);
    }
    // Log the operation and return success flag.
    $logger = \Drupal::logger('locale');
    switch ($status) {
        case 'updated':
            $logger->notice('Updated JavaScript translation file for the language %language.', [
                '%language' => $language->getName(),
            ]);
            return TRUE;
        case 'rebuilt':
            $logger->warning('JavaScript translation file %file.js was lost.', [
                '%file' => $locale_javascripts[$language->getId()],
            ]);
        // Proceed to the 'created' case as the JavaScript translation file has
        // been created again.
        case 'created':
            $logger->notice('Created JavaScript translation file for the language %language.', [
                '%language' => $language->getName(),
            ]);
            return TRUE;
        case 'deleted':
            $logger->notice('Removed JavaScript translation file for the language %language because no translations currently exist for that language.', [
                '%language' => $language->getName(),
            ]);
            return TRUE;
        case 'error':
            $logger->error('An error occurred during creation of the JavaScript translation file for the language %language.', [
                '%language' => $language->getName(),
            ]);
            return FALSE;
        default:
            // No operation needed.
            return TRUE;
    }
}

/**
 * Form element callback: After build changes to the language update table.
 *
 * Adds labels to the languages and removes checkboxes from languages from which
 * translation files could not be found.
 */
function locale_translation_language_table($form_element) {
    // Remove checkboxes of languages without updates.
    if ($form_element['#not_found']) {
        foreach ($form_element['#not_found'] as $langcode) {
            $form_element[$langcode] = [];
        }
    }
    return $form_element;
}

Functions

Title Deprecated Summary
locale_form_language_admin_add_form_alter_submit Form submission handler for language_admin_add_form().
locale_form_language_admin_edit_form_alter_submit Form submission handler for language_admin_edit_form().
locale_get_plural Returns plural form index for a specific number.
locale_is_translatable Checks whether $langcode is a language supported as a locale target.
locale_js_translate Returns a list of translation files given a list of JavaScript files.
locale_preprocess_node Implements hook_preprocess_HOOK() for node templates.
locale_string_is_safe Check that a string is safe to be added or imported as a translation.
locale_system_file_system_settings_submit Submit handler for the file system settings form.
locale_system_remove Delete translation history of modules and themes.
locale_system_set_config_langcodes Updates default configuration when new modules or themes are installed.
locale_system_update Imports translations when new modules or themes are installed.
locale_translatable_language_list Returns list of translatable languages.
locale_translation_clear_status Clear the translation status cache.
locale_translation_file_history_delete Deletes the history of downloaded translations.
locale_translation_get_file_history Gets current translation status from the {locale_file} table.
locale_translation_get_status Gets the current translation status.
locale_translation_language_table Form element callback: After build changes to the language update table.
locale_translation_status_delete_languages Delete language entries from the status cache.
locale_translation_status_delete_projects Delete project entries from the status cache.
locale_translation_status_save Saves the status of translation sources in static cache.
locale_translation_update_file_history Updates the {locale_file} table.
locale_translation_use_remote_source Checks whether remote translation sources are used.
_locale_invalidate_js Force the JavaScript translation file(s) to be refreshed.
_locale_parse_js_file Parses a JavaScript file, extracts translatable strings, and saves them.
_locale_rebuild_js (Re-)Creates the JavaScript translation file for a language.
_locale_refresh_configuration Refreshes configuration after string translations have been updated.
_locale_refresh_translations Refresh related information after string translations have been updated.
_locale_strip_quotes Removes the quotes and string concatenations from the string.

Constants

Title Deprecated Summary
LOCALE_CUSTOMIZED Flag for locally customized interface translation.
LOCALE_JS_OBJECT Regular expression pattern used to match simple JS object literal.
LOCALE_JS_OBJECT_CONTEXT Regular expression to match an object containing a key 'context'.
LOCALE_JS_STRING Regular expression pattern used to localize JavaScript strings.
LOCALE_NOT_CUSTOMIZED Flag for locally not customized interface translation.
LOCALE_TRANSLATION_CURRENT Translation source is the current translation.
LOCALE_TRANSLATION_DEFAULT_SERVER_PATTERN Default location of gettext file on the translation server.
LOCALE_TRANSLATION_LOCAL Translation source is a local file.
LOCALE_TRANSLATION_OVERWRITE_ALL UI option for override of existing translations. Override any translation.
LOCALE_TRANSLATION_OVERWRITE_NONE UI option for override of existing translations.
LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED UI option for override of existing translations.
LOCALE_TRANSLATION_REMOTE Translation source is a remote file.
LOCALE_TRANSLATION_STATUS_TTL The number of seconds that the translations status entry should be considered.
LOCALE_TRANSLATION_USE_SOURCE_LOCAL Translation update mode: Use local files only.
LOCALE_TRANSLATION_USE_SOURCE_REMOTE_AND_LOCAL Translation update mode: Use both remote and local files.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.