Replaced gettext translation with package

This allows to check if no translation is available
This commit is contained in:
Igor Scheller 2019-07-08 01:47:01 +02:00
parent f90ab26fee
commit 508695efb2
20 changed files with 346 additions and 99 deletions

View File

@ -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

View File

@ -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.*",

View File

@ -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' => [

View File

@ -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

View File

@ -0,0 +1,34 @@
<?php
namespace Engelsystem\Migrations;
use Engelsystem\Database\Migration\Migration;
class FixUserLanguages extends Migration
{
/**
* Run the migration
*/
public function up()
{
$connection = $this->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")')
]);
}
}

View File

@ -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"

View File

@ -0,0 +1,53 @@
<?php
namespace Engelsystem\Helpers\Translation;
use Gettext\Translator;
class GettextTranslator extends Translator
{
/**
* @param string $domain
* @param string $context
* @param string $original
* @return string
* @throws TranslationNotFound
*/
public function dpgettext($domain, $context, $original)
{
$this->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]));
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace Engelsystem\Helpers\Translation;
use Exception;
class TranslationNotFound extends Exception
{
}

View File

@ -4,11 +4,15 @@ namespace Engelsystem\Helpers\Translation;
use Engelsystem\Config\Config;
use Engelsystem\Container\ServiceProvider;
use Gettext\Translations;
use Symfony\Component\HttpFoundation\Session\Session;
class TranslationServiceProvider extends ServiceProvider
{
public function register()
/** @var GettextTranslator */
protected $translators = [];
public function register(): void
{
/** @var Config $config */
$config = $this->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];
}
}

View File

@ -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
*/

Binary file not shown.

View File

@ -0,0 +1,3 @@
# Testing content
msgid "foo.bar"
msgstr "Foo Bar!"

View File

@ -0,0 +1,67 @@
<?php
namespace Engelsystem\Test\Unit\Helpers\Translation;
use Engelsystem\Helpers\Translation\GettextTranslator;
use Engelsystem\Helpers\Translation\TranslationNotFound;
use Engelsystem\Test\Unit\ServiceProviderTest;
use Gettext\Translation;
use Gettext\Translations;
class GettextTranslatorTest extends ServiceProviderTest
{
/**
* @covers \Engelsystem\Helpers\Translation\GettextTranslator::assertHasTranslation()
*/
public function testNoTranslation()
{
$translations = $this->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;
}
}

View File

@ -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)
@ -67,6 +53,8 @@ class TranslationServiceProviderTest extends ServiceProviderTest
[
'locale' => $locale,
'locales' => $locales,
'fallbackLocale' => 'en_US',
'getTranslatorCallback' => [$serviceProvider, 'getTranslator'],
'localeChangeCallback' => [$serviceProvider, 'setLocale'],
]
)
@ -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');
}
}

View File

@ -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']));
}
}