diff --git a/README.md b/README.md index db62c6e5..2e06be9f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ To report bugs use [engelsystem/issues](https://github.com/engelsystem/engelsyst * PHP >= 7.1 * Required modules: * dom - * gettext * json * mbstring * PDO diff --git a/composer.json b/composer.json index 3e50226a..b2b70789 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ ], "require": { "php": ">=7.1.0", - "ext-gettext": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", @@ -24,6 +23,7 @@ "ext-xml": "*", "doctrine/dbal": "^2.9", "erusev/parsedown": "^1.7", + "gettext/gettext": "^4.6", "illuminate/container": "5.8.*", "illuminate/database": "5.8.*", "illuminate/support": "5.8.*", diff --git a/config/config.default.php b/config/config.default.php index 9c9505c6..3fad18bc 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -134,12 +134,12 @@ return [ // Available locales in /locale/ 'locales' => [ - 'de_DE.UTF-8' => 'Deutsch', - 'en_US.UTF-8' => 'English', + 'de_DE' => 'Deutsch', + 'en_US' => 'English', ], // The default locale to use - 'default_locale' => env('DEFAULT_LOCALE', 'en_US.UTF-8'), + 'default_locale' => env('DEFAULT_LOCALE', 'en_US'), // Available T-Shirt sizes, set value to null if not available 'tshirt_sizes' => [ diff --git a/contrib/Dockerfile b/contrib/Dockerfile index dd3bd308..b6e2cb95 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -35,8 +35,8 @@ RUN rm -f /app/import/* /app/config/config.php # Build the PHP container FROM php:7-fpm-alpine WORKDIR /var/www -RUN apk add --no-cache icu-dev gettext-dev && \ - docker-php-ext-install intl gettext pdo_mysql +RUN apk add --no-cache icu-dev && \ + docker-php-ext-install intl pdo_mysql COPY --from=data /app/ /var/www RUN chown -R www-data:www-data /var/www/import/ /var/www/storage/ && \ rm -r /var/www/html diff --git a/db/migrations/2019_06_12_000000_fix_user_languages.php b/db/migrations/2019_06_12_000000_fix_user_languages.php new file mode 100644 index 00000000..c7d1474c --- /dev/null +++ b/db/migrations/2019_06_12_000000_fix_user_languages.php @@ -0,0 +1,34 @@ +schema->getConnection(); + $connection + ->table('users_settings') + ->update([ + 'language' => $connection->raw('REPLACE(language, ".UTF-8", "")') + ]); + } + + /** + * Reverse the migration + */ + public function down() + { + $connection = $this->schema->getConnection(); + $connection + ->table('users_settings') + ->update([ + 'language' => $connection->raw('CONCAT(language, ".UTF-8")') + ]); + } +} diff --git a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.mo b/resources/lang/de_DE/default.mo similarity index 100% rename from resources/lang/de_DE.UTF-8/LC_MESSAGES/default.mo rename to resources/lang/de_DE/default.mo diff --git a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po b/resources/lang/de_DE/default.po similarity index 99% rename from resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po rename to resources/lang/de_DE/default.po index 27ceb586..cd696610 100644 --- a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po +++ b/resources/lang/de_DE/default.po @@ -10,7 +10,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 1.8.11\n" -"X-Poedit-KeywordsList: __;_e;translate;translatePlural;gettext;gettext_noop\n" +"X-Poedit-KeywordsList: __;_e;translate;translatePlural\n" "X-Poedit-Basepath: ../../../..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo b/resources/lang/en_US/default.mo similarity index 100% rename from resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo rename to resources/lang/en_US/default.mo diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.po b/resources/lang/en_US/default.po similarity index 100% rename from resources/lang/en_US.UTF-8/LC_MESSAGES/default.po rename to resources/lang/en_US/default.po diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo b/resources/lang/pt_BR/default.mo similarity index 100% rename from resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo rename to resources/lang/pt_BR/default.mo diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po b/resources/lang/pt_BR/default.po similarity index 100% rename from resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po rename to resources/lang/pt_BR/default.po diff --git a/src/Helpers/Translation/GettextTranslator.php b/src/Helpers/Translation/GettextTranslator.php new file mode 100644 index 00000000..7f2299e2 --- /dev/null +++ b/src/Helpers/Translation/GettextTranslator.php @@ -0,0 +1,53 @@ +assertHasTranslation($domain, $context, $original); + + return parent::dpgettext($domain, $context, $original); + } + + /** + * @param string $domain + * @param string $context + * @param string $original + * @param string $plural + * @param string $value + * @return string + * @throws TranslationNotFound + */ + public function dnpgettext($domain, $context, $original, $plural, $value) + { + $this->assertHasTranslation($domain, $context, $original); + + return parent::dnpgettext($domain, $context, $original, $plural, $value); + } + + /** + * @param string $domain + * @param string $context + * @param string $original + * @throws TranslationNotFound + */ + protected function assertHasTranslation($domain, $context, $original) + { + if ($this->getTranslation($domain, $context, $original)) { + return; + } + + throw new TranslationNotFound(implode('/', [$domain, $context, $original])); + } +} diff --git a/src/Helpers/Translation/TranslationNotFound.php b/src/Helpers/Translation/TranslationNotFound.php new file mode 100644 index 00000000..1552838b --- /dev/null +++ b/src/Helpers/Translation/TranslationNotFound.php @@ -0,0 +1,9 @@ +app->get('config'); @@ -17,41 +21,36 @@ class TranslationServiceProvider extends ServiceProvider $locales = $config->get('locales'); $locale = $config->get('default_locale'); + $fallbackLocale = $config->get('fallback_locale', 'en_US'); $sessionLocale = $session->get('locale', $locale); if (isset($locales[$sessionLocale])) { $locale = $sessionLocale; } - $this->initGettext(); $session->set('locale', $locale); $translator = $this->app->make( Translator::class, - ['locale' => $locale, 'locales' => $locales, 'localeChangeCallback' => [$this, 'setLocale']] + [ + 'locale' => $locale, + 'locales' => $locales, + 'fallbackLocale' => $fallbackLocale, + 'getTranslatorCallback' => [$this, 'getTranslator'], + 'localeChangeCallback' => [$this, 'setLocale'], + ] ); $this->app->instance(Translator::class, $translator); $this->app->instance('translator', $translator); } - /** - * @param string $textDomain - * @param string $encoding - * @codeCoverageIgnore - */ - protected function initGettext($textDomain = 'default', $encoding = 'UTF-8') - { - bindtextdomain($textDomain, $this->app->get('path.lang')); - bind_textdomain_codeset($textDomain, $encoding); - textdomain($textDomain); - } - /** * @param string $locale * @codeCoverageIgnore */ - public function setLocale($locale) + public function setLocale(string $locale): void { + $locale .= '.UTF-8'; // Set the users locale putenv('LC_ALL=' . $locale); setlocale(LC_ALL, $locale); @@ -60,4 +59,28 @@ class TranslationServiceProvider extends ServiceProvider putenv('LC_NUMERIC=C'); setlocale(LC_NUMERIC, 'C'); } + + /** + * @param string $locale + * @return GettextTranslator + */ + public function getTranslator(string $locale): GettextTranslator + { + if (!isset($this->translators[$locale])) { + $file = $this->app->get('path.lang') . '/' . $locale . '/default.mo'; + + /** @var GettextTranslator $translator */ + $translator = $this->app->make(GettextTranslator::class); + + /** @var Translations $translations */ + $translations = $this->app->make(Translations::class); + $translations->addFromMoFile($file); + + $translator->loadTranslations($translations); + + $this->translators[$locale] = $translator; + } + + return $this->translators[$locale]; + } } diff --git a/src/Helpers/Translation/Translator.php b/src/Helpers/Translation/Translator.php index 545963eb..8b11ecb4 100644 --- a/src/Helpers/Translation/Translator.php +++ b/src/Helpers/Translation/Translator.php @@ -10,6 +10,12 @@ class Translator /** @var string */ protected $locale; + /** @var string */ + protected $fallbackLocale; + + /** @var callable */ + protected $getTranslatorCallback; + /** @var callable */ protected $localeChangeCallback; @@ -17,15 +23,24 @@ class Translator * Translator constructor. * * @param string $locale + * @param string $fallbackLocale + * @param callable $getTranslatorCallback * @param string[] $locales * @param callable $localeChangeCallback */ - public function __construct(string $locale, array $locales = [], callable $localeChangeCallback = null) - { + public function __construct( + string $locale, + string $fallbackLocale, + callable $getTranslatorCallback, + array $locales = [], + callable $localeChangeCallback = null + ) { $this->localeChangeCallback = $localeChangeCallback; + $this->getTranslatorCallback = $getTranslatorCallback; $this->setLocale($locale); - $this->setLocales($locales); + $this->fallbackLocale = $fallbackLocale; + $this->locales = $locales; } /** @@ -37,9 +52,7 @@ class Translator */ public function translate(string $key, array $replace = []): string { - $translated = $this->translateGettext($key); - - return $this->replaceText($translated, $replace); + return $this->translateText('gettext', [$key], $replace); } /** @@ -53,7 +66,29 @@ class Translator */ public function translatePlural(string $key, string $pluralKey, int $number, array $replace = []): string { - $translated = $this->translateGettextPlural($key, $pluralKey, $number); + return $this->translateText('ngettext', [$key, $pluralKey, $number], $replace); + } + + /** + * @param string $type + * @param array $parameters + * @param array $replace + * @return mixed|string + */ + protected function translateText(string $type, array $parameters, array $replace = []) + { + $translated = $parameters[0]; + + foreach ([$this->locale, $this->fallbackLocale] as $lang) { + /** @var GettextTranslator $translator */ + $translator = call_user_func($this->getTranslatorCallback, $lang); + + try { + $translated = call_user_func_array([$translator, $type], $parameters); + break; + } catch (TranslationNotFound $e) { + } + } return $this->replaceText($translated, $replace); } @@ -74,32 +109,6 @@ class Translator return call_user_func_array('sprintf', array_merge([$key], $replace)); } - /** - * Translate the key via gettext - * - * @param string $key - * @return string - * @codeCoverageIgnore - */ - protected function translateGettext(string $key): string - { - return gettext($key); - } - - /** - * Translate the key via gettext - * - * @param string $key - * @param string $keyPlural - * @param int $number - * @return string - * @codeCoverageIgnore - */ - protected function translateGettextPlural(string $key, string $keyPlural, int $number): string - { - return ngettext($key, $keyPlural, $number); - } - /** * @return string */ diff --git a/tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo new file mode 100644 index 00000000..96f1f3ca Binary files /dev/null and b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo differ diff --git a/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po new file mode 100644 index 00000000..015bc36d --- /dev/null +++ b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po @@ -0,0 +1,3 @@ +# Testing content +msgid "foo.bar" +msgstr "Foo Bar!" diff --git a/tests/Unit/Helpers/Translation/GettextTranslatorTest.php b/tests/Unit/Helpers/Translation/GettextTranslatorTest.php new file mode 100644 index 00000000..825cf5b7 --- /dev/null +++ b/tests/Unit/Helpers/Translation/GettextTranslatorTest.php @@ -0,0 +1,67 @@ +getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translation!', $translator->gettext('test.value')); + + $this->expectException(TranslationNotFound::class); + $this->expectExceptionMessage('//foo.bar'); + + $translator->gettext('foo.bar'); + } + + /** + * @covers \Engelsystem\Helpers\Translation\GettextTranslator::dpgettext() + */ + public function testDpgettext() + { + $translations = $this->getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translation!', $translator->dpgettext(null, null, 'test.value')); + } + + /** + * @covers \Engelsystem\Helpers\Translation\GettextTranslator::dnpgettext() + */ + public function testDnpgettext() + { + $translations = $this->getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translations!', $translator->dnpgettext(null, null, 'test.value', 'test.values', 2)); + } + + protected function getTranslations(): Translations + { + $translations = new Translations(); + $translations[] = + (new Translation(null, 'test.value', 'test.values')) + ->setTranslation('Translation!') + ->setPluralTranslations(['Translations!']); + + return $translations; + } +} diff --git a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php index 171b5967..91307bdd 100644 --- a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php +++ b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php @@ -14,11 +14,14 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register() */ - public function testRegister() + public function testRegister(): void { + $defaultLocale = 'fo_OO'; + $locale = 'te_ST.WTF-9'; + $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; + $config = new Config(['locales' => $locales, 'default_locale' => $defaultLocale]); + $app = $this->getApp(['make', 'instance', 'get']); - /** @var Config|MockObject $config */ - $config = $this->createMock(Config::class); /** @var Session|MockObject $session */ $session = $this->createMock(Session::class); /** @var Translator|MockObject $translator */ @@ -27,31 +30,14 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** @var TranslationServiceProvider|MockObject $serviceProvider */ $serviceProvider = $this->getMockBuilder(TranslationServiceProvider::class) ->setConstructorArgs([$app]) - ->setMethods(['initGettext', 'setLocale']) + ->setMethods(['setLocale']) ->getMock(); - $serviceProvider->expects($this->once()) - ->method('initGettext'); - $app->expects($this->exactly(2)) ->method('get') ->withConsecutive(['config'], ['session']) ->willReturnOnConsecutiveCalls($config, $session); - $defaultLocale = 'fo_OO'; - $locale = 'te_ST.WTF-9'; - $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; - $config->expects($this->exactly(2)) - ->method('get') - ->withConsecutive( - ['locales'], - ['default_locale'] - ) - ->willReturnOnConsecutiveCalls( - $locales, - $defaultLocale - ); - $session->expects($this->once()) ->method('get') ->with('locale', $defaultLocale) @@ -65,9 +51,11 @@ class TranslationServiceProviderTest extends ServiceProviderTest ->with( Translator::class, [ - 'locale' => $locale, - 'locales' => $locales, - 'localeChangeCallback' => [$serviceProvider, 'setLocale'], + 'locale' => $locale, + 'locales' => $locales, + 'fallbackLocale' => 'en_US', + 'getTranslatorCallback' => [$serviceProvider, 'getTranslator'], + 'localeChangeCallback' => [$serviceProvider, 'setLocale'], ] ) ->willReturn($translator); @@ -81,4 +69,22 @@ class TranslationServiceProviderTest extends ServiceProviderTest $serviceProvider->register(); } + + /** + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator() + */ + public function testGetTranslator(): void + { + $app = $this->getApp(['get']); + $serviceProvider = new TranslationServiceProvider($app); + + $this->setExpects($app, 'get', ['path.lang'], __DIR__ . '/Assets'); + + // Get translator + $translator = $serviceProvider->getTranslator('fo_OO'); + $this->assertEquals('Foo Bar!', $translator->gettext('foo.bar')); + + // Retry from cache + $serviceProvider->getTranslator('fo_OO'); + } } diff --git a/tests/Unit/Helpers/Translation/TranslatorTest.php b/tests/Unit/Helpers/Translation/TranslatorTest.php index 7e9c534c..c173209a 100644 --- a/tests/Unit/Helpers/Translation/TranslatorTest.php +++ b/tests/Unit/Helpers/Translation/TranslatorTest.php @@ -2,6 +2,8 @@ namespace Engelsystem\Test\Unit\Helpers\Translation; +use Engelsystem\Helpers\Translation\GettextTranslator; +use Engelsystem\Helpers\Translation\TranslationNotFound; use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Test\Unit\ServiceProviderTest; use PHPUnit\Framework\MockObject\MockObject; @@ -19,18 +21,18 @@ class TranslatorTest extends ServiceProviderTest */ public function testInit() { - $locales = ['te_ST.ER-01' => 'Tests', 'fo_OO' => 'SomeFOO']; - $locale = 'te_ST.ER-01'; + $locales = ['te_ST' => 'Tests', 'fo_OO' => 'SomeFOO']; + $locale = 'te_ST'; - /** @var callable|MockObject $callable */ - $callable = $this->getMockBuilder(stdClass::class) + /** @var callable|MockObject $localeChange */ + $localeChange = $this->getMockBuilder(stdClass::class) ->setMethods(['__invoke']) ->getMock(); - $callable->expects($this->exactly(2)) + $localeChange->expects($this->exactly(2)) ->method('__invoke') - ->withConsecutive(['te_ST.ER-01'], ['fo_OO']); + ->withConsecutive(['te_ST'], ['fo_OO']); - $translator = new Translator($locale, $locales, $callable); + $translator = new Translator($locale, 'fo_OO', function () { }, $locales, $localeChange); $this->assertEquals($locales, $translator->getLocales()); $this->assertEquals($locale, $translator->getLocale()); @@ -43,24 +45,23 @@ class TranslatorTest extends ServiceProviderTest $this->assertEquals($newLocales, $translator->getLocales()); $this->assertTrue($translator->hasLocale('ip_SU-M')); - $this->assertFalse($translator->hasLocale('te_ST.ER-01')); + $this->assertFalse($translator->hasLocale('te_ST')); } /** - * @covers \Engelsystem\Helpers\Translation\Translator::replaceText * @covers \Engelsystem\Helpers\Translation\Translator::translate */ public function testTranslate() { /** @var Translator|MockObject $translator */ $translator = $this->getMockBuilder(Translator::class) - ->setConstructorArgs(['de_DE.UTF-8', ['de_DE.UTF-8' => 'Deutsch']]) - ->setMethods(['translateGettext']) + ->setConstructorArgs(['de_DE', 'en_US', function () { }, ['de_DE' => 'Deutsch']]) + ->setMethods(['translateText']) ->getMock(); $translator->expects($this->exactly(2)) - ->method('translateGettext') - ->withConsecutive(['Hello!'], ['My favourite number is %u!']) - ->willReturnOnConsecutiveCalls('Hallo!', 'Meine Lieblingszahl ist die %u!'); + ->method('translateText') + ->withConsecutive(['gettext', ['Hello!'], []], ['gettext', ['My favourite number is %u!'], [3]]) + ->willReturnOnConsecutiveCalls('Hallo!', 'Meine Lieblingszahl ist die 3!'); $return = $translator->translate('Hello!'); $this->assertEquals('Hallo!', $return); @@ -76,15 +77,58 @@ class TranslatorTest extends ServiceProviderTest { /** @var Translator|MockObject $translator */ $translator = $this->getMockBuilder(Translator::class) - ->setConstructorArgs(['de_DE.UTF-8', ['de_DE.UTF-8' => 'Deutsch']]) - ->setMethods(['translateGettextPlural']) + ->setConstructorArgs(['de_DE', 'en_US', function () { }, ['de_DE' => 'Deutsch']]) + ->setMethods(['translateText']) ->getMock(); $translator->expects($this->once()) - ->method('translateGettextPlural') - ->with('%s apple', '%s apples', 2) + ->method('translateText') + ->with('ngettext', ['%s apple', '%s apples', 2], [2]) ->willReturn('2 Äpfel'); $return = $translator->translatePlural('%s apple', '%s apples', 2, [2]); $this->assertEquals('2 Äpfel', $return); } + + /** + * @covers \Engelsystem\Helpers\Translation\Translator::translatePlural + * @covers \Engelsystem\Helpers\Translation\Translator::translateText + * @covers \Engelsystem\Helpers\Translation\Translator::replaceText + */ + public function testReplaceText() + { + /** @var GettextTranslator|MockObject $gtt */ + $gtt = $this->createMock(GettextTranslator::class); + /** @var callable|MockObject $getTranslator */ + $getTranslator = $this->getMockBuilder(stdClass::class) + ->setMethods(['__invoke']) + ->getMock(); + $getTranslator->expects($this->exactly(5)) + ->method('__invoke') + ->withConsecutive(['te_ST'], ['fo_OO'], ['te_ST'], ['fo_OO'], ['te_ST']) + ->willReturn($gtt); + + $i = 0; + $gtt->expects($this->exactly(4)) + ->method('gettext') + ->willReturnCallback(function () use (&$i) { + $i++; + if ($i != 4) { + throw new TranslationNotFound(); + } + + return 'Lorem %s???'; + }); + $this->setExpects($gtt, 'ngettext', ['foo.barf'], 'Lorem %s!'); + + $translator = new Translator('te_ST', 'fo_OO', $getTranslator, ['te_ST' => 'Test', 'fo_OO' => 'Foo']); + + // No translation + $this->assertEquals('foo.bar', $translator->translate('foo.bar')); + + // Fallback translation + $this->assertEquals('Lorem test2???', $translator->translate('foo.batz', ['test2'])); + + // Successful translation + $this->assertEquals('Lorem test3!', $translator->translatePlural('foo.barf', 'foo.bar2', 3, ['test3'])); + } }