--- /dev/null
+<?php
+
+namespace Drupal\locale\Tests;
+
+use Drupal\simpletest\WebTestBase;
+use Drupal\Core\Language\LanguageInterface;
+
+/**
+ * Tests the import of locale files.
+ *
+ * @group locale
+ */
+class LocaleImportFunctionalTest extends WebTestBase {
+
+ /**
+ * Modules to enable.
+ *
+ * @var array
+ */
+ public static $modules = ['locale', 'dblog'];
+
+ /**
+ * A user able to create languages and import translations.
+ *
+ * @var \Drupal\user\Entity\User
+ */
+ protected $adminUser;
+
+ /**
+ * A user able to create languages, import translations and access site
+ * reports.
+ *
+ * @var \Drupal\user\Entity\User
+ */
+ protected $adminUserAccessSiteReports;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ // Copy test po files to the translations directory.
+ file_unmanaged_copy(__DIR__ . '/../../tests/test.de.po', 'translations://', FILE_EXISTS_REPLACE);
+ file_unmanaged_copy(__DIR__ . '/../../tests/test.xx.po', 'translations://', FILE_EXISTS_REPLACE);
+
+ $this->adminUser = $this->drupalCreateUser(['administer languages', 'translate interface', 'access administration pages']);
+ $this->adminUserAccessSiteReports = $this->drupalCreateUser(['administer languages', 'translate interface', 'access administration pages', 'access site reports']);
+ $this->drupalLogin($this->adminUser);
+
+ // Enable import of translations. By default this is disabled for automated
+ // tests.
+ $this->config('locale.settings')
+ ->set('translation.import_enabled', TRUE)
+ ->save();
+ }
+
+ /**
+ * Test import of standalone .po files.
+ */
+ public function testStandalonePoFile() {
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFile(), [
+ 'langcode' => 'fr',
+ ]);
+ $this->config('locale.settings');
+ // The import should automatically create the corresponding language.
+ $this->assertRaw(t('The language %language has been created.', ['%language' => 'French']), 'The language has been automatically created.');
+
+ // The import should have created 8 strings.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 8, '%update' => 0, '%delete' => 0]), 'The translation file was successfully imported.');
+
+ // This import should have saved plural forms to have 2 variants.
+ $locale_plurals = \Drupal::service('locale.plural.formula')->getNumberOfPlurals('fr');
+ $this->assertEqual(2, $locale_plurals, 'Plural number initialized.');
+
+ // Ensure we were redirected correctly.
+ $this->assertUrl(\Drupal::url('locale.translate_page', [], ['absolute' => TRUE]), [], 'Correct page redirection.');
+
+ // Try importing a .po file with invalid tags.
+ $this->importPoFile($this->getBadPoFile(), [
+ 'langcode' => 'fr',
+ ]);
+
+ // The import should have created 1 string and rejected 2.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 1, '%update' => 0, '%delete' => 0]), 'The translation file was successfully imported.');
+
+ $skip_message = \Drupal::translation()->formatPlural(2, 'One translation string was skipped because of disallowed or malformed HTML. <a href=":url">See the log</a> for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.', [':url' => \Drupal::url('dblog.overview')]);
+ $this->assertRaw($skip_message, 'Unsafe strings were skipped.');
+
+ // Repeat the process with a user that can access site reports, and this
+ // time the different warnings must contain links to the log.
+ $this->drupalLogin($this->adminUserAccessSiteReports);
+
+ // Try importing a .po file with invalid tags.
+ $this->importPoFile($this->getBadPoFile(), [
+ 'langcode' => 'fr',
+ ]);
+
+ $skip_message = \Drupal::translation()->formatPlural(2, 'One translation string was skipped because of disallowed or malformed HTML. <a href=":url">See the log</a> for details.', '@count translation strings were skipped because of disallowed or malformed HTML. <a href=":url">See the log</a> for details.', [':url' => \Drupal::url('dblog.overview')]);
+ $this->assertRaw($skip_message, 'Unsafe strings were skipped.');
+
+ // Check empty files import with a user that cannot access site reports..
+ $this->drupalLogin($this->adminUser);
+ // Try importing a zero byte sized .po file.
+ $this->importPoFile($this->getEmptyPoFile(), [
+ 'langcode' => 'fr',
+ ]);
+ // The import should have created 0 string and rejected 0.
+ $this->assertRaw(t('One translation file could not be imported. See the log for details.'), 'The empty translation file import reported no translations imported.');
+
+ // Repeat the process with a user that can access site reports, and this
+ // time the different warnings must contain links to the log.
+ $this->drupalLogin($this->adminUserAccessSiteReports);
+ // Try importing a zero byte sized .po file.
+ $this->importPoFile($this->getEmptyPoFile(), [
+ 'langcode' => 'fr',
+ ]);
+ // The import should have created 0 string and rejected 0.
+ $this->assertRaw(t('One translation file could not be imported. <a href=":url">See the log</a> for details.', [':url' => \Drupal::url('dblog.overview')]), 'The empty translation file import reported no translations imported.');
+
+ // Try importing a .po file which doesn't exist.
+ $name = $this->randomMachineName(16);
+ $this->drupalPostForm('admin/config/regional/translate/import', [
+ 'langcode' => 'fr',
+ 'files[file]' => $name,
+ ], t('Import'));
+ $this->assertUrl(\Drupal::url('locale.translate_import', [], ['absolute' => TRUE]), [], 'Correct page redirection.');
+ $this->assertText(t('File to import not found.'), 'File to import not found message.');
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // strings are kept.
+ $this->importPoFile($this->getOverwritePoFile(), [
+ 'langcode' => 'fr',
+ ]);
+
+ // The import should have created 1 string.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 1, '%update' => 0, '%delete' => 0]), 'The translation file was successfully imported.');
+ // Ensure string wasn't overwritten.
+ $search = [
+ 'string' => 'Montag',
+ 'langcode' => 'fr',
+ 'translation' => 'translated',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), 'String not overwritten by imported string.');
+
+ // This import should not have changed number of plural forms.
+ $locale_plurals = \Drupal::service('locale.plural.formula')->getNumberOfPlurals('fr');
+ $this->assertEqual(2, $locale_plurals, 'Plural numbers untouched.');
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // strings are overwritten.
+ $this->importPoFile($this->getOverwritePoFile(), [
+ 'langcode' => 'fr',
+ 'overwrite_options[not_customized]' => TRUE,
+ ]);
+
+ // The import should have updated 2 strings.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 0, '%update' => 2, '%delete' => 0]), 'The translation file was successfully imported.');
+ // Ensure string was overwritten.
+ $search = [
+ 'string' => 'Montag',
+ 'langcode' => 'fr',
+ 'translation' => 'translated',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), 'String overwritten by imported string.');
+ // This import should have changed number of plural forms.
+ $locale_plurals = \Drupal::service('locale.plural.formula')->reset()->getNumberOfPlurals('fr');
+ $this->assertEqual(3, $locale_plurals, 'Plural numbers changed.');
+
+ // Importing a .po file and mark its strings as customized strings.
+ $this->importPoFile($this->getCustomPoFile(), [
+ 'langcode' => 'fr',
+ 'customized' => TRUE,
+ ]);
+
+ // The import should have created 6 strings.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 6, '%update' => 0, '%delete' => 0]), 'The customized translation file was successfully imported.');
+
+ // The database should now contain 6 customized strings (two imported
+ // strings are not translated).
+ $count = db_query('SELECT COUNT(*) FROM {locales_target} WHERE customized = :custom', [':custom' => 1])->fetchField();
+ $this->assertEqual($count, 6, 'Customized translations successfully imported.');
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // customized strings are kept.
+ $this->importPoFile($this->getCustomOverwritePoFile(), [
+ 'langcode' => 'fr',
+ 'overwrite_options[not_customized]' => TRUE,
+ 'overwrite_options[customized]' => FALSE,
+ ]);
+
+ // The import should have created 1 string.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 1, '%update' => 0, '%delete' => 0]), 'The customized translation file was successfully imported.');
+ // Ensure string wasn't overwritten.
+ $search = [
+ 'string' => 'januari',
+ 'langcode' => 'fr',
+ 'translation' => 'translated',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), 'Customized string not overwritten by imported string.');
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // customized strings are overwritten.
+ $this->importPoFile($this->getCustomOverwritePoFile(), [
+ 'langcode' => 'fr',
+ 'overwrite_options[not_customized]' => FALSE,
+ 'overwrite_options[customized]' => TRUE,
+ ]);
+
+ // The import should have updated 2 strings.
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 0, '%update' => 2, '%delete' => 0]), 'The customized translation file was successfully imported.');
+ // Ensure string was overwritten.
+ $search = [
+ 'string' => 'januari',
+ 'langcode' => 'fr',
+ 'translation' => 'translated',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), 'Customized string overwritten by imported string.');
+
+ }
+
+ /**
+ * Test msgctxt context support.
+ */
+ public function testLanguageContext() {
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithContext(), [
+ 'langcode' => 'hr',
+ ]);
+
+ // We cast the return value of t() to string so as to retrieve the
+ // translated value, rendered as a string.
+ $this->assertIdentical((string) t('May', [], ['langcode' => 'hr', 'context' => 'Long month name']), 'Svibanj', 'Long month name context is working.');
+ $this->assertIdentical((string) t('May', [], ['langcode' => 'hr']), 'Svi.', 'Default context is working.');
+ }
+
+ /**
+ * Test empty msgstr at end of .po file see #611786.
+ */
+ public function testEmptyMsgstr() {
+ $langcode = 'hu';
+
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithMsgstr(), [
+ 'langcode' => $langcode,
+ ]);
+
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 1, '%update' => 0, '%delete' => 0]), 'The translation file was successfully imported.');
+ $this->assertIdentical((string) t('Operations', [], ['langcode' => $langcode]), 'Műveletek', 'String imported and translated.');
+
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithEmptyMsgstr(), [
+ 'langcode' => $langcode,
+ 'overwrite_options[not_customized]' => TRUE,
+ ]);
+ $this->assertRaw(t('One translation file imported. %number translations were added, %update translations were updated and %delete translations were removed.', ['%number' => 0, '%update' => 0, '%delete' => 1]), 'The translation file was successfully imported.');
+
+ $str = "Operations";
+ $search = [
+ 'string' => $str,
+ 'langcode' => $langcode,
+ 'translation' => 'untranslated',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertText($str, 'Search found the string as untranslated.');
+ }
+
+ /**
+ * Tests .po file import with configuration translation.
+ */
+ public function testConfigPoFile() {
+ // Values for translations to assert. Config key, original string,
+ // translation and config property name.
+ $config_strings = [
+ 'system.maintenance' => [
+ '@site is currently under maintenance. We should be back shortly. Thank you for your patience.',
+ '@site karbantartás alatt áll. Rövidesen visszatérünk. Köszönjük a türelmet.',
+ 'message',
+ ],
+ 'user.role.anonymous' => [
+ 'Anonymous user',
+ 'Névtelen felhasználó',
+ 'label',
+ ],
+ ];
+
+ // Add custom language for testing.
+ $langcode = 'xx';
+ $edit = [
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'label' => $this->randomMachineName(16),
+ 'direction' => LanguageInterface::DIRECTION_LTR,
+ ];
+ $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Check for the source strings we are going to translate. Adding the
+ // custom language should have made the process to export configuration
+ // strings to interface translation executed.
+ $locale_storage = $this->container->get('locale.storage');
+ foreach ($config_strings as $config_string) {
+ $string = $locale_storage->findString(['source' => $config_string[0], 'context' => '', 'type' => 'configuration']);
+ $this->assertTrue($string, 'Configuration strings have been created upon installation.');
+ }
+
+ // Import a .po file to translate.
+ $this->importPoFile($this->getPoFileWithConfig(), [
+ 'langcode' => $langcode,
+ ]);
+
+ // Translations got recorded in the interface translation system.
+ foreach ($config_strings as $config_string) {
+ $search = [
+ 'string' => $config_string[0],
+ 'langcode' => $langcode,
+ 'translation' => 'all',
+ ];
+ $this->drupalPostForm('admin/config/regional/translate', $search, t('Filter'));
+ $this->assertText($config_string[1], format_string('Translation of @string found.', ['@string' => $config_string[0]]));
+ }
+
+ // Test that translations got recorded in the config system.
+ $overrides = \Drupal::service('language.config_factory_override');
+ foreach ($config_strings as $config_key => $config_string) {
+ $override = $overrides->getOverride($langcode, $config_key);
+ $this->assertEqual($override->get($config_string[2]), $config_string[1]);
+ }
+ }
+
+ /**
+ * Tests .po file import with user.settings configuration.
+ */
+ public function testConfigtranslationImportingPoFile() {
+ // Set the language code.
+ $langcode = 'de';
+
+ // Import a .po file to translate.
+ $this->importPoFile($this->getPoFileWithConfigDe(), [
+ 'langcode' => $langcode]);
+
+ // Check that the 'Anonymous' string is translated.
+ $config = \Drupal::languageManager()->getLanguageConfigOverride($langcode, 'user.settings');
+ $this->assertEqual($config->get('anonymous'), 'Anonymous German');
+ }
+
+ /**
+ * Test the translation are imported when a new language is created.
+ */
+ public function testCreatedLanguageTranslation() {
+ // Import a .po file to add de language.
+ $this->importPoFile($this->getPoFileWithConfigDe(), ['langcode' => 'de']);
+
+ // Get the language.entity.de label and check it's been translated.
+ $override = \Drupal::languageManager()->getLanguageConfigOverride('de', 'language.entity.de');
+ $this->assertEqual($override->get('label'), 'Deutsch');
+ }
+
+ /**
+ * Helper function: import a standalone .po file in a given language.
+ *
+ * @param string $contents
+ * Contents of the .po file to import.
+ * @param array $options
+ * (optional) Additional options to pass to the translation import form.
+ */
+ public function importPoFile($contents, array $options = []) {
+ $name = \Drupal::service('file_system')->tempnam('temporary://', "po_") . '.po';
+ file_put_contents($name, $contents);
+ $options['files[file]'] = $name;
+ $this->drupalPostForm('admin/config/regional/translate/import', $options, t('Import'));
+ drupal_unlink($name);
+ }
+
+ /**
+ * Helper function that returns a proper .po file.
+ */
+ public function getPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "One sheep"
+msgid_plural "@count sheep"
+msgstr[0] "un mouton"
+msgstr[1] "@count moutons"
+
+msgid "Monday"
+msgstr "lundi"
+
+msgid "Tuesday"
+msgstr "mardi"
+
+msgid "Wednesday"
+msgstr "mercredi"
+
+msgid "Thursday"
+msgstr "jeudi"
+
+msgid "Friday"
+msgstr "vendredi"
+
+msgid "Saturday"
+msgstr "samedi"
+
+msgid "Sunday"
+msgstr "dimanche"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a empty .po file.
+ */
+ public function getEmptyPoFile() {
+ return '';
+ }
+
+ /**
+ * Helper function that returns a bad .po file.
+ */
+ public function getBadPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Save configuration"
+msgstr "Enregistrer la configuration"
+
+msgid "edit"
+msgstr "modifier<img SRC="javascript:alert(\'xss\');">"
+
+msgid "delete"
+msgstr "supprimer<script>alert('xss');</script>"
+
+EOF;
+ }
+
+ /**
+ * Helper function that returns a proper .po file for testing.
+ */
+ public function getOverwritePoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n"
+
+msgid "Monday"
+msgstr "Montag"
+
+msgid "Day"
+msgstr "Jour"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file which strings will be marked
+ * as customized.
+ */
+ public function getCustomPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "One dog"
+msgid_plural "@count dogs"
+msgstr[0] "un chien"
+msgstr[1] "@count chiens"
+
+msgid "January"
+msgstr "janvier"
+
+msgid "February"
+msgstr "février"
+
+msgid "March"
+msgstr "mars"
+
+msgid "April"
+msgstr "avril"
+
+msgid "June"
+msgstr "juin"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file for testing customized strings.
+ */
+ public function getCustomOverwritePoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "January"
+msgstr "januari"
+
+msgid "February"
+msgstr "februari"
+
+msgid "July"
+msgstr "juillet"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with context.
+ */
+ public function getPoFileWithContext() {
+ // Croatian (code hr) is one of the languages that have a different
+ // form for the full name and the abbreviated name for the month of May.
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n"
+
+msgctxt "Long month name"
+msgid "May"
+msgstr "Svibanj"
+
+msgid "May"
+msgstr "Svi."
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with an empty last item.
+ */
+ public function getPoFileWithEmptyMsgstr() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Operations"
+msgstr ""
+
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with an empty last item.
+ */
+ public function getPoFileWithMsgstr() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Operations"
+msgstr "Műveletek"
+
+msgid "Will not appear in Drupal core, so we can ensure the test passes"
+msgstr ""
+
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with configuration translations.
+ */
+ public function getPoFileWithConfig() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "@site is currently under maintenance. We should be back shortly. Thank you for your patience."
+msgstr "@site karbantartás alatt áll. Rövidesen visszatérünk. Köszönjük a türelmet."
+
+msgid "Anonymous user"
+msgstr "Névtelen felhasználó"
+
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with configuration translations.
+ */
+ public function getPoFileWithConfigDe() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 8\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Anonymous"
+msgstr "Anonymous German"
+
+msgid "German"
+msgstr "Deutsch"
+
+EOF;
+ }
+
+}