From 8e95bcfd039f3fd9cc65def1c629645c854b22ee Mon Sep 17 00:00:00 2001 From: kislota Date: Tue, 17 Aug 2021 03:41:59 +0300 Subject: [PATCH] Upload --- .gitignore | 7 + .travis.yml | 12 + LICENSE.md | 21 + composer.json | 43 ++ config/.gitkeep | 0 config/translator.php | 51 ++ database/migrations/.gitkeep | 0 ...13_07_25_145943_create_languages_table.php | 34 ++ ...07_25_145958_create_translations_table.php | 39 ++ ...16_06_02_124154_increase_locale_length.php | 33 ++ phpunit.xml | 20 + readme.md | 418 +++++++++++++++ src/Cache/CacheRepositoryInterface.php | 58 +++ src/Cache/RepositoryFactory.php | 15 + src/Cache/SimpleRepository.php | 108 ++++ src/Cache/TaggedRepository.php | 115 +++++ src/Commands/CacheFlushCommand.php | 59 +++ src/Commands/FileLoaderCommand.php | 136 +++++ src/Facades/TranslationCache.php | 25 + src/Facades/UriLocalizer.php | 24 + src/Loaders/CacheLoader.php | 102 ++++ src/Loaders/DatabaseLoader.php | 82 +++ src/Loaders/FileLoader.php | 80 +++ src/Loaders/Loader.php | 81 +++ src/Loaders/MixedLoader.php | 87 ++++ src/Middleware/TranslationMiddleware.php | 98 ++++ src/Models/Language.php | 50 ++ src/Models/Translation.php | 69 +++ src/Repositories/LanguageRepository.php | 204 ++++++++ src/Repositories/Repository.php | 112 ++++ src/Repositories/TranslationRepository.php | 479 ++++++++++++++++++ src/Routes/ResourceRegistrar.php | 55 ++ src/Traits/Translatable.php | 157 ++++++ src/Traits/TranslatableObserver.php | 40 ++ src/TranslationServiceProvider.php | 149 ++++++ src/UriLocalizer.php | 144 ++++++ tests/.gitkeep | 0 tests/Cache/RepositoryFactoryTest.php | 37 ++ tests/Cache/SimpleRepositoryTest.php | 61 +++ tests/Cache/TaggedRepositoryTest.php | 73 +++ tests/Cache/TranslationCacheTest.php | 70 +++ tests/Commands/FlushTest.php | 45 ++ tests/Commands/LoadTest.php | 151 ++++++ tests/Loaders/CacheLoaderTest.php | 45 ++ tests/Loaders/DatabaseLoaderTest.php | 61 +++ tests/Loaders/FileLoaderTest.php | 38 ++ tests/Loaders/LoadTest.php | 61 +++ tests/Loaders/MixedLoaderTest.php | 57 +++ tests/Localizer/CleanUrlTest.php | 72 +++ tests/Localizer/GetLocaleFromUrlTest.php | 41 ++ tests/Localizer/LocalizeUriTest.php | 73 +++ .../Middleware/TranslationMiddlewareTest.php | 141 ++++++ tests/Repositories/LanguageRepositoryTest.php | 184 +++++++ .../TranslationRepositoryTest.php | 467 +++++++++++++++++ tests/Routes/ResourceRouteTest.php | 96 ++++ tests/TestCase.php | 106 ++++ tests/Traits/TranslatableTest.php | 109 ++++ tests/lang/ca/test.php | 5 + tests/lang/en/auth.php | 9 + tests/lang/en/empty.php | 6 + tests/lang/en/welcome/page.php | 5 + tests/lang/es/auth.php | 8 + tests/lang/es/welcome/page.php | 5 + tests/lang/vendor/package/en/example.php | 5 + tests/lang/vendor/package/es/example.php | 5 + 65 files changed, 5243 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 config/.gitkeep create mode 100644 config/translator.php create mode 100644 database/migrations/.gitkeep create mode 100644 database/migrations/2013_07_25_145943_create_languages_table.php create mode 100644 database/migrations/2013_07_25_145958_create_translations_table.php create mode 100644 database/migrations/2016_06_02_124154_increase_locale_length.php create mode 100644 phpunit.xml create mode 100644 readme.md create mode 100644 src/Cache/CacheRepositoryInterface.php create mode 100644 src/Cache/RepositoryFactory.php create mode 100644 src/Cache/SimpleRepository.php create mode 100644 src/Cache/TaggedRepository.php create mode 100644 src/Commands/CacheFlushCommand.php create mode 100644 src/Commands/FileLoaderCommand.php create mode 100644 src/Facades/TranslationCache.php create mode 100644 src/Facades/UriLocalizer.php create mode 100644 src/Loaders/CacheLoader.php create mode 100644 src/Loaders/DatabaseLoader.php create mode 100644 src/Loaders/FileLoader.php create mode 100644 src/Loaders/Loader.php create mode 100644 src/Loaders/MixedLoader.php create mode 100644 src/Middleware/TranslationMiddleware.php create mode 100644 src/Models/Language.php create mode 100644 src/Models/Translation.php create mode 100644 src/Repositories/LanguageRepository.php create mode 100644 src/Repositories/Repository.php create mode 100644 src/Repositories/TranslationRepository.php create mode 100644 src/Routes/ResourceRegistrar.php create mode 100644 src/Traits/Translatable.php create mode 100644 src/Traits/TranslatableObserver.php create mode 100644 src/TranslationServiceProvider.php create mode 100644 src/UriLocalizer.php create mode 100644 tests/.gitkeep create mode 100644 tests/Cache/RepositoryFactoryTest.php create mode 100644 tests/Cache/SimpleRepositoryTest.php create mode 100644 tests/Cache/TaggedRepositoryTest.php create mode 100644 tests/Cache/TranslationCacheTest.php create mode 100644 tests/Commands/FlushTest.php create mode 100644 tests/Commands/LoadTest.php create mode 100644 tests/Loaders/CacheLoaderTest.php create mode 100644 tests/Loaders/DatabaseLoaderTest.php create mode 100644 tests/Loaders/FileLoaderTest.php create mode 100644 tests/Loaders/LoadTest.php create mode 100644 tests/Loaders/MixedLoaderTest.php create mode 100644 tests/Localizer/CleanUrlTest.php create mode 100644 tests/Localizer/GetLocaleFromUrlTest.php create mode 100644 tests/Localizer/LocalizeUriTest.php create mode 100644 tests/Middleware/TranslationMiddlewareTest.php create mode 100644 tests/Repositories/LanguageRepositoryTest.php create mode 100644 tests/Repositories/TranslationRepositoryTest.php create mode 100644 tests/Routes/ResourceRouteTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Traits/TranslatableTest.php create mode 100644 tests/lang/ca/test.php create mode 100644 tests/lang/en/auth.php create mode 100644 tests/lang/en/empty.php create mode 100644 tests/lang/en/welcome/page.php create mode 100644 tests/lang/es/auth.php create mode 100644 tests/lang/es/welcome/page.php create mode 100644 tests/lang/vendor/package/en/example.php create mode 100644 tests/lang/vendor/package/es/example.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b164a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/tests/temp +composer.phar +composer.lock +.DS_Store +tests/temp/database.sqlite +.idea diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9c8fb22 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: php + +php: + - 7.2 + - 7.3 + +before_script: + - travis_retry composer self-update + - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source + +script: + - phpunit diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..105b3e5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 WAAVI STUDIO SL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8cbfdcb --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "waavi/translation", + "description": "A Translation package for Laravel 5 with database and cache support", + "keywords": [ + "waavi", + "laravel-translator", + "laravel", + "translator", + "translation", + "localization" + ], + "license": "MIT", + "authors": [ + { + "name": "Waavi", + "email": "info@waavi.com", + "homepage": "http://waavi.com" + } + ], + "require": { + "laravel/framework": "^6.0|^7.0|^8.0", + "doctrine/dbal": "^2.5" + }, + "require-dev": { + "phpunit/phpunit" : "^9.1", + "orchestra/testbench": "~6.0", + "mockery/mockery": "^1.3.0" + }, + "autoload": { + "psr-4": { + "Waavi\\Translation\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Waavi\\Translation\\Test\\": "tests" + } + }, + "minimum-stability": "dev", + "scripts": { + "test": "vendor/bin/phpunit" + } +} diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/translator.php b/config/translator.php new file mode 100644 index 0000000..394da40 --- /dev/null +++ b/config/translator.php @@ -0,0 +1,51 @@ + env('TRANSLATION_SOURCE', 'files'), + + /* + |-------------------------------------------------------------------------- + | Default Translation Connection + |-------------------------------------------------------------------------- + | + | This option controls the translation's connection. By default is use Laravel default connection. In most cases + | you don't need to change it. + */ + 'connection' => config('database.default', env('TRANSLATOR_CONNECTION', 'mysql')), + + // In case the files source is selected, please enter here the supported locales for your app. + // Ex: ['en', 'es', 'fr'] + 'available_locales' => [], + + /* + |-------------------------------------------------------------------------- + | Default Translation Cache + |-------------------------------------------------------------------------- + | + | Choose whether to leverage Laravel's cache module and how to do so. + | + | 'enabled' Boolean value. + | 'timeout' In minutes. + | + */ + 'cache' => [ + 'enabled' => env('TRANSLATION_CACHE_ENABLED', true), + 'timeout' => env('TRANSLATION_CACHE_TIMEOUT', 60), + 'suffix' => env('TRANSLATION_CACHE_SUFFIX', 'translation'), + ], +]; diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/database/migrations/2013_07_25_145943_create_languages_table.php b/database/migrations/2013_07_25_145943_create_languages_table.php new file mode 100644 index 0000000..db53c55 --- /dev/null +++ b/database/migrations/2013_07_25_145943_create_languages_table.php @@ -0,0 +1,34 @@ +create('translator_languages', function ($table) { + $table->increments('id'); + $table->string('locale', 6)->unique(); + $table->string('name', 60)->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('translator_languages'); + } + +} diff --git a/database/migrations/2013_07_25_145958_create_translations_table.php b/database/migrations/2013_07_25_145958_create_translations_table.php new file mode 100644 index 0000000..3d68c56 --- /dev/null +++ b/database/migrations/2013_07_25_145958_create_translations_table.php @@ -0,0 +1,39 @@ +create('translator_translations', function ($table) { + $table->increments('id'); + $table->string('locale', 6); + $table->string('namespace', 150)->default('*'); + $table->string('group', 150); + $table->string('item', 150); + $table->text('text'); + $table->boolean('unstable')->default(false); + $table->boolean('locked')->default(false); + $table->timestamps(); + $table->foreign('locale')->references('locale')->on('translator_languages'); + $table->unique(['locale', 'namespace', 'group', 'item']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('translator_translations'); + } +} diff --git a/database/migrations/2016_06_02_124154_increase_locale_length.php b/database/migrations/2016_06_02_124154_increase_locale_length.php new file mode 100644 index 0000000..aeecc57 --- /dev/null +++ b/database/migrations/2016_06_02_124154_increase_locale_length.php @@ -0,0 +1,33 @@ +table('translator_languages', function ($table) { + $table->string('locale', 10)->change(); + }); + Schema::connection(config('translator.connection'))->table('translator_translations', function ($table) { + $table->string('locale', 10)->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } + +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a54c0da --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + ./tests/ + /temp + /lang + + + \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4d71081 --- /dev/null +++ b/readme.md @@ -0,0 +1,418 @@ +# Better localization management for Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/waavi/translation.svg?style=flat-square)](https://packagist.org/packages/waavi/translation) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Build Status](https://img.shields.io/travis/Waavi/translation/master.svg?style=flat-square)](https://travis-ci.org/Waavi/translation) +[![Total Downloads](https://img.shields.io/packagist/dt/waavi/translation.svg?style=flat-square)](https://packagist.org/packages/waavi/translation) + +## Introduction + +Keeping a project's translations properly updated is cumbersome. Usually translators do not have access to the codebase, and even when they do it's hard to keep track of which translations are missing for each language or when updates to the original text require that translations be revised. + +This package allows developers to leverage their database and cache to manage multilanguage sites, while still working on language files during development and benefiting from all the features Laravel's Translation bundle has, like pluralization or replacement. + +WAAVI is a web development studio based in Madrid, Spain. You can learn more about us at [waavi.com](http://waavi.com) + +## Table of contents + +- [Laravel compatibility](#laravel-compatibility) +- [Features overview](#features-overview) +- [Installation](#installation) +- [Set source for translations](#translations-source) + - [Load translations from files](#load-translations-from-files) + - [Load translations from the database](#load-translations-from-the-database) + - [Mixed mode](#mixed-mode) + - [Loading your files into the database](#loading-your-files-into-the-database) +- [Cache translations](#cache-translations) +- [Managing languages and translations in the Database](#managing-languages-and-translations-in-the-database) + - [Managing Languages](#managing-languages) + - [Managing Translations](#managing-translations) +- [Model attributes translation](#model-attributes-translation) +- [Uri localization](#uri-localization) + +## Laravel compatibility + + Laravel | translation +:---------|:---------- + 4.x | 1.0.x + 5.0.x | 2.0.x + 5.1.x\|5.3.x | 2.1.x + 5.4.x | 2.2.x + 5.5.x | 2.3.x and higher + 5.6.x | 2.3.x and higher + 6.x\|7.x | 2.4.x and higher +## Features overview + + - Allow dynamic changes to the site's text and translations. + - Cache your localization entries. + - Load your translation files into the database. + - Force your urls to be localized (ex: /home -> /es/home) and set the locale automatically through the browser's config. + - Localize your model attributes. + +## Installation + +Require through composer + + + composer require waavi/translation 2.3.x + +Or manually edit your composer.json file: + + "require": { + "waavi/translation": "2.3.x" + } + +Once installed, in your project's config/app.php file replace the following entry from the providers array: + + Illuminate\Translation\TranslationServiceProvider::class + +with: + + Waavi\Translation\TranslationServiceProvider::class + +Remove your config cache: + + php artisan config:clear + +Publish both the configuration file and the migrations: + + php artisan vendor:publish --provider="Waavi\Translation\TranslationServiceProvider" + +Execute the database migrations: + + php artisan migrate + +You may check the package's configuration file at: + + config/translator.php + +## Translations source + +This package allows you to load translation from the regular Laravel localization files (in /resources/lang), from the database, from cache or in a mix of the previous for development. You may configure the desired mode of operation through the translator.php config file and/or the TRANSLATION_SOURCE environment variable. Accepted values are: + + - 'files' To load translations from Laravel's language files (default) + - 'database' To load translations from the database + - 'mixed' To load translations both from the filesystem and the database, with the filesystem having priority. + - 'mixed_db' To load translations both from the filesystem and the database, with the database having priority. [v2.1.5.3] + +NOTE: When adding the package to an existing Laravel project, 'files' must be used until migrations have been executed. + +For cache configuration, please go to [cache configuration](#cache-translations) + +### Load translations from files + +If you do not wish to leverage your database for translations, you may choose to load language lines exclusively through language files. This mode differs from Laravel in that, in case a line is not found in the specified locale, instead of returning the key right away, we first check the default language for an entry. In case you wish to use this mode exclusively, you will need to set the 'available_locales' config file: + + config/translator.php + 'available_locales' => ['en', 'es', 'fr'], + +Example: + +The content in en/validations.php, where 'en' is the default locale, is: +```php + [ + 'missing_name' => 'Name is missing', + 'missing_surname' => 'Surname is missing', + ]; +``` +The content in es/validations.php is: +```php + [ + 'missing_name' => 'Falta el nombre', + ]; +``` +Output for different keys with 'es' locale: +```php + trans('validations.missing_name'); // 'Falta el nombre' + trans('validations.missing_surname'); // 'Surname is missing' + trans('validations.missing_email'); // 'validations.missing_email' +``` + +### Load translations from the database + +You may choose to load translations exclusively from the database. This is very useful if you intend to allow users or administrators to live edit the site's text and translations. In a live production environment, you will usually want this source mode to be activated with the translation's cache. Please see [Loading your files into the database](#loading-your-files-into-the-database) for details on the steps required to use this source mode. + +Example: + +The content in the languages table is: + + | id | locale | name | + ------------------------- + | 1 | en | english | + | 2 | es | spanish | + +The relevant content in the language_entries table is: + + | id | locale | namespace | group | item | text | + ------------------------------------------------------------------------------------- + | 1 | en | * | validations | missing.name | Name is missing | + | 2 | en | * | validations | missing.surname | Surname is missing | + | 3 | en | * | validations | min_number | Number is too small | + | 4 | es | * | validations | missing.name | Falta nombre | + | 5 | es | * | validations | missing.surname | Falta apellido | + +Output for different keys with es locale: + +```php + trans('validations.missing.name'); // 'Falta nombre' + trans('validations.min_number'); // 'Number is too small' + trans('validations.missing.email'); // 'missing_email' +``` + +### Mixed mode + +In mixed mode, both the language files and the database are queried when looking for a group of language lines. Entries found in the filesystem take precedence over the database. This source mode is useful when in development, so that both the filesystem and the user entries are taken into consideration. + +Example: + + When files and database are set like in the previous examples: +```php + trans('validations.missing_name'); // 'Falta el nombre' + trans('validations.missing_surname'); // 'Falta apellido' + trans('validations.min_number'); // 'Number is too small' + trans('validations.missing_email'); // 'missing_email' +``` + +### Loading your files into the database + +When using either the database or mixed translation sources, you will need to first load your translations into the database. To do so, follow these steps: + +* Run the migrations detailed in the installation instructions. +* Add your languages of choice to the database (see [Managing Database Languages](#managing-database-languages)) +* Load your language files into the database using the provided Artisan command: + + ` php artisan translator:load ` + +When executing the artisan command, the following will happen: + +- Non existing entries will be created. +- Existing entries will be updated **except if they're locked**. When allowing users to live edit the translations, it is recommended you do it throught the updateAndLock method provided in the [Translations repository](#managing-translations). This prevents entries being overwritten when reloading translations from files. +- When an entry in the default locale is edited, all of its translations will be flagged as **pending review**. This gives translators the oportunity to review translations that might not be correct, but doesn't delete them so as to avoid minor errata changes in the source text from erasing all translations. See [Managing translations](#managing-translations) for details on how to work with unstable translations. + +Both vendor files and subdirectories are supported. Please keep in mind that when loading an entry inside a subdirectory, Laravel 5 has changed the syntax to: +```php + trans('subdir/file.entry') + trans('package::subdir/file.entry') +``` + +## Cache translations + +Since querying the database everytime a language group must be loaded is grossly inefficient, you may choose to leverage Laravel's cache system. This module will use the same cache configuration as defined by you in app/config/cache.php. + +You may enable or disable the cache through the translator.php config file or the 'TRANSLATION_CACHE_ENABLED' environment variable. Config options are: + + Env key | type |description +:---------|:--------|:----------- + TRANSLATION_CACHE_ENABLED | boolean| Enable / disable the translations cache + TRANSLATION_CACHE_TIMEOUT | integer| Minutes translation items should be kept in the cache. + TRANSLATION_CACHE_SUFFIX | string | Default is 'translation'. This will be the cache suffix applied to all translation cache entries. + +### Cache tags + +Available since version 2.1.3.8, if the cache store in use allows for tags, the TRANSLATION_CACHE_SUFFIX will be used as the common tag to all cache entries. This is recommended to be able to invalidate only the translation cache, or even just a given locale, namespace and group configuration. + +### Clearing the cache + +Available since version 2.1.3.8, you may clear the translation cache through both an Artisan Command and a Facade. If cache tags are in use, only the translation cache will be cleared. All of your application cache will however be cleared if you cache tags are not available. + +Cache flush command: + + php artisan translator:flush + +In order to access the translation cache, add to your config/app.php files, the following alias: +```php + 'aliases' => [ + /* ... */ + 'TranslationCache' => \Waavi\Translation\Facades\TranslationCache::class, + ] +``` +Once done, you may clear the whole translation cache by calling: +```php + \TranslationCache::flushAll(); +``` + +You may also choose to invalidate only a given locale, namespace and group combination. +```php + \TranslationCache::flush($locale, $group, $namespace); +``` + +- The locale is the language locale you wish to clear. +- The namespace is either '*' for your application translation files, or 'package' for vendor translation files. +- The group variable is the path to the translation file you wish to clear. + +For example, say we have the following file in our resources/lang directory: en/auth.php, en/auth/login.php and en/vendor/waavi/login.php. To clear the cache entries for each of them you would call: +```php + \TranslationCache::flush('en', 'auth', '*'); + \TranslationCache::flush('en', 'auth/login', '*'); + \TranslationCache::flush('en', 'login', 'waavi'); +``` + +## Managing languages and translations in the Database + +The recommended way of managing both languages and translations is through the provided repositories. You may circumvent this by saving changes directly through the Language and Translation models, however validation is no longer executed automatically on model save and could lead to instability and errors. + +Both the Language and the Translation repositories provide the following methods: + + Method | Description +:---------|:-------- +hasTable(); | Returns true if the corresponding table exists in the database, false otherwise +all($related = [], $perPage = 0); | Retrieve all records from the DB. A paginated record will be return if the second argument is > 0, with $perPage items returned per page +find($id); | Find a record by id +create($attributes); | Validates the given attributes and inserts a new record. Returns false if validation errors occured +delete($id); | Delete a record by id +restore($id); | Restore a record by id +count(); | Return the total number of entries +validate(array $attributes); | Checks if the given attributes are valid +validationErrors(); | Get validation errors for create and update methods + +### Managing Languages + +Language management should be done through the **\Waavi\Translation\Repositories\LanguageRepository** to ensure proper data validation before inserts and updates. It is recommended that you instantiate this class through Dependency Injection. + +A valid Language record requires both its name and locale to be unique. It is recommended you use the native name for each language (Ex: English, Español, Français) + +The provided methods are: + + Method | Description +:---------|:-------- +update(array $attributes); | Updates a Language entry [id, name, locale] +trashed($related = [], $perPage = 0); | Retrieve all trashed records from the DB. +findTrashed($id, $related = []); | Find a trashed record by id +findByLocale($locale); | Find a record by locale +findTrashedByLocale($locale); | Finds a trashed record by locale +allExcept($locale); | Returns a list of all languages excluding the given locale +availableLocales(); | Returns a list of all available locales +isValidLocale($locale); | Checks if a language exists with the given locale +percentTranslated($locale); | Returns the percent translated for the given locale + + +### Managing Translations + +Translation management should be done through the **\Waavi\Translation\Repositories\TranslationRepository** to ensure proper data validation before inserts and updates. It is recommended that you instantiate this class through Dependency Injection. + +A valid translation entry cannot have the same locale and language code than another. + +The provided methods are: + + Method | Description +:---------|:-------- +update($id, $text); | Update an unlocked entry +updateAndLock($id, $text); | Update and lock an entry (locked or not) +allByLocale($locale, $perPage = 0); | Get all by locale +untranslated($locale, $perPage = 0, $text = null); | Get all untranslated entries. If $text is set, entries will be filtered by partial matches to translation value. +pendingReview($locale, $perPage = 0); | List all entries pending review +search($locale, $term, $perPage = 0); | Search by all entries by locale and a partial match to both the text value and the translation code. +randomUntranslated($locale); | Get a random untranslated entry +translateText($text, $textLocale, $targetLocale); | Translate text to another locale +flagAsReviewed($id); | Flag entry as reviewed + +Things to consider: + + - You may lock translations so that they can only be updated through updateAndLock. The language file loader uses the update method and will not be able to override locked translations. + - When a text entry belonging to the default locale is updated, all of its siblings are marked as pending review. + - When deleting an entry, if it belongs to the default locale its translations will also be deleted. + +## Model attributes translation + +You can also use the translation management system to manage your model attributes translations. To do this, you only need to: + + - Make sure either the database or mixed source are set. + - Make sure your models use the Waavi\Translation\Translatable\Trait + - In your model, add a translatableAttributes array with the names of the attributes you wish to be available for translation. + - For every field you wish to translate, make sure there is a corresponding attributeName_translation field in your database. + +Example: +```php + \Schema::create('examples', function ($table) { + $table->increments('id'); + $table->string('slug')->nullable(); + $table->string('title')->nullable(); + $table->string('title_translation')->nullable(); + $table->string('text')->nullable(); + $table->string('text_translation')->nullable(); + $table->timestamps(); + }); + + class Example extends Model + { + use \Waavi\Translation\Traits\Translatable; + protected $translatableAttributes = ['title', 'text']; + } +``` + +## Uri localization + +You may use Waavi\Translation\Middleware\TranslationMiddleware to make sure all of your urls are properly localized. The TranslationMiddleware will only redirect GET requests that do not have a locale in them. + +For example, if a user visits the url /home, the following would happen: + + - The middleware will check if a locale is present. + - If a valid locale is present: + - it will globally set the language for that locale + - the following data will be available in your views: + - currentLanguage: current selected Language instance. + - selectableLanguages: list of all languages the visitor can switch to (except the current one) + - altLocalizedUrls: a list of all localized urls for the current resource except this one, formatted as ['locale' => 'en', 'name' => 'English', 'url' => '/en/home'] + - If no locale is present: + - Check the first two letters of the brower's accepted locale HTTP_ACCEPT_LANGUAGE (for example 'en-us' => 'en') + - If this is a valid locale, redirect the visitor to that locale => /es/home + - If not, redirect to default locale => /en/home + - Redirects will keep input data in the url, if any + +You may choose to activate this Middleware globally by adding the middleware to your App\Http\Kernel file: +```php + protected $middleware = [ + /* ... */ + \Waavi\Translation\Middleware\TranslationMiddleware::class, + ] +``` +Or to apply it selectively through the **'localize'** route middleware, which is already registered when installing the package through the ServiceProvider. + +It is recommended you add the following alias to your config/app.php aliases: + +```php + 'aliases' => [ + /* ... */ + 'UriLocalizer' => Waavi\Translation\Facades\UriLocalizer::class, + ]; +``` + +Every localized route must be prefixed with the current locale: + +```php + // If the middleware is globally applied: + Route::group(['prefix' => \UriLocalizer::localeFromRequest()], function(){ + /* Your routes here */ + }); + + // For selectively chosen routes: + Route::group(['prefix' => \UriLocalizer::localeFromRequest(), 'middleware' => 'localize')], function () { + /* Your routes here */ + }); +``` + +Starting on v2.1.6, you may also specify a custom position for the locale segment in your url. For example, if the locale info is the third segment in a URL (/api/v1/es/my_resource), you may use: + +```php + // For selectively chosen routes: + Route::group(['prefix' => 'api/v1'], function() { + /** ... Non localized urls here **/ + + Route::group(['prefix' => \UriLocalizer::localeFromRequest(2), 'middleware' => 'localize:2')], function () { + /* Your localized routes here */ + }); + }); +``` + +In your views, for routes where the Middleware is active, you may present the user with a menu to switch from the current language to another by using the shared variables. For example: + +```php + +``` diff --git a/src/Cache/CacheRepositoryInterface.php b/src/Cache/CacheRepositoryInterface.php new file mode 100644 index 0000000..3065386 --- /dev/null +++ b/src/Cache/CacheRepositoryInterface.php @@ -0,0 +1,58 @@ +getParentClass(); + $parentName = $storeParent ? $storeParent->name : ''; + return $parentName == 'Illuminate\Cache\TaggableStore' ? new TaggedRepository($store, $cacheTag) : new SimpleRepository($store, $cacheTag); + } +} diff --git a/src/Cache/SimpleRepository.php b/src/Cache/SimpleRepository.php new file mode 100644 index 0000000..0f15f22 --- /dev/null +++ b/src/Cache/SimpleRepository.php @@ -0,0 +1,108 @@ +store = $store; + $this->cacheTag = $cacheTag; + } + + /** + * Checks if an entry with the given key exists in the cache. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return boolean + */ + public function has($locale, $group, $namespace) + { + return !is_null($this->get($locale, $group, $namespace)); + } + + /** + * Get an item from the cache + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return mixed + */ + public function get($locale, $group, $namespace) + { + $key = $this->getKey($locale, $group, $namespace); + return $this->store->get($key); + } + + /** + * Put an item into the cache store + * + * @param string $locale + * @param string $group + * @param string $namespace + * @param mixed $content + * @param integer $minutes + * @return void + */ + public function put($locale, $group, $namespace, $content, $minutes) + { + $key = $this->getKey($locale, $group, $namespace); + $this->store->put($key, $content, $minutes); + } + + /** + * Flush the cache for the given entries + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return void + */ + public function flush($locale, $group, $namespace) + { + $this->flushAll(); + } + + /** + * Completely flush the cache + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return void + */ + public function flushAll() + { + $this->store->flush(); + } + + /** + * Returns a unique cache key. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return string + */ + protected function getKey($locale, $group, $namespace) + { + return md5("{$this->cacheTag}-{$locale}-{$group}-{$namespace}"); + } + +} diff --git a/src/Cache/TaggedRepository.php b/src/Cache/TaggedRepository.php new file mode 100644 index 0000000..1c09cb5 --- /dev/null +++ b/src/Cache/TaggedRepository.php @@ -0,0 +1,115 @@ +store = $store; + $this->cacheTag = $cacheTag; + } + + /** + * Checks if an entry with the given key exists in the cache. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return boolean + */ + public function has($locale, $group, $namespace) + { + return !is_null($this->get($locale, $group, $namespace)); + } + + /** + * Get an item from the cache + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return mixed + */ + public function get($locale, $group, $namespace) + { + $key = $this->getKey($locale, $group, $namespace); + return $this->store->tags([$this->cacheTag, $key])->get($key); + } + + /** + * Put an item into the cache store + * + * @param string $locale + * @param string $group + * @param string $namespace + * @param mixed $content + * @param integer $minutes + * @return void + */ + public function put($locale, $group, $namespace, $content, $minutes) + { + $key = $this->getKey($locale, $group, $namespace); + $this->store->tags([$this->cacheTag, $key])->put($key, $content, $minutes); + } + + /** + * Flush the cache for the given entries + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return void + */ + public function flush($locale, $group, $namespace) + { + $key = $this->getKey($locale, $group, $namespace); + $this->store->tags([$key])->flush(); + } + + /** + * Completely flush the cache + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return void + */ + public function flushAll() + { + $this->store->tags([$this->cacheTag])->flush(); + } + + /** + * Returns a unique cache key. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return string + */ + protected function getKey($locale, $group, $namespace) + { + return md5("{$this->cacheTag}-{$locale}-{$group}-{$namespace}"); + } +} diff --git a/src/Commands/CacheFlushCommand.php b/src/Commands/CacheFlushCommand.php new file mode 100644 index 0000000..0a6c869 --- /dev/null +++ b/src/Commands/CacheFlushCommand.php @@ -0,0 +1,59 @@ +cacheRepository = $cacheRepository; + $this->cacheEnabled = $cacheEnabled; + } + + /** + * Execute the console command. + * + * @return void + */ + public function fire() + { + if (!$this->cacheEnabled) { + $this->info('The translation cache is disabled.'); + } else { + $this->cacheRepository->flushAll(); + $this->info('Translation cache cleared.'); + } + } + + /** + * Execute the console command for Laravel 5.5 + * this laravel version call handle intead of fire + */ + public function handle() + { + $this->fire(); + } +} diff --git a/src/Commands/FileLoaderCommand.php b/src/Commands/FileLoaderCommand.php new file mode 100644 index 0000000..aaec654 --- /dev/null +++ b/src/Commands/FileLoaderCommand.php @@ -0,0 +1,136 @@ +languageRepository = $languageRepository; + $this->translationRepository = $translationRepository; + $this->path = $translationsPath; + $this->files = $files; + $this->defaultLocale = $defaultLocale; + } + + public function handle() + { + return $this->fire(); + } + + /** + * Execute the console command. + * + * @return void + */ + public function fire() + { + $this->loadLocaleDirectories($this->path); + } + + /** + * Loads all locale directories in the given path (/en, /es, /fr) as long as the locale corresponds to a language in the database. + * If a vendor directory is found not inside another vendor directory, the files within it will be loaded with the corresponding namespace. + * + * @param string $path Full path to the root directory of the locale directories. Usually /path/to/laravel/resources/lang + * @param string $namespace Namespace where the language files should be inserted. + * @return void + */ + public function loadLocaleDirectories($path, $namespace = '*') + { + $availableLocales = $this->languageRepository->availableLocales(); + $directories = $this->files->directories($path); + foreach ($directories as $directory) { + $locale = basename($directory); + if (in_array($locale, $availableLocales)) { + $this->loadDirectory($directory, $locale, $namespace); + } + if ($locale === 'vendor' && $namespace === '*') { + $this->loadVendor($directory); + } + } + } + + /** + * Load all vendor overriden localization packages. Calls loadLocaleDirectories with the appropriate namespace. + * + * @param string $path Path to vendor locale root, usually /path/to/laravel/resources/lang/vendor. + * @see http://laravel.com/docs/5.1/localization#overriding-vendor-language-files + * @return void + */ + public function loadVendor($path) + { + $directories = $this->files->directories($path); + foreach ($directories as $directory) { + $namespace = basename($directory); + $this->loadLocaleDirectories($directory, $namespace); + } + } + + /** + * Load all files inside a locale directory and its subdirectories. + * + * @param string $path Path to locale root. Ex: /path/to/laravel/resources/lang/en + * @param string $locale Locale to apply when loading the localization files. + * @param string $namespace Namespace to apply when loading the localization files ('*' by default, or the vendor package name if not) + * @param string $group When loading from a subdirectory, the subdirectory's name must be prepended. For example: trans('subdir/file.entry'). + * @return void + */ + public function loadDirectory($path, $locale, $namespace = '*', $group = '') + { + // Load all files inside subdirectories: + $directories = $this->files->directories($path); + foreach ($directories as $directory) { + $directoryName = str_replace($path . '/', '', $directory); + $dirGroup = $group . basename($directory) . '/'; + $this->loadDirectory($directory, $locale, $namespace, $dirGroup); + } + + // Load all files in root: + $files = $this->files->files($path); + foreach ($files as $file) { + $this->loadFile($file, $locale, $namespace, $group); + } + } + + /** + * Loads the given file into the database + * + * @param string $path Full path to the localization file. For example: /path/to/laravel/resources/lang/en/auth.php + * @param string $locale + * @param string $namespace + * @param string $group Relative from the locale directory's root. For example subdirectory/subdir2/ + * @return void + */ + public function loadFile($file, $locale, $namespace = '*', $group = '') + { + $group = $group . basename($file, '.php'); + $translations = $this->files->getRequire($file); + $this->translationRepository->loadArray($translations, $locale, $group, $namespace, $locale == $this->defaultLocale); + } +} diff --git a/src/Facades/TranslationCache.php b/src/Facades/TranslationCache.php new file mode 100644 index 0000000..9dd7124 --- /dev/null +++ b/src/Facades/TranslationCache.php @@ -0,0 +1,25 @@ +cache = $cache; + $this->fallback = $fallback; + $this->cacheTimeout = $cacheTimeout; + } + + /** + * Load the messages for the given locale. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + public function loadSource($locale, $group, $namespace = '*') + { + if ($this->cache->has($locale, $group, $namespace)) { + return $this->cache->get($locale, $group, $namespace); + } else { + $source = $this->fallback->load($locale, $group, $namespace); + $this->cache->put($locale, $group, $namespace, $source, $this->cacheTimeout); + return $source; + } + } + + /** + * Add a new namespace to the loader. + * + * @param string $namespace + * @param string $hint + * @return void + */ + public function addNamespace($namespace, $hint) + { + $this->fallback->addNamespace($namespace, $hint); + } + + /** + * Add a new JSON path to the loader. + * + * @param string $path + * @return void + */ + public function addJsonPath($path) + { + // + } + + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + public function namespaces() + { + return $this->fallback->namespaces(); + } +} diff --git a/src/Loaders/DatabaseLoader.php b/src/Loaders/DatabaseLoader.php new file mode 100644 index 0000000..76484c6 --- /dev/null +++ b/src/Loaders/DatabaseLoader.php @@ -0,0 +1,82 @@ +translationRepository = $translationRepository; + } + + /** + * Load the messages strictly for the given locale. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + public function loadSource($locale, $group, $namespace = '*') + { + $dotArray = $this->translationRepository->loadSource($locale, $namespace, $group); + $undot = []; + foreach ($dotArray as $item => $text) { + Arr::set($undot, $item, $text); + } + return $undot; + } + + /** + * Add a new namespace to the loader. + * + * @param string $namespace + * @param string $hint + * @return void + */ + public function addNamespace($namespace, $hint) + { + $this->hints[$namespace] = $hint; + } + + /** + * Add a new JSON path to the loader. + * + * @param string $path + * @return void + */ + public function addJsonPath($path) + { + // + } + + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + public function namespaces() + { + return $this->hints; + } +} diff --git a/src/Loaders/FileLoader.php b/src/Loaders/FileLoader.php new file mode 100644 index 0000000..2e3d806 --- /dev/null +++ b/src/Loaders/FileLoader.php @@ -0,0 +1,80 @@ +laravelFileLoader = $laravelFileLoader; + } + + /** + * Load the messages strictly for the given locale without checking the cache or in case of a cache miss. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + public function loadSource($locale, $group, $namespace = '*') + { + return $this->laravelFileLoader->load($locale, $group, $namespace); + } + + /** + * Add a new namespace to the loader. + * + * @param string $namespace + * @param string $hint + * @return void + */ + public function addNamespace($namespace, $hint) + { + $this->hints[$namespace] = $hint; + $this->laravelFileLoader->addNamespace($namespace, $hint); + } + + /** + * Add a new JSON path to the loader. + * + * @param string $path + * @return void + */ + public function addJsonPath($path) + { + $this->laravelFileLoader->addJsonPath($path); + } + + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + public function namespaces() + { + return $this->hints; + } +} diff --git a/src/Loaders/Loader.php b/src/Loaders/Loader.php new file mode 100644 index 0000000..e6de0ae --- /dev/null +++ b/src/Loaders/Loader.php @@ -0,0 +1,81 @@ +defaultLocale = $defaultLocale; + } + + /** + * Load the messages for the given locale. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + public function load($locale, $group, $namespace = null) + { + if ($locale != $this->defaultLocale) { + return array_replace_recursive( + $this->loadSource($this->defaultLocale, $group, $namespace), + $this->loadSource($locale, $group, $namespace) + ); + } + return $this->loadSource($locale, $group, $namespace); + } + + /** + * Load the messages for the given locale from the loader source (cache, file, database, etc...) + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + abstract public function loadSource($locale, $group, $namespace = null); + + /** + * Add a new namespace to the loader. + * + * @param string $namespace + * @param string $hint + * @return void + */ + abstract public function addNamespace($namespace, $hint); + + /** + * Add a new JSON path to the loader. + * + * @param string $path + * @return void + **/ + abstract public function addJsonPath($path); + + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + abstract public function namespaces(); +} diff --git a/src/Loaders/MixedLoader.php b/src/Loaders/MixedLoader.php new file mode 100644 index 0000000..7b3e04b --- /dev/null +++ b/src/Loaders/MixedLoader.php @@ -0,0 +1,87 @@ +primaryLoader = $primaryLoader; + $this->secondaryLoader = $secondaryLoader; + } + + /** + * Load the messages strictly for the given locale. + * + * @param string $locale + * @param string $group + * @param string $namespace + * @return array + */ + public function loadSource($locale, $group, $namespace = '*') + { + return array_replace_recursive( + $this->secondaryLoader->loadSource($locale, $group, $namespace), + $this->primaryLoader->loadSource($locale, $group, $namespace) + ); + } + + /** + * Add a new namespace to the loader. + * + * @param string $namespace + * @param string $hint + * @return void + */ + public function addNamespace($namespace, $hint) + { + $this->hints[$namespace] = $hint; + $this->primaryLoader->addNamespace($namespace, $hint); + $this->secondaryLoader->addNamespace($namespace, $hint); + } + + /** + * Add a new JSON path to the loader. + * + * @param string $path + * @return void + */ + public function addJsonPath($path) + { + // + } + + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + public function namespaces() + { + return $this->hints; + } +} diff --git a/src/Middleware/TranslationMiddleware.php b/src/Middleware/TranslationMiddleware.php new file mode 100644 index 0000000..bb23117 --- /dev/null +++ b/src/Middleware/TranslationMiddleware.php @@ -0,0 +1,98 @@ +uriLocalizer = $uriLocalizer; + $this->languageRepository = $languageRepository; + $this->config = $config; + $this->viewFactory = $viewFactory; + $this->app = $app; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param integer $segment Index of the segment containing locale info + * @return mixed + */ + public function handle($request, Closure $next, $segment = 0) + { + // Ignores all non GET requests: + if ($request->method() !== 'GET') { + return $next($request); + } + + $currentUrl = $request->getUri(); + $uriLocale = $this->uriLocalizer->getLocaleFromUrl($currentUrl, $segment); + $defaultLocale = $this->config->get('app.locale'); + + // If a locale was set in the url: + if ($uriLocale) { + $currentLanguage = $this->languageRepository->findByLocale($uriLocale); + $selectableLanguages = $this->languageRepository->allExcept($uriLocale); + $altLocalizedUrls = []; + foreach ($selectableLanguages as $lang) { + $altLocalizedUrls[] = [ + 'locale' => $lang->locale, + 'name' => $lang->name, + 'url' => $this->uriLocalizer->localize($currentUrl, $lang->locale, $segment), + ]; + } + + // Set app locale + $this->app->setLocale($uriLocale); + + // Share language variable with views: + $this->viewFactory->share('currentLanguage', $currentLanguage); + $this->viewFactory->share('selectableLanguages', $selectableLanguages); + $this->viewFactory->share('altLocalizedUrls', $altLocalizedUrls); + + // Set locale in session: + if ($request->hasSession() && $request->session()->get('waavi.translation.locale') !== $uriLocale) { + $request->session()->put('waavi.translation.locale', $uriLocale); + } + return $next($request); + } + + // If no locale was set in the url, check the session locale + if ($request->hasSession() && $sessionLocale = $request->session()->get('waavi.translation.locale')) { + if ($this->languageRepository->isValidLocale($sessionLocale)) { + return redirect()->to($this->uriLocalizer->localize($currentUrl, $sessionLocale, $segment)); + } + } + + // If no locale was set in the url, check the browser's locale: + $browserLocale = substr($request->server('HTTP_ACCEPT_LANGUAGE'), 0, 2); + if ($this->languageRepository->isValidLocale($browserLocale)) { + return redirect()->to($this->uriLocalizer->localize($currentUrl, $browserLocale, $segment)); + } + + // If not, redirect to the default locale: + // Keep flash data. + if ($request->hasSession()) { + $request->session()->reflash(); + } + return redirect()->to($this->uriLocalizer->localize($currentUrl, $defaultLocale, $segment)); + } +} diff --git a/src/Models/Language.php b/src/Models/Language.php new file mode 100644 index 0000000..bf5f928 --- /dev/null +++ b/src/Models/Language.php @@ -0,0 +1,50 @@ +setConnection(config('translator.connection')); + } + + /** + * Each language may have several translations. + */ + public function translations() + { + return $this->hasMany(Translation::class, 'locale', 'locale'); + } + + /** + * Returns the name of this language in the current selected language. + * + * @return string + */ + public function getLanguageCodeAttribute() + { + return "languages.{$this->locale}"; + } + +} diff --git a/src/Models/Translation.php b/src/Models/Translation.php new file mode 100644 index 0000000..63aa323 --- /dev/null +++ b/src/Models/Translation.php @@ -0,0 +1,69 @@ +setConnection(config('translator.connection')); + } + + /** + * Each translation belongs to a language. + */ + public function language() + { + return $this->belongsTo(Language::class, 'locale', 'locale'); + } + + /** + * Returns the full translation code for an entry: namespace.group.item + * @return string + */ + public function getCodeAttribute() + { + return $this->namespace === '*' ? "{$this->group}.{$this->item}" : "{$this->namespace}::{$this->group}.{$this->item}"; + } + + /** + * Flag this entry as Reviewed + * @return void + */ + public function flagAsReviewed() + { + $this->unstable = 0; + } + + /** + * Set the translation to the locked state + * @return void + */ + public function lock() + { + $this->locked = 1; + } + + /** + * Check if the translation is locked + * @return boolean + */ + public function isLocked() + { + return (boolean) $this->locked; + } +} diff --git a/src/Repositories/LanguageRepository.php b/src/Repositories/LanguageRepository.php new file mode 100644 index 0000000..c97ca2a --- /dev/null +++ b/src/Repositories/LanguageRepository.php @@ -0,0 +1,204 @@ +model = $model; + $this->validator = $app['validator']; + $config = $app['config']; + $this->defaultLocale = $config->get('app.locale'); + $this->defaultAvailableLocales = $config->get('translator.available_locales', []); + $this->config = $config; + } + + /** + * Insert a new language entry into the database. + * If the attributes are not valid, a null response is given and the errors can be retrieved through validationErrors() + * + * @param array $attributes Model attributes + * @return boolean + */ + public function create(array $attributes) + { + return $this->validate($attributes) ? Language::create($attributes) : null; + } + + /** + * Insert a new language entry into the database. + * If the attributes are not valid, a null response is given and the errors can be retrieved through validationErrors() + * + * @param array $attributes Model attributes + * @return boolean + */ + public function update(array $attributes) + { + return $this->validate($attributes) ? (boolean) Language::where('id', $attributes['id'])->update($attributes) : false; + } + + /** + * Find a Language by its locale + * + * @return Language | null + */ + public function findByLocale($locale) + { + return $this->model->where('locale', $locale)->first(); + } + + /** + * Find a deleted Language by its locale + * + * @return Language | null + */ + public function findTrashedByLocale($locale) + { + return $this->model->onlyTrashed()->where('locale', $locale)->first(); + } + + /** + * Find all Languages except the one with the specified locale. + * + * @return Language | null + */ + public function allExcept($locale) + { + return $this->model->where('locale', '!=', $locale)->get(); + } + + /** + * Returns a list of all available locales. + * + * @return array + */ + public function availableLocales() + { + if ($this->config->has('translator.locales')) { + return $this->config->get('translator.locales'); + } + + if ($this->config->get('translator.source') !== 'files') { + if ($this->tableExists()) { + $locales = $this->model->distinct()->get()->pluck('locale')->toArray(); + $this->config->set('translator.locales', $locales); + return $locales; + } + } + + return $this->defaultAvailableLocales; + } + + /** + * Checks if a language with the given locale exists. + * + * @return boolean + */ + public function isValidLocale($locale) + { + return $this->model->whereLocale($locale)->count() > 0; + } + + /** + * Compute percentage translate of the given language. + * + * @param string $locale + * @param string $referenceLocale + * @return int + */ + public function percentTranslated($locale) + { + $lang = $this->findByLocale($locale); + $referenceLang = $this->findByLocale($this->defaultLocale); + + $langEntries = $lang->translations()->count(); + $referenceEntries = $referenceLang->translations()->count(); + + return $referenceEntries > 0 ? (int) round($langEntries * 100 / $referenceEntries) : 0; + } + + /** + * Validate the given attributes + * + * @param array $attributes + * @return boolean + */ + public function validate(array $attributes) + { + $id = Arr::get($attributes, 'id', 'NULL'); + $table = $this->model->getTable(); + $rules = [ + 'locale' => "required|unique:{$table},locale,{$id}", + 'name' => "required|unique:{$table},name,{$id}", + ]; + $validator = $this->validator->make($attributes, $rules); + if ($validator->fails()) { + $this->errors = $validator->errors(); + return false; + } + return true; + } + + /** + * Returns the validations errors of the last action executed. + * + * @return \Illuminate\Support\MessageBag + */ + public function validationErrors() + { + return $this->errors; + } +} diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php new file mode 100644 index 0000000..3df545f --- /dev/null +++ b/src/Repositories/Repository.php @@ -0,0 +1,112 @@ +model; + } + + /** + * Check if the model's table exists + * + * @return boolean + */ + public function tableExists() + { + return $this->model->getConnection()->getSchemaBuilder()->hasTable($this->model->getTable()); + } + + /** + * Retrieve all records. + * + * @param array $related Related object to include. + * @param integer $perPage Number of records to retrieve per page. If zero the whole result set is returned. + * @return \Illuminate\Database\Eloquent\Model + */ + public function all($related = [], $perPage = 0) + { + $results = $this->model->with($related)->orderBy('created_at', 'DESC'); + return $perPage ? $results->paginate($perPage) : $results->get(); + } + + /** + * Retrieve all trashed. + * + * @param array $related Related object to include. + * @param integer $perPage Number of records to retrieve per page. If zero the whole result set is returned. + * @return \Illuminate\Database\Eloquent\Model + */ + public function trashed($related = [], $perPage = 0) + { + $trashed = $this->model->onlyTrashed()->with($related); + return $perPage ? $trashed->paginate($perPage) : $trashed->get(); + } + + /** + * Retrieve a single record by id. + * + * @param integer $id + * @return \Illuminate\Database\Eloquent\Model + */ + public function find($id, $related = []) + { + return $this->model->with($related)->find($id); + } + + /** + * Retrieve a single record by id. + * + * @param integer $id + * @return \Illuminate\Database\Eloquent\Model + */ + public function findTrashed($id, $related = []) + { + return $this->model->onlyTrashed()->with($related)->find($id); + } + + /** + * Remove a record. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return boolean + */ + public function delete($id) + { + $model = $this->model->where('id', $id)->first(); + if (!$model) { + return false; + } + return $model->delete(); + } + + /** + * Restore a record. + * + * @param int $id + * @return boolean + */ + public function restore($id) + { + $model = $this->findTrashed($id); + if ($model) { + $model->restore(); + } + return $model; + } + + /** + * Returns total number of entries in DB. + * + * @return integer + */ + public function count() + { + return $this->model->count(); + } +} diff --git a/src/Repositories/TranslationRepository.php b/src/Repositories/TranslationRepository.php new file mode 100644 index 0000000..7976b25 --- /dev/null +++ b/src/Repositories/TranslationRepository.php @@ -0,0 +1,479 @@ +model = $model; + $this->app = $app; + $this->defaultLocale = $app['config']->get('app.locale'); + $this->database = $app['db']; + } + + /** + * Insert a new translation into the database. + * If the attributes are not valid, a null response is given and the errors can be retrieved through validationErrors() + * + * @param array $attributes Model attributes + * @return boolean + */ + public function create(array $attributes) + { + return $this->validate($attributes) ? Translation::create($attributes) : null; + } + + /** + * Update a translation. + * If the translation is locked, no update will be made. + * + * @param array $attributes Model attributes + * @return boolean + */ + public function update($id, $text) + { + $translation = $this->find($id); + if (!$translation || $translation->isLocked()) { + return false; + } + $translation->text = $text; + $saved = $translation->save(); + if ($saved && $translation->locale === $this->defaultLocale) { + $this->flagAsUnstable($translation->namespace, $translation->group, $translation->item); + } + return $saved; + } + + /** + * Update and lock translation. Locked translations will not be ovewritten when loading translation files into the database. + * This will force and update if the translation is locked. + * If the attributes are not valid, a null response is given and the errors can be retrieved through validationErrors() + * + * @param array $attributes Model attributes + * @return boolean + */ + public function updateAndLock($id, $text) + { + $translation = $this->find($id); + if (!$translation) { + return false; + } + $translation->text = $text; + $translation->lock(); + $saved = $translation->save(); + if ($saved && $translation->locale === $this->defaultLocale) { + $this->flagAsUnstable($translation->namespace, $translation->group, $translation->item); + } + return $saved; + } + + /** + * Insert or Update entry by translation code for the default locale. + * + * @param string $code + * @param string $text + * @return boolean + */ + public function updateDefaultByCode($code, $text) + { + list($namespace, $group, $item) = $this->parseCode($code); + $locale = $this->defaultLocale; + $translation = $this->model->whereLocale($locale)->whereNamespace($namespace)->whereGroup($group)->whereItem($item)->first(); + if (!$translation) { + return $this->create(compact('locale', 'namespace', 'group', 'item', 'text')); + } + return $this->update($translation->id, $text); + } + + /** + * Delete a translation. If the translation is of the default language, delete all translations with the same namespace, group and item + * + * @param integer $id + * @return boolean + */ + public function delete($id) + { + $translation = $this->find($id); + if (!$translation) { + return false; + } + + if ($translation->locale === $this->defaultLocale) { + return $this->model->whereNamespace($translation->namespace)->whereGroup($translation->group)->whereItem($translation->item)->delete(); + } else { + return $translation->delete(); + } + } + + /** + * Delete all entries by code + * + * @param string $code + * @return boolean + */ + public function deleteByCode($code) + { + list($namespace, $group, $item) = $this->parseCode($code); + $this->model->whereNamespace($namespace)->whereGroup($group)->whereItem($item)->delete(); + } + + /** + * Loads a localization array from a localization file into the databas. + * + * @param array $lines + * @param string $locale + * @param string $group + * @param string $namespace + * @return void + */ + public function loadArray(array $lines, $locale, $group, $namespace = '*') + { + // Transform the lines into a flat dot array: + $lines = Arr::dot($lines); + foreach ($lines as $item => $text) { + if (is_string($text)) { + // Check if the entry exists in the database: + $translation = Translation::whereLocale($locale) + ->whereNamespace($namespace) + ->whereGroup($group) + ->whereItem($item) + ->first(); + + // If the translation already exists, we update the text: + if ($translation && !$translation->isLocked()) { + $translation->text = $text; + $saved = $translation->save(); + if ($saved && $translation->locale === $this->defaultLocale) { + $this->flagAsUnstable($namespace, $group, $item); + } + } + // If no entry was found, create it: + else { + $this->create(compact('locale', 'namespace', 'group', 'item', 'text')); + } + } + } + } + + /** + * Return a list of translations for the given language. If perPage is > 0 a paginated list is returned with perPage items per page. + * + * @param string $locale + * @return Translation + */ + public function allByLocale($locale, $perPage = 0) + { + $translations = $this->model->where('locale', $locale); + return $perPage ? $translations->paginate($perPage) : $translations->get(); + } + + /** + * Return all items for a given locale, namespace and group + * + * @param string $locale + * @param string $namespace + * @param string $group + * @return array + */ + public function getItems($locale, $namespace, $group) + { + return $this->model + ->whereLocale($locale) + ->whereNamespace($namespace) + ->whereGroup($group) + ->get() + ->toArray(); + } + + /** + * Return all items formatted as if coming from a PHP language file. + * + * @param string $locale + * @param string $namespace + * @param string $group + * @return array + */ + public function loadSource($locale, $namespace, $group) + { + return $this->model + ->whereLocale($locale) + ->whereNamespace($namespace) + ->whereGroup($group) + ->get() + ->keyBy('item') + ->map(function ($translation) { + return $translation['text']; + }) + ->toArray(); + } + + /** + * Retrieve translations pending review for the given locale. + * + * @param string $locale + * @param int $perPage Number of elements per page. 0 if all are wanted. + * @return Translation + */ + public function pendingReview($locale, $perPage = 0) + { + $underReview = $this->model->whereLocale($locale)->whereUnstable(1); + return $perPage ? $underReview->paginate($perPage) : $underReview->get(); + } + + /** + * Search for entries given a partial code and a locale + * + * @param string $locale + * @param string $partialCode + * @param integer $perPage 0 if all, > 0 if paginated list with that number of elements per page. + * @return Translation + */ + public function search($locale, $partialCode, $perPage = 0) + { + // Get the namespace, if any: + $colonIndex = stripos($partialCode, '::'); + $query = $this->model->whereLocale($locale); + if ($colonIndex === 0) { + $query = $query->where('namespace', '!=', '*'); + } elseif ($colonIndex > 0) { + $namespace = substr($partialCode, 0, $colonIndex); + $query = $query->where('namespace', 'like', "%{$namespace}%"); + $partialCode = substr($partialCode, $colonIndex + 2); + } + + // Divide the code in segments by . + $elements = explode('.', $partialCode); + foreach ($elements as $element) { + if ($element) { + $query = $query->where(function ($query) use ($element) { + $query->where('group', 'like', "%{$element}%")->orWhere('item', 'like', "%{$element}%")->orWhere('text', 'like', "%{$element}%"); + }); + } + } + + return $perPage ? $query->paginate($perPage) : $query->get(); + } + + /** + * List all entries in the default locale that do not exist for the target locale. + * + * @param string $locale Language to translate to. + * @param integer $perPage If greater than zero, return a paginated list with $perPage items per page. + * @param string $text [optional] Show only entries with the given text in them in the reference language. + * @return Collection + */ + public function untranslated($locale, $perPage = 0, $text = null) + { + $ids = $this->untranslatedQuery($locale)->pluck('id'); + + $untranslated = $text ? $this->model->whereIn('id', $ids)->where('text', 'like', "%$text%") : $this->model->whereIn('id', $ids); + + return $perPage ? $untranslated->paginate($perPage) : $untranslated->get(); + } + + /** + * Find a random entry that is present in the default locale but not in the given one. + * + * @param string $locale Locale to translate to. + * @return Translation + */ + public function randomUntranslated($locale) + { + return $this->untranslatedQuery($locale)->inRandomOrder()->take(1)->pluck('id'); + } + + /** + * Find a translation per namespace, group and item values + * + * @param string $locale + * @param string $namespace + * @param string $group + * @param string $item + * @return Translation + */ + public function findByLangCode($locale, $code) + { + list($namespace, $group, $item) = $this->parseCode($code); + return $this->model->whereLocale($locale)->whereNamespace($namespace)->whereGroup($group)->whereItem($item)->first(); + } + + /** + * Find a translation per namespace, group and item values + * + * @param string $locale + * @param string $namespace + * @param string $group + * @param string $item + * @return Translation + */ + public function findByCode($locale, $namespace, $group, $item) + { + return $this->model->whereLocale($locale)->whereNamespace($namespace)->whereGroup($group)->whereItem($item)->first(); + } + + /** + * Check if there are existing translations for the given text in the given locale for the target locale. + * + * @param string $text + * @param string $textLocale + * @param string $targetLocale + * @return array + */ + public function translateText($text, $textLocale, $targetLocale) + { + $table = $this->model->getTable(); + + return $this->model + ->newQuery() + ->select($table . '.text') + ->from($table) + ->leftJoin("{$table} as e", function ($join) use ($table, $text, $textLocale) { + $join->on('e.namespace', '=', "{$table}.namespace") + ->on('e.group', '=', "{$table}.group") + ->on('e.item', '=', "{$table}.item"); + }) + ->where("{$table}.locale", $targetLocale) + ->where('e.locale', $textLocale) + ->where('e.text', $text) + ->get() + ->pluck('text') + ->unique() + ->toArray(); + } + + /** + * Flag all entries with the given namespace, group and item and locale other than default as pending review. + * This is used when an entry for the default locale is updated. + * + * @param Translation $entry + * @return boolean + */ + public function flagAsUnstable($namespace, $group, $item) + { + $this->model->whereNamespace($namespace)->whereGroup($group)->whereItem($item)->where('locale', '!=', $this->defaultLocale)->update(['unstable' => '1']); + } + + /** + * Flag the entry with the given id as reviewed. + * + * @param integer $id + * @return boolean + */ + public function flagAsReviewed($id) + { + $this->model->where('id', $id)->update(['unstable' => '0']); + } + + /** + * Validate the given attributes + * + * @param array $attributes + * @return boolean + */ + public function validate(array $attributes) + { + $table = $this->model->getTable(); + $locale = Arr::get($attributes, 'locale', ''); + $namespace = Arr::get($attributes, 'namespace', ''); + $group = Arr::get($attributes, 'group', ''); + $rules = [ + 'locale' => 'required', + 'namespace' => 'required', + 'group' => 'required', + 'item' => "required|unique:{$table},item,NULL,id,locale,{$locale},namespace,{$namespace},group,{$group}", + 'text' => '', // Translations may be empty + ]; + $validator = $this->app['validator']->make($attributes, $rules); + if ($validator->fails()) { + $this->errors = $validator->errors(); + return false; + } + return true; + } + + /** + * Returns the validations errors of the last action executed. + * + * @return \Illuminate\Support\MessageBag + */ + public function validationErrors() + { + return $this->errors; + } + + /** + * Parse a translation code into its components + * + * @param string $code + * @return boolean + */ + public function parseCode($code) + { + $segments = (new NamespacedItemResolver)->parseKey($code); + + if (is_null($segments[0])) { + $segments[0] = '*'; + } + + return $segments; + } + + /** + * Create and return a new query to identify untranslated records. + * + * @param string $locale + * @return \Illuminate\Database\Query\Builder + */ + protected function untranslatedQuery($locale) + { + $table = $this->model->getTable(); + + return $this->database->table("$table as $table") + ->select("$table.id") + ->leftJoin("$table as e", function (JoinClause $query) use ($table, $locale) { + $query->on('e.namespace', '=', "$table.namespace") + ->on('e.group', '=', "$table.group") + ->on('e.item', '=', "$table.item") + ->where('e.locale', '=', $locale); + }) + ->where("$table.locale", $this->defaultLocale) + ->whereNull("e.id"); + } +} diff --git a/src/Routes/ResourceRegistrar.php b/src/Routes/ResourceRegistrar.php new file mode 100644 index 0000000..c5a8041 --- /dev/null +++ b/src/Routes/ResourceRegistrar.php @@ -0,0 +1,55 @@ +languageRepository = $languageRepository; + } + + /** + * Get the resource name for a grouped resource. + * + * @param string $prefix + * @param string $resource + * @param string $method + * @return string + */ + protected function getGroupResourceName($prefix, $resource, $method) + { + $availableLocales = $this->languageRepository->availableLocales(); + + // Remove segments from group prefix that are equal to one of the available locales: + $groupSegments = explode('/', $this->router->getLastGroupPrefix()); + $groupSegments = array_filter($groupSegments, function ($segment) use ($availableLocales) { + return !in_array($segment, $availableLocales); + }); + $group = trim(implode('.', $groupSegments), '.'); + + if (empty($group)) { + return trim("{$prefix}{$resource}.{$method}", '.'); + } + + return trim("{$prefix}{$group}.{$resource}.{$method}", '.'); + } +} diff --git a/src/Traits/Translatable.php b/src/Traits/Translatable.php new file mode 100644 index 0000000..46fecc2 --- /dev/null +++ b/src/Traits/Translatable.php @@ -0,0 +1,157 @@ +rawValueRequested($attribute)) { + $rawAttribute = snake_case(str_replace('raw', '', $attribute)); + return $this->attributes[$rawAttribute]; + } + // Return the translation for the given attribute if available + if ($this->isTranslated($attribute)) { + return $this->translate($attribute); + } + // Return parent + return parent::getAttribute($attribute); + } + + /** + * Hijack Eloquent's setAttribute to create a Language Entry, or update the existing one, when setting the value of this attribute. + * + * @param string $attribute Attribute name + * @param string $value Text value in default locale. + * @return void + */ + public function setAttribute($attribute, $value) + { + if ($this->isTranslatable($attribute) && !empty($value)) { + // If a translation code has not yet been set, generate one: + if (!$this->translationCodeFor($attribute)) { + $reflected = new \ReflectionClass($this); + $group = 'translatable'; + $item = strtolower($reflected->getShortName()) . '.' . strtolower($attribute) . '.' . Str::random(); + $this->attributes["{$attribute}_translation"] = "$group.$item"; + } + } + return parent::setAttribute($attribute, $value); + } + + /** + * Extend parent's attributesToArray so that _translation attributes do not appear in array, and translatable attributes are translated. + * + * @return array + */ + public function attributesToArray() + { + $attributes = parent::attributesToArray(); + + foreach ($this->translatableAttributes as $translatableAttribute) { + if (isset($attributes[$translatableAttribute])) { + $attributes[$translatableAttribute] = $this->translate($translatableAttribute); + } + unset($attributes["{$translatableAttribute}_translation"]); + } + + return $attributes; + } + + /** + * Get the set translation code for the give attribute + * + * @param string $attribute + * @return string + */ + public function translationCodeFor($attribute) + { + return Arr::get($this->attributes, "{$attribute}_translation", false); + } + + /** + * Check if the attribute being queried is the raw value of a translatable attribute. + * + * @param string $attribute + * @return boolean + */ + public function rawValueRequested($attribute) + { + if (strrpos($attribute, 'raw') === 0) { + $rawAttribute = snake_case(str_replace('raw', '', $attribute)); + return $this->isTranslatable($rawAttribute); + } + return false; + } + + /** + * @param $attribute + */ + public function getRawAttribute($attribute) + { + return Arr::get($this->attributes, $attribute, ''); + } + + /** + * Return the translation related to a translatable attribute. + * + * @param string $attribute + * @return Translation + */ + public function translate($attribute) + { + $translationCode = $this->translationCodeFor($attribute); + $translation = $translationCode ? trans($translationCode) : false; + return $translation ?: parent::getAttribute($attribute); + } + + /** + * Check if an attribute is translatable. + * + * @return boolean + */ + public function isTranslatable($attribute) + { + return in_array($attribute, $this->translatableAttributes); + } + + /** + * Check if a translation exists for the given attribute. + * + * @param string $attribute + * @return boolean + */ + public function isTranslated($attribute) + { + return $this->isTranslatable($attribute) && isset($this->attributes["{$attribute}_translation"]); + } + + /** + * Return the translatable attributes array + * + * @return array + */ + public function translatableAttributes() + { + return $this->translatableAttributes; + } +} diff --git a/src/Traits/TranslatableObserver.php b/src/Traits/TranslatableObserver.php new file mode 100644 index 0000000..9fe62fa --- /dev/null +++ b/src/Traits/TranslatableObserver.php @@ -0,0 +1,40 @@ +translatableAttributes() as $attribute) { + // If the value of the translatable attribute has changed: + if ($model->isDirty($attribute)) { + $translationRepository->updateDefaultByCode($model->translationCodeFor($attribute), $model->getRawAttribute($attribute)); + } + } + $cacheRepository->flush(config('app.locale'), 'translatable', '*'); + } + + /** + * Delete translations when model is deleted. + * + * @param Model $model + * @return void + */ + public function deleted($model) + { + $translationRepository = \App::make(TranslationRepository::class); + foreach ($model->translatableAttributes() as $attribute) { + $translationRepository->deleteByCode($model->translationCodeFor($attribute)); + } + } +} diff --git a/src/TranslationServiceProvider.php b/src/TranslationServiceProvider.php new file mode 100644 index 0000000..1c71055 --- /dev/null +++ b/src/TranslationServiceProvider.php @@ -0,0 +1,149 @@ +publishes([ + __DIR__ . '/../config/translator.php' => config_path('translator.php'), + ]); + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations/'); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom(__DIR__ . '/../config/translator.php', 'translator'); + + parent::register(); + $this->registerCacheRepository(); + $this->registerFileLoader(); + $this->registerCacheFlusher(); + $this->app->singleton('translation.uri.localizer', UriLocalizer::class); + $this->app[\Illuminate\Routing\Router::class]->aliasMiddleware('localize', TranslationMiddleware::class); + // Fix issue with laravel prepending the locale to localize resource routes: + $this->app->bind('Illuminate\Routing\ResourceRegistrar', ResourceRegistrar::class); + } + + /** + * IOC alias provided by this Service Provider. + * + * @return array + */ + public function provides() + { + return array_merge(parent::provides(), ['translation.cache.repository', 'translation.uri.localizer', 'translation.loader']); + } + + /** + * Register the translation line loader. + * + * @return void + */ + protected function registerLoader() + { + $app = $this->app; + $this->app->singleton('translation.loader', function ($app) { + $defaultLocale = $app['config']->get('app.locale'); + $loader = null; + $source = $app['config']->get('translator.source'); + + switch ($source) { + case 'mixed': + $laravelFileLoader = new LaravelFileLoader($app['files'], $app->basePath() . '/resources/lang'); + $fileLoader = new FileLoader($defaultLocale, $laravelFileLoader); + $databaseLoader = new DatabaseLoader($defaultLocale, $app->make(TranslationRepository::class)); + $loader = new MixedLoader($defaultLocale, $fileLoader, $databaseLoader); + break; + case 'mixed_db': + $laravelFileLoader = new LaravelFileLoader($app['files'], $app->basePath() . '/resources/lang'); + $fileLoader = new FileLoader($defaultLocale, $laravelFileLoader); + $databaseLoader = new DatabaseLoader($defaultLocale, $app->make(TranslationRepository::class)); + $loader = new MixedLoader($defaultLocale, $databaseLoader, $fileLoader); + break; + case 'database': + $loader = new DatabaseLoader($defaultLocale, $app->make(TranslationRepository::class)); + break; + default:case 'files': + $laravelFileLoader = new LaravelFileLoader($app['files'], $app->basePath() . '/resources/lang'); + $loader = new FileLoader($defaultLocale, $laravelFileLoader); + break; + } + if ($app['config']->get('translator.cache.enabled')) { + $loader = new CacheLoader($defaultLocale, $app['translation.cache.repository'], $loader, $app['config']->get('translator.cache.timeout')); + } + return $loader; + }); + } + + /** + * Register the translation cache repository + * + * @return void + */ + public function registerCacheRepository() + { + $this->app->singleton('translation.cache.repository', function ($app) { + $cacheStore = $app['cache']->getStore(); + return CacheRepositoryFactory::make($cacheStore, $app['config']->get('translator.cache.suffix')); + }); + } + + /** + * Register the translator:load language file loader. + * + * @return void + */ + protected function registerFileLoader() + { + $app = $this->app; + $defaultLocale = $app['config']->get('app.locale'); + $languageRepository = $app->make(LanguageRepository::class); + $translationRepository = $app->make(TranslationRepository::class); + $translationsPath = $app->basePath() . '/resources/lang'; + $command = new FileLoaderCommand($languageRepository, $translationRepository, $app['files'], $translationsPath, $defaultLocale); + + $this->app['command.translator:load'] = $command; + $this->commands('command.translator:load'); + } + + /** + * Flushes the translation cache + * + * @return void + */ + public function registerCacheFlusher() + { + //$cacheStore = $this->app['cache']->getStore(); + //$cacheRepository = CacheRepositoryFactory::make($cacheStore, $this->app['config']->get('translator.cache.suffix')); + $command = new CacheFlushCommand($this->app['translation.cache.repository'], $this->app['config']->get('translator.cache.enabled')); + + $this->app['command.translator:flush'] = $command; + $this->commands('command.translator:flush'); + } +} diff --git a/src/UriLocalizer.php b/src/UriLocalizer.php new file mode 100644 index 0000000..de303a7 --- /dev/null +++ b/src/UriLocalizer.php @@ -0,0 +1,144 @@ +request = $request; + $this->availableLocales = $languageRepository->availableLocales(); + } + + /** + * Returns the locale present in the current url, if any. + * + * @param integer $segment Index of the segment containing locale info + * @return string + */ + public function localeFromRequest($segment = 0) + { + $url = $this->request->getUri(); + return $this->getLocaleFromUrl($url, $segment); + } + + /** + * Localizes the given url to the given locale. Removes domain if present. + * Ex: /home => /es/home, /en/home => /es/home, http://www.domain.com/en/home => /en/home, https:://domain.com/ => /en + * If a non zero segment index is given, and the url doesn't have enought segments, the url is unchanged. + * + * @param string $url + * @param string $locale + * @param integer $segment Index of the segment containing locale info + * @return string + */ + public function localize($url, $locale, $segment = 0) + { + $cleanUrl = $this->cleanUrl($url, $segment); + $parsedUrl = $this->parseUrl($cleanUrl, $segment); + + // Check if there are enough segments, if not return url unchanged: + if (count($parsedUrl['segments']) >= $segment) { + array_splice($parsedUrl['segments'], $segment, 0, $locale); + } + return $this->pathFromParsedUrl($parsedUrl); + } + + /** + * Extract the first valid locale from a url + * + * @param string $url + * @param integer $segment Index of the segment containing locale info + * @return string|null $locale + */ + public function getLocaleFromUrl($url, $segment = 0) + { + return $this->parseUrl($url, $segment)['locale']; + } + + /** + * Removes the domain and locale (if present) of a given url. + * Ex: http://www.domain.com/locale/random => /random, https://www.domain.com/random => /random, http://domain.com/random?param=value => /random?param=value + * + * @param string $url + * @param integer $segment Index of the segment containing locale info + * @return string + */ + public function cleanUrl($url, $segment = 0) + { + $parsedUrl = $this->parseUrl($url, $segment); + // Remove locale from segments: + if ($parsedUrl['locale']) { + unset($parsedUrl['segments'][$segment]); + $parsedUrl['locale'] = false; + } + return $this->pathFromParsedUrl($parsedUrl); + } + + /** + * Parses the given url in a similar way to PHP's parse_url, with the following differences: + * Forward and trailling slashed are removed from the path value. + * A new "segments" key replaces 'path', with the uri segments in array form ('/es/random/thing' => ['es', 'random', 'thing']) + * A 'locale' key is added, with the value of the locale found in the current url + * + * @param string $url + * @param integer $segment Index of the segment containing locale info + * @return mixed + */ + protected function parseUrl($url, $segment = 0) + { + $parsedUrl = parse_url($url); + $parsedUrl['segments'] = array_values(array_filter(explode('/', $parsedUrl['path']), 'strlen')); + $localeCandidate = Arr::get($parsedUrl['segments'], $segment, false); + $parsedUrl['locale'] = in_array($localeCandidate, $this->availableLocales) ? $localeCandidate : null; + $parsedUrl['query'] = Arr::get($parsedUrl, 'query', false); + $parsedUrl['fragment'] = Arr::get($parsedUrl, 'fragment', false); + unset($parsedUrl['path']); + return $parsedUrl; + } + + /** + * Returns the uri for the given parsed url based on its segments, query and fragment + * + * @return string + */ + protected function pathFromParsedUrl($parsedUrl) + { + $path = '/' . implode('/', $parsedUrl['segments']); + if ($parsedUrl['query']) { + $path .= "?{$parsedUrl['query']}"; + } + if ($parsedUrl['fragment']) { + $path .= "#{$parsedUrl['fragment']}"; + } + return $path; + } + + /** + * Remove the front slash from a string + * + * @param string $path + * @return string + */ + protected function removeFrontSlash($path) + { + return strlen($path) > 0 && substr($path, 0, 1) === '/' ? substr($path, 1) : $path; + } + + /** + * Remove the trailing slash from a string + * + * @param string $path + * @return string + */ + protected function removeTrailingSlash($path) + { + return strlen($path) > 0 && substr($path, -1) === '/' ? substr($path, 0, -1) : $path; + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Cache/RepositoryFactoryTest.php b/tests/Cache/RepositoryFactoryTest.php new file mode 100644 index 0000000..8ac88ba --- /dev/null +++ b/tests/Cache/RepositoryFactoryTest.php @@ -0,0 +1,37 @@ +assertEquals(SimpleRepository::class, get_class($repo)); + } + + /** + * @test + */ + public function test_returns_simple_cache_if_taggable_store() + { + $store = new ArrayStore; + $repo = RepositoryFactory::make($store, 'translation'); + $this->assertEquals(TaggedRepository::class, get_class($repo)); + } +} diff --git a/tests/Cache/SimpleRepositoryTest.php b/tests/Cache/SimpleRepositoryTest.php new file mode 100644 index 0000000..e36ef2a --- /dev/null +++ b/tests/Cache/SimpleRepositoryTest.php @@ -0,0 +1,61 @@ +repo = new SimpleRepository(new ArrayStore, 'translation'); + } + + /** + * @test + */ + public function test_has_with_no_entry() + { + $this->assertFalse($this->repo->has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_has_returns_true_if_entry() + { + $this->repo->put('en', 'namespace', 'group', 'key', 'value'); + $this->assertTrue($this->repo->has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_get_returns_null_if_empty() + { + $this->assertNull($this->repo->get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_get_return_content_if_hit() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->assertEquals('value', $this->repo->get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_flush_removes_all() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->repo->put('es', 'namespace', 'group', 'valor', 60); + $this->repo->flush('en', 'namespace', 'group'); + $this->assertNull($this->repo->get('en', 'namespace', 'group')); + $this->assertNull($this->repo->get('es', 'namespace', 'group')); + } +} diff --git a/tests/Cache/TaggedRepositoryTest.php b/tests/Cache/TaggedRepositoryTest.php new file mode 100644 index 0000000..0b6d7e8 --- /dev/null +++ b/tests/Cache/TaggedRepositoryTest.php @@ -0,0 +1,73 @@ +repo = new TaggedRepository(new ArrayStore, 'translation'); + } + + /** + * @test + */ + public function has_returns_false_when_no_entry_present() + { + $this->assertFalse($this->repo->has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function has_returns_true_if_entry_present() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->assertTrue($this->repo->has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function get_returns_null_if_empty() + { + $this->assertNull($this->repo->get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function get_return_content_if_hit() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->assertEquals('value', $this->repo->get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_flush_removes_just_the_group() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->repo->put('es', 'namespace', 'group', 'valor', 60); + $this->repo->flush('en', 'namespace', 'group'); + $this->assertNull($this->repo->get('en', 'namespace', 'group')); + $this->assertEquals('valor', $this->repo->get('es', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_flush_all_removes_all() + { + $this->repo->put('en', 'namespace', 'group', 'value', 60); + $this->repo->put('es', 'namespace', 'group', 'value', 60); + $this->repo->flushAll(); + $this->assertNull($this->repo->get('en', 'namespace', 'group')); + $this->assertNull($this->repo->get('es', 'namespace', 'group')); + } +} diff --git a/tests/Cache/TranslationCacheTest.php b/tests/Cache/TranslationCacheTest.php new file mode 100644 index 0000000..8b42c89 --- /dev/null +++ b/tests/Cache/TranslationCacheTest.php @@ -0,0 +1,70 @@ +assertFalse(\TranslationCache::has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_has_returns_true_if_entry() + { + \TranslationCache::put('en', 'namespace', 'group', 'value', 60); + $this->assertTrue(\TranslationCache::has('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_get_returns_null_if_empty() + { + $this->assertNull(\TranslationCache::get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_get_return_content_if_hit() + { + \TranslationCache::put('en', 'namespace', 'group', 'value', 60); + $this->assertEquals('value', \TranslationCache::get('en', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_flush_removes_just_the_group() + { + \TranslationCache::put('en', 'namespace', 'group', 'value', 60); + \TranslationCache::put('es', 'namespace', 'group', 'valor', 60); + \TranslationCache::flush('en', 'namespace', 'group'); + $this->assertNull(\TranslationCache::get('en', 'namespace', 'group')); + $this->assertEquals('valor', \TranslationCache::get('es', 'namespace', 'group')); + } + + /** + * @test + */ + public function test_flush_all_removes_all() + { + \TranslationCache::put('en', 'namespace', 'group', 'value', 60); + \TranslationCache::put('es', 'namespace', 'group', 'value', 60); + \TranslationCache::flushAll(); + $this->assertNull(\TranslationCache::get('en', 'namespace', 'group')); + $this->assertNull(\TranslationCache::get('es', 'namespace', 'group')); + } +} diff --git a/tests/Commands/FlushTest.php b/tests/Commands/FlushTest.php new file mode 100644 index 0000000..de24dd9 --- /dev/null +++ b/tests/Commands/FlushTest.php @@ -0,0 +1,45 @@ +cacheRepository = \App::make('translation.cache.repository'); + } + + public function tearDown(): void + { + parent::tearDown(); + Mockery::close(); + } + + /** + * @test + */ + public function it_does_nothing_if_cache_disabled() + { + $this->cacheRepository->put('en', 'group', 'namespace', 'value', 60); + $this->assertTrue($this->cacheRepository->has('en', 'group', 'namespace')); + $command = Mockery::mock('Waavi\Translation\Commands\CacheFlushCommand[info]', [$this->cacheRepository, false]); + $command->shouldReceive('info')->with('The translation cache is disabled.')->once(); + $command->handle(); + $this->assertTrue($this->cacheRepository->has('en', 'group', 'namespace')); + } + + /** + * @test + */ + public function it_flushes_the_cache() + { + $this->cacheRepository->put('en', 'group', 'namespace', 'value', 60); + $this->assertTrue($this->cacheRepository->has('en', 'group', 'namespace')); + $command = Mockery::mock('Waavi\Translation\Commands\CacheFlushCommand[info]', [$this->cacheRepository, true]); + $command->shouldReceive('info')->with('Translation cache cleared.')->once(); + $command->handle(); + $this->assertFalse($this->cacheRepository->has('en', 'group', 'namespace')); + } +} diff --git a/tests/Commands/LoadTest.php b/tests/Commands/LoadTest.php new file mode 100644 index 0000000..c5cde61 --- /dev/null +++ b/tests/Commands/LoadTest.php @@ -0,0 +1,151 @@ +languageRepository = \App::make(LanguageRepository::class); + $this->translationRepository = \App::make(TranslationRepository::class); + $translationsPath = realpath(__DIR__ . '/../lang'); + $this->command = new FileLoaderCommand($this->languageRepository, $this->translationRepository, \App::make('files'), $translationsPath, 'en'); + } + + /** + * @test + */ + public function it_loads_files_into_database() + { + $file = realpath(__DIR__ . '/../lang/en/auth.php'); + $this->command->loadFile($file, 'en'); + $translations = $this->translationRepository->all(); + + $this->assertEquals(3, $translations->count()); + + $this->assertEquals('en', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('auth', $translations[0]->group); + $this->assertEquals('login.label', $translations[0]->item); + $this->assertEquals('Enter your credentials', $translations[0]->text); + + $this->assertEquals('en', $translations[1]->locale); + $this->assertEquals('*', $translations[1]->namespace); + $this->assertEquals('auth', $translations[1]->group); + $this->assertEquals('login.action', $translations[1]->item); + $this->assertEquals('Login', $translations[1]->text); + + $this->assertEquals('en', $translations[2]->locale); + $this->assertEquals('*', $translations[2]->namespace); + $this->assertEquals('auth', $translations[2]->group); + $this->assertEquals('simple', $translations[2]->item); + $this->assertEquals('Simple', $translations[2]->text); + } + + /** + * @test + */ + public function it_loads_files_in_subdirectories_into_database() + { + $directory = realpath(__DIR__ . '/../lang/es'); + $this->command->loadDirectory($directory, 'es'); + $translations = $this->translationRepository->all()->sortBy('id'); + + $this->assertEquals(2, $translations->count()); + + $this->assertEquals('es', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('welcome/page', $translations[0]->group); + $this->assertEquals('title', $translations[0]->item); + $this->assertEquals('Bienvenido', $translations[0]->text); + + $this->assertEquals('es', $translations[1]->locale); + $this->assertEquals('*', $translations[1]->namespace); + $this->assertEquals('auth', $translations[1]->group); + $this->assertEquals('login.action', $translations[1]->item); + $this->assertEquals('Identifícate', $translations[1]->text); + } + + /** + * @test + */ + public function it_doesnt_load_undefined_locales() + { + $this->command->handle(); + $locales = $this->translationRepository->all()->pluck('locale')->toArray(); + $this->assertTrue(in_array('en', $locales)); + $this->assertTrue(in_array('es', $locales)); + $this->assertFalse(in_array('ca', $locales)); + } + + /** + * @test + */ + public function it_loads_overwritten_vendor_files_correctly() + { + $this->command->handle(); + + $translations = $this->translationRepository->all(); + + $this->assertEquals(9, $translations->count()); + + $this->assertEquals('Texto proveedor', $translations->where('locale', 'es')->where('namespace', 'package')->where('group', 'example')->where('item', 'entry')->first()->text); + $this->assertEquals('Vendor text', $translations->where('locale', 'en')->where('namespace', 'package')->where('group', 'example')->where('item', 'entry')->first()->text); + } + + /** + * @test + */ + public function it_doesnt_overwrite_locked_translations() + { + $trans = $this->translationRepository->create([ + 'locale' => 'en', + 'namespace' => '*', + 'group' => 'auth', + 'item' => 'login.label', + 'text' => 'No override', + ]); + $trans->locked = true; + $trans->save(); + + $file = realpath(__DIR__ . '/../lang/en/auth.php'); + $this->command->loadFile($file, 'en'); + $translations = $this->translationRepository->all(); + + $this->assertEquals(3, $translations->count()); + + $this->assertEquals('en', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('auth', $translations[0]->group); + $this->assertEquals('login.label', $translations[0]->item); + $this->assertEquals('No override', $translations[0]->text); + + $this->assertEquals('en', $translations[1]->locale); + $this->assertEquals('*', $translations[1]->namespace); + $this->assertEquals('auth', $translations[1]->group); + $this->assertEquals('login.action', $translations[1]->item); + $this->assertEquals('Login', $translations[1]->text); + } + + /** + * @test + */ + public function it_doesnt_load_empty_arrays() + { + $file = realpath(__DIR__ . '/../lang/en/empty.php'); + $this->command->loadFile($file, 'en'); + $translations = $this->translationRepository->all(); + + $this->assertEquals(1, $translations->count()); + + $this->assertEquals('en', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('empty', $translations[0]->group); + $this->assertEquals('emptyString', $translations[0]->item); + $this->assertEquals('', $translations[0]->text); + } +} diff --git a/tests/Loaders/CacheLoaderTest.php b/tests/Loaders/CacheLoaderTest.php new file mode 100644 index 0000000..91371cb --- /dev/null +++ b/tests/Loaders/CacheLoaderTest.php @@ -0,0 +1,45 @@ +cache = Mockery::mock(Cache::class); + $this->fallback = Mockery::mock(Loader::class); + $this->cacheLoader = new CacheLoader('en', $this->cache, $this->fallback, 60, 'translation'); + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function it_returns_from_cache_if_hit() + { + $this->cache->shouldReceive('has')->with('en', 'group', 'name')->once()->andReturn(true); + $this->cache->shouldReceive('get')->with('en', 'group', 'name')->once()->andReturn('cache hit'); + $this->assertEquals('cache hit', $this->cacheLoader->loadSource('en', 'group', 'name')); + } + + /** + * @test + */ + public function it_returns_from_fallback_and_stores_in_cache_if_miss() + { + $this->cache->shouldReceive('has')->with('en', 'group', 'name')->once()->andReturn(false); + $this->fallback->shouldReceive('load')->with('en', 'group', 'name')->once()->andReturn('cache miss'); + $this->cache->shouldReceive('put')->with('en', 'group', 'name', 'cache miss', 60)->once()->andReturn(true); + $this->assertEquals('cache miss', $this->cacheLoader->loadSource('en', 'group', 'name')); + } +} diff --git a/tests/Loaders/DatabaseLoaderTest.php b/tests/Loaders/DatabaseLoaderTest.php new file mode 100644 index 0000000..dd2fb6b --- /dev/null +++ b/tests/Loaders/DatabaseLoaderTest.php @@ -0,0 +1,61 @@ +translationRepository = \App::make(TranslationRepository::class); + $this->loader = new DatabaseLoader('es', $this->translationRepository); + } + + public function tearDown():void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function it_returns_from_database() + { + $expected = [ + 'simple' => 'text', + 'array' => [ + 'item' => 'item', + 'nested' => [ + 'item' => 'nested', + ], + ], + ]; + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'simple', + 'text' => 'text', + ]); + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'array.item', + 'text' => 'item', + ]); + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'array.nested.item', + 'text' => 'nested', + ]); + $translations = $this->loader->loadSource('es', 'group'); + $this->assertEquals($expected, $translations); + } +} diff --git a/tests/Loaders/FileLoaderTest.php b/tests/Loaders/FileLoaderTest.php new file mode 100644 index 0000000..ec23454 --- /dev/null +++ b/tests/Loaders/FileLoaderTest.php @@ -0,0 +1,38 @@ +laravelLoader = Mockery::mock(LaravelFileLoader::class); + $this->fileLoader = new FileLoader('en', $this->laravelLoader); + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function it_returns_from_file() + { + $data = [ + 'simple' => 'Simple', + 'nested' => [ + 'one' => 'First', + 'two' => 'Second', + ], + ]; + $this->laravelLoader->shouldReceive('load')->with('en', 'group', 'name')->andReturn($data); + $this->assertEquals($data, $this->fileLoader->loadSource('en', 'group', 'name')); + } +} diff --git a/tests/Loaders/LoadTest.php b/tests/Loaders/LoadTest.php new file mode 100644 index 0000000..73097ef --- /dev/null +++ b/tests/Loaders/LoadTest.php @@ -0,0 +1,61 @@ +laravelLoader = Mockery::mock(LaravelFileLoader::class); + // We will use the file loader: + $this->fileLoader = new FileLoader('en', $this->laravelLoader); + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function it_merges_default_and_target_locales() + { + $en = [ + 'simple' => 'Simple', + 'nested' => [ + 'one' => 'First', + 'two' => 'Second', + ], + ]; + $es = [ + 'simple' => 'OverSimple', + 'nested' => [ + 'one' => 'OverFirst', + ], + ]; + $expected = [ + 'simple' => 'OverSimple', + 'nested' => [ + 'one' => 'OverFirst', + 'two' => 'Second', + ], + ]; + $this->laravelLoader->shouldReceive('load')->with('en', 'group', 'name')->andReturn($en); + $this->laravelLoader->shouldReceive('load')->with('es', 'group', 'name')->andReturn($es); + $this->assertEquals($expected, $this->fileLoader->load('es', 'group', 'name')); + } + + /** + * @testLoadTest + */ + public function it_returns_translation_code_if_text_not_found() + { + $this->assertEquals('auth.code', trans('auth.code')); + } +} diff --git a/tests/Loaders/MixedLoaderTest.php b/tests/Loaders/MixedLoaderTest.php new file mode 100644 index 0000000..b812ab9 --- /dev/null +++ b/tests/Loaders/MixedLoaderTest.php @@ -0,0 +1,57 @@ +fileLoader = Mockery::mock(FileLoader::class); + $this->dbLoader = Mockery::mock(DatabaseLoader::class); + $this->mixedLoader = new MixedLoader('en', $this->fileLoader, $this->dbLoader); + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function it_merges_file_and_db() + { + $file = [ + 'in.file' => 'File', + 'no.db' => 'No database', + ]; + $db = [ + 'in.file' => 'Database', + 'no.file' => 'No file', + ]; + $expected = [ + 'in.file' => 'File', + 'no.db' => 'No database', + 'no.file' => 'No file', + ]; + $this->fileLoader->shouldReceive('loadSource')->with('en', 'group', 'name')->andReturn($file); + $this->dbLoader->shouldReceive('loadSource')->with('en', 'group', 'name')->andReturn($db); + $this->assertEquals($expected, $this->mixedLoader->load('en', 'group', 'name')); + } + + /** + * @test + */ + public function it_cascades_namespaces() + { + $this->fileLoader->shouldReceive('addNamespace')->with('package', '/some/path/to/package')->andReturnNull(); + $this->dbLoader->shouldReceive('addNamespace')->with('package', '/some/path/to/package')->andReturnNull(); + $this->assertNull($this->mixedLoader->addNamespace('package', '/some/path/to/package')); + } +} diff --git a/tests/Localizer/CleanUrlTest.php b/tests/Localizer/CleanUrlTest.php new file mode 100644 index 0000000..b08bf71 --- /dev/null +++ b/tests/Localizer/CleanUrlTest.php @@ -0,0 +1,72 @@ +assertEquals('/', UriLocalizer::cleanUrl('')); + $this->assertEquals('/', UriLocalizer::cleanUrl('/')); + } + + /** + * @test + */ + public function it_cleans_uri() + { + $this->assertEquals('/random', UriLocalizer::cleanUrl('random/')); + } + + /** + * @test + */ + public function it_cleans_http_url() + { + $this->assertEquals('/random', UriLocalizer::cleanUrl('http://domain.com/random/')); + } + + /** + * @test + */ + public function it_cleans_https_url() + { + $this->assertEquals('/random', UriLocalizer::cleanUrl('https://domain.com/random/')); + } + + /** + * @test + */ + public function it_keeps_query_string() + { + $this->assertEquals('/random?param=value¶m=', UriLocalizer::cleanUrl('https://domain.com/random/?param=value¶m=')); + } + + /** + * @test + */ + public function it_removes_locale_string() + { + $this->assertEquals('/random?param=value¶m=', UriLocalizer::cleanUrl('https://domain.com/es/random/?param=value¶m=')); + } + + /** + * @test + */ + public function it_removes_locale_string_in_custom_position() + { + $this->assertEquals('/api/random?param=value¶m=', UriLocalizer::cleanUrl('https://domain.com/api/es/random/?param=value¶m=', 1)); + } + + /** + * @test + */ + public function it_keeps_invalid_locale_string() + { + $this->assertEquals('/ca/random?param=value¶m=', UriLocalizer::cleanUrl('https://domain.com/ca/random/?param=value¶m=')); + } +} diff --git a/tests/Localizer/GetLocaleFromUrlTest.php b/tests/Localizer/GetLocaleFromUrlTest.php new file mode 100644 index 0000000..b0dd4c7 --- /dev/null +++ b/tests/Localizer/GetLocaleFromUrlTest.php @@ -0,0 +1,41 @@ +assertEquals('es', UriLocalizer::getLocaleFromUrl('http://domain.com/es/random/')); + } + + /** + * @test + */ + public function it_returns_locale_from_uri() + { + $this->assertEquals('es', UriLocalizer::getLocaleFromUrl('/es/random/')); + $this->assertEquals('es', UriLocalizer::getLocaleFromUrl('es/random/')); + } + + /** + * @test + */ + public function it_return_null_if_no_locale_found() + { + $this->assertNull(UriLocalizer::getLocaleFromUrl('/random/')); + $this->assertNull(UriLocalizer::getLocaleFromUrl('ca/random/')); + } + + /** + * @test + */ + public function it_returns_locale_from_url_in_custom_position() + { + $this->assertEquals('es', UriLocalizer::getLocaleFromUrl('http://domain.com/api/es/random/', 1)); + } +} diff --git a/tests/Localizer/LocalizeUriTest.php b/tests/Localizer/LocalizeUriTest.php new file mode 100644 index 0000000..492fca9 --- /dev/null +++ b/tests/Localizer/LocalizeUriTest.php @@ -0,0 +1,73 @@ +assertEquals('/es', UriLocalizer::localize('/', 'es')); + $this->assertEquals('/es', UriLocalizer::localize('', 'es')); + } + + /** + * @test + */ + public function test_home_with_locale() + { + $this->assertEquals('/es', UriLocalizer::localize('/en', 'es')); + $this->assertEquals('/es', UriLocalizer::localize('en', 'es')); + } + + /** + * @test + */ + public function test_random_page_no_locale() + { + $this->assertEquals('/es/random', UriLocalizer::localize('/random', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('random', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('/random/', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('random/', 'es')); + } + + /** + * @test + */ + public function test_random_page_with_locale() + { + $this->assertEquals('/es/random', UriLocalizer::localize('/en/random', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('en/random', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('/en/random/', 'es')); + $this->assertEquals('/es/random', UriLocalizer::localize('en/random/', 'es')); + } + + /** + * @test + */ + public function it_ignores_unexesting_locales() + { + $this->assertEquals('/es/ca/random', UriLocalizer::localize('/ca/random', 'es')); + } + + /** + * @test + */ + public function it_maintains_get_parameters() + { + $this->assertEquals('/es/random?param1=value1¶m2=', UriLocalizer::localize('random?param1=value1¶m2=', 'es')); + } + + /** + * @test + */ + public function it_localizes_when_locale_is_not_first() + { + $this->assertEquals('/api/es/random', UriLocalizer::localize('api/random', 'es', 1)); + $this->assertEquals('/api/es/random', UriLocalizer::localize('api/en/random', 'es', 1)); + } +} diff --git a/tests/Middleware/TranslationMiddlewareTest.php b/tests/Middleware/TranslationMiddlewareTest.php new file mode 100644 index 0000000..81c1141 --- /dev/null +++ b/tests/Middleware/TranslationMiddlewareTest.php @@ -0,0 +1,141 @@ +call('GET', '/'); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->headers->has('location')); + $this->assertEquals('http://localhost/en', $response->headers->get('location')); + } + + /** + * @test + */ + public function it_will_redirect_to_browser_locale_before_default() + { + $response = $this->call('GET', '/', [], [], [], ['HTTP_ACCEPT_LANGUAGE' => 'es']); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->headers->has('location')); + $this->assertEquals('http://localhost/es', $response->headers->get('location')); + } + + /** + * @test + */ + public function it_will_redirect_if_invalid_locale() + { + $response = $this->call('GET', '/ca'); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->headers->has('location')); + $this->assertEquals('http://localhost/en/ca', $response->headers->get('location')); + } + + /** + * @test + */ + public function it_will_not_redirect_if_valid_locale() + { + $response = $this->call('GET', '/es'); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Hola mundo', $response->getContent()); + } + + /** + * @test + */ + public function it_will_ignore_post_requests() + { + $response = $this->call('POST', '/'); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('POST answer', $response->getContent()); + } + + /** + * @test + */ + public function it_sets_the_app_locale() + { + $response = $this->call('GET', '/en/locale'); + $this->assertEquals('en', $response->getContent()); + $response = $this->call('GET', '/es/locale'); + $this->assertEquals('es', $response->getContent()); + } + + /** + * @test + */ + public function it_detects_the_app_locale_in_custom_segment() + { + $response = $this->call('GET', '/api/v1/en/locale'); + $this->assertEquals('en', $response->getContent()); + $response = $this->call('GET', '/api/v1/es/locale'); + $this->assertEquals('es', $response->getContent()); + } + + /** + * @test + */ + public function it_redirects_invalid_locale_in_custom_segment() + { + $response = $this->call('GET', '/api/v1/ca/locale'); + $statusCode = $response->getStatusCode(); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->headers->has('location')); + $this->assertEquals('http://localhost/api/v1/en/ca/locale', $response->headers->get('location')); + } + + /** + * @test + */ + public function it_keeps_locale_in_post_requests_with_no_locale_set() + { + $translationRepository = \App::make(TranslationRepository::class); + $trans = $translationRepository->create([ + 'locale' => 'en', + 'namespace' => '*', + 'group' => 'welcome', + 'item' => 'title', + 'text' => 'Welcome', + ]); + + $trans = $translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'welcome', + 'item' => 'title', + 'text' => 'Bienvenido', + ]); + + $this->call('GET', '/es'); + $response = $this->call('POST', '/welcome'); + $statusCode = $response->getStatusCode(); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Bienvenido', $response->getContent()); + + $this->call('GET', '/en'); + $response = $this->call('POST', '/welcome'); + $statusCode = $response->getStatusCode(); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Welcome', $response->getContent()); + } +} diff --git a/tests/Repositories/LanguageRepositoryTest.php b/tests/Repositories/LanguageRepositoryTest.php new file mode 100644 index 0000000..f406d0c --- /dev/null +++ b/tests/Repositories/LanguageRepositoryTest.php @@ -0,0 +1,184 @@ +languageRepository = \App::make(LanguageRepository::class); + $this->translationRepository = \App::make(TranslationRepository::class); + } + + /** + * @test + */ + public function test_can_create() + { + $this->assertNotNull($this->languageRepository->create(['locale' => 'ca', 'name' => 'Catalan'])); + } + + /** + * @test + */ + public function test_has_table() + { + $this->assertTrue($this->languageRepository->tableExists()); + } + + /** + * @test + */ + public function test_create_disallows_duplicate_locale() + { + $this->assertNull($this->languageRepository->create(['locale' => 'en', 'name' => 'Catalan'])); + } + + /** + * @test + */ + public function test_create_disallows_duplicate_name() + { + $this->assertNull($this->languageRepository->create(['locale' => 'ca', 'name' => 'English'])); + } + + /** + * @test + */ + public function test_can_update() + { + $this->assertTrue($this->languageRepository->update(['id' => 1, 'locale' => 'ens', 'name' => 'Englishs'])); + $lang = $this->languageRepository->find(1); + $this->assertEquals('ens', $lang->locale); + $this->assertEquals('Englishs', $lang->name); + } + + /** + * @test + */ + public function test_update_disallows_duplicate_locale() + { + $this->assertFalse($this->languageRepository->update(['id' => 1, 'locale' => 'es', 'name' => 'Englishs'])); + } + + /** + * @test + */ + public function test_update_disallows_duplicate_name() + { + $this->assertFalse($this->languageRepository->update(['id' => 1, 'locale' => 'ens', 'name' => 'Spanish'])); + } + + /** + * @test + */ + public function it_can_delete() + { + $this->languageRepository->delete(2); + $this->assertEquals(1, $this->languageRepository->all()->count()); + } + + /** + * @test + */ + public function it_can_restore() + { + $this->languageRepository->delete(2); + $this->assertEquals(1, $this->languageRepository->all()->count()); + $this->languageRepository->restore(2); + $this->assertEquals(2, $this->languageRepository->all()->count()); + } + + /** + * @test + */ + public function it_can_find_by_locale() + { + $language = $this->languageRepository->findByLocale('es'); + $this->assertNotNull($language); + $this->assertEquals('es', $language->locale); + $this->assertEquals('Spanish', $language->name); + } + + /** + * @test + */ + public function it_can_find_trashed_by_locale() + { + $this->languageRepository->delete(2); + $language = $this->languageRepository->findTrashedByLocale('es'); + $this->assertNotNull($language); + $this->assertEquals('es', $language->locale); + $this->assertEquals('Spanish', $language->name); + } + + /** + * @test + */ + public function it_can_find_all_except_one() + { + $this->languageRepository->create(['locale' => 'ca', 'name' => 'Catalan']); + $languages = $this->languageRepository->allExcept('es'); + $this->assertNotNull($languages); + $this->assertEquals(2, $languages->count()); + + $this->assertEquals('en', $languages[0]->locale); + $this->assertEquals('English', $languages[0]->name); + $this->assertEquals('ca', $languages[1]->locale); + $this->assertEquals('Catalan', $languages[1]->name); + } + + /** + * @test + */ + public function it_can_get_a_list_of_all_available_locales() + { + $this->assertEquals(['en', 'es'], $this->languageRepository->availableLocales()); + } + + /** + * @test + */ + public function it_can_check_a_locale_exists() + { + $this->assertTrue($this->languageRepository->isValidLocale('es')); + $this->assertFalse($this->languageRepository->isValidLocale('ca')); + } + + /** + * @test + */ + public function it_can_calculate_the_percent_translated() + { + $this->assertEquals(0, $this->languageRepository->percentTranslated('es')); + + $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->translationRepository->create([ + 'locale' => 'en', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->translationRepository->create([ + 'locale' => 'en', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item2', + 'text' => 'text', + ]); + + $this->assertEquals(50, $this->languageRepository->percentTranslated('es')); + $this->assertEquals(100, $this->languageRepository->percentTranslated('en')); + } +} diff --git a/tests/Repositories/TranslationRepositoryTest.php b/tests/Repositories/TranslationRepositoryTest.php new file mode 100644 index 0000000..edcc235 --- /dev/null +++ b/tests/Repositories/TranslationRepositoryTest.php @@ -0,0 +1,467 @@ +languageRepository = \App::make(LanguageRepository::class); + $this->translationRepository = \App::make(TranslationRepository::class); + } + + /** + * @test + */ + public function test_can_create() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + + $this->assertTrue($translation->exists()); + + $this->assertEquals('es', $translation->locale); + $this->assertEquals('*', $translation->namespace); + $this->assertEquals('group', $translation->group); + $this->assertEquals('item', $translation->item); + $this->assertEquals('text', $translation->text); + } + + /** + * @test + */ + public function test_namespace_is_required() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->assertNull($translation); + } + + /** + * @test + */ + public function test_locale_is_required() + { + $translation = $this->translationRepository->create([ + 'locale' => '', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->assertNull($translation); + } + + /** + * @test + */ + public function test_group_is_required() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => '', + 'item' => 'item', + 'text' => 'text', + ]); + $this->assertNull($translation); + } + + /** + * @test + */ + public function test_item_is_required() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => '', + 'text' => 'text', + ]); + $this->assertNull($translation); + } + + /** + * @test + */ + public function test_text_not_required() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => '', + ]); + $this->assertNotNull($translation); + $this->assertTrue($translation->exists()); + } + + /** + * @test + */ + public function test_cannot_repeat_same_code_on_same_language() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->assertNotNull($translation); + $this->assertTrue($translation->exists()); + + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $this->assertNull($translation); + } + + /** + * @test + */ + public function test_update_works() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + + $this->assertTrue($this->translationRepository->update($translation->id, 'new text')); + + $translation = $this->translationRepository->find($translation->id); + + $this->assertNotNull($translation); + $this->assertEquals('new text', $translation->text); + $this->assertFalse($translation->isLocked()); + } + + /** + * @test + */ + public function test_update_and_lock() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + + $this->assertTrue($this->translationRepository->updateAndLock($translation->id, 'new text')); + + $translation = $this->translationRepository->find($translation->id); + + $this->assertNotNull($translation); + $this->assertEquals('new text', $translation->text); + $this->assertTrue($translation->isLocked()); + } + + /** + * @test + */ + public function test_update_fails_if_lock() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $translation->lock(); + $translation->save(); + + $this->assertFalse($this->translationRepository->update($translation->id, 'new text')); + } + + /** + * @test + */ + public function test_force_update() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $translation->lock(); + $translation->save(); + + $this->assertTrue($this->translationRepository->updateAndLock($translation->id, 'new text')); + + $translation = $this->translationRepository->find($translation->id); + + $this->assertNotNull($translation); + $this->assertEquals('new text', $translation->text); + $this->assertTrue($translation->isLocked()); + } + + /** + * @test + */ + public function test_delete() + { + $translation = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $translation2 = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item2', + 'text' => 'text', + ]); + $this->assertEquals(2, $this->translationRepository->count()); + $this->translationRepository->delete($translation->id); + $this->assertEquals(1, $this->translationRepository->count()); + } + + /** + * @test + */ + public function it_deletes_other_locales_if_default() + { + $translation = $this->translationRepository->create([ + 'locale' => 'en', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $translation2 = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item', + 'text' => 'text', + ]); + $translation3 = $this->translationRepository->create([ + 'locale' => 'es', + 'namespace' => '*', + 'group' => 'group', + 'item' => 'item2', + 'text' => 'text', + ]); + $this->assertEquals(3, $this->translationRepository->count()); + $this->translationRepository->delete($translation->id); + $this->assertEquals(1, $this->translationRepository->count()); + } + + /** + * @test + */ + public function it_loads_arrays() + { + $array = [ + 'simple' => 'Simple', + 'group' => [ + 'item' => 'Item', + 'meti' => 'metI', + ], + ]; + $this->translationRepository->loadArray($array, 'en', 'file'); + + $translations = $this->translationRepository->all(); + + $this->assertEquals(3, $translations->count()); + + $this->assertEquals('en', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('file', $translations[0]->group); + $this->assertEquals('simple', $translations[0]->item); + $this->assertEquals('Simple', $translations[0]->text); + + $this->assertEquals('en', $translations[1]->locale); + $this->assertEquals('*', $translations[1]->namespace); + $this->assertEquals('file', $translations[1]->group); + $this->assertEquals('group.item', $translations[1]->item); + $this->assertEquals('Item', $translations[1]->text); + + $this->assertEquals('en', $translations[2]->locale); + $this->assertEquals('*', $translations[2]->namespace); + $this->assertEquals('file', $translations[2]->group); + $this->assertEquals('group.meti', $translations[2]->item); + $this->assertEquals('metI', $translations[2]->text); + } + + /** + * @test + */ + public function load_arrays_does_not_overwrite_locked_translations() + { + $array = [ + 'simple' => 'Simple', + 'group' => [ + 'item' => 'Item', + 'meti' => 'metI', + ], + ]; + $this->translationRepository->loadArray($array, 'en', 'file'); + $this->translationRepository->updateAndLock(1, 'Complex'); + $this->translationRepository->loadArray($array, 'en', 'file'); + + $translations = $this->translationRepository->all(); + + $this->assertEquals(3, $translations->count()); + + $this->assertEquals('en', $translations[0]->locale); + $this->assertEquals('*', $translations[0]->namespace); + $this->assertEquals('file', $translations[0]->group); + $this->assertEquals('simple', $translations[0]->item); + $this->assertEquals('Complex', $translations[0]->text); + } + + /** + * @test + */ + public function it_picks_a_random_untranslated_entry() + { + $array = ['simple' => 'Simple']; + $this->translationRepository->loadArray($array, 'en', 'file'); + + $translation = $this->translationRepository->randomUntranslated('es'); + $this->assertNotNull($translation); + } + + /** + * @test + */ + public function it_lists_all_untranslated_entries() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'en', 'file'); + $array = ['simple' => 'Simple']; + $this->translationRepository->loadArray($array, 'es', 'file'); + + $translations = $this->translationRepository->untranslated('es'); + $this->assertNotNull($translations); + $this->assertEquals(1, $translations->count()); + $this->assertEquals('Complex', $translations[0]->text); + } + + /** + * @test + */ + public function it_finds_by_code() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'en', 'file'); + $translation = $this->translationRepository->findByCode('en', '*', 'file', 'complex'); + $this->assertNotNull($translation); + $this->assertEquals('Complex', $translation->text); + } + + /** + * @test + */ + public function it_gets_all_items_in_a_group() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'en', 'file'); + $array = ['test2' => 'test']; + $this->translationRepository->loadArray($array, 'en', 'file2'); + + $translations = $this->translationRepository->getItems('en', '*', 'file'); + $this->assertNotNull($translations); + $this->assertEquals(2, count($translations)); + $this->assertEquals('simple', $translations[1]['item']); + $this->assertEquals('Simple', $translations[1]['text']); + $this->assertEquals('complex', $translations[0]['item']); + $this->assertEquals('Complex', $translations[0]['text']); + } + + /** + * @test + */ + public function it_flag_as_unstable() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'es', 'file'); + + $this->translationRepository->flagAsUnstable('*', 'file', 'complex'); + + $translations = $this->translationRepository->pendingReview('es'); + $this->assertEquals(1, $translations->count()); + $this->assertEquals('Complex', $translations[0]->text); + } + + /** + * @test + */ + public function it_searches_by_code_fragment() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'es', 'file', 'namespace'); + $array = ['test' => '2', 'hhh' => 'Juan']; + $this->translationRepository->loadArray($array, 'es', 'fichero'); + + $this->assertEquals(2, $this->translationRepository->search('es', 'space::')->count()); + $this->assertEquals(1, $this->translationRepository->search('es', 'Juan')->count()); + $this->assertEquals(1, $this->translationRepository->search('es', 'st.2')->count()); + $this->assertEquals(0, $this->translationRepository->search('es', 'ple.2')->count()); + } + + /** + * @test + */ + public function it_translates_text() + { + $array = ['lang' => 'Castellano', 'multi' => 'Multiple', 'multi2' => 'Multiple']; + $this->translationRepository->loadArray($array, 'es', 'file'); + $array = ['lang' => 'English', 'other' => 'Random', 'multi' => 'Multi', 'multi2' => 'Many']; + $this->translationRepository->loadArray($array, 'en', 'file'); + + $this->assertEquals(['Castellano'], $this->translationRepository->translateText('English', 'en', 'es')); + $this->assertEquals(['English'], $this->translationRepository->translateText('Castellano', 'es', 'en')); + $this->assertEquals([], $this->translationRepository->translateText('Complex', 'en', 'es')); + $this->assertEquals(['Multi', 'Many'], $this->translationRepository->translateText('Multiple', 'es', 'en')); + } + + /** + * @test + */ + public function test_flag_as_reviewed() + { + $array = ['simple' => 'Simple', 'complex' => 'Complex']; + $this->translationRepository->loadArray($array, 'es', 'file'); + + $this->translationRepository->flagAsUnstable('*', 'file', 'complex'); + $translations = $this->translationRepository->pendingReview('es'); + $this->assertEquals(1, $translations->count()); + $this->translationRepository->flagAsReviewed(2); + $translations = $this->translationRepository->pendingReview('es'); + $this->assertEquals(0, $translations->count()); + } +} diff --git a/tests/Routes/ResourceRouteTest.php b/tests/Routes/ResourceRouteTest.php new file mode 100644 index 0000000..9a3ddbe --- /dev/null +++ b/tests/Routes/ResourceRouteTest.php @@ -0,0 +1,96 @@ +languageRepository = Mockery::mock(LanguageRepository::class); + $this->router = Mockery::mock(Router::class); + $this->registrar = new ResourceRegistrar($this->router, $this->languageRepository); + } + + protected function getMethod() + { + // Set the method to public for testing + $class = new \ReflectionClass(ResourceRegistrar::class); + $method = $class->getMethod('getGroupResourceName'); + $method->setAccessible(true); + return $method; + } + + public function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @test + */ + public function test_group_resource_name_filters_out_locales() + { + $this->router->shouldReceive('getLastGroupPrefix')->andReturn('en/admin/blog'); + $this->languageRepository->shouldReceive('availableLocales')->andReturn(['en', 'es']); + $method = $this->getMethod(); + $result = $method->invoke($this->registrar, '', 'post', 'index'); + $this->assertEquals('admin.blog.post.index', $result); + } + + /** + * @test + */ + public function test_group_resource_name_doesnt_mess_with_prefixes_containing_part_of_the_locale() + { + $this->router->shouldReceive('getLastGroupPrefix')->andReturn('en/enabled/enabler'); + $this->languageRepository->shouldReceive('availableLocales')->andReturn(['en', 'es']); + $method = $this->getMethod(); + $result = $method->invoke($this->registrar, '', 'women', 'index'); + $this->assertEquals('enabled.enabler.women.index', $result); + } + + /** + * @test + */ + public function test_only_locale_prefix() + { + $this->router->shouldReceive('getLastGroupPrefix')->andReturn('en'); + $this->languageRepository->shouldReceive('availableLocales')->andReturn(['en', 'es']); + $method = $this->getMethod(); + $result = $method->invoke($this->registrar, '', 'post', 'index'); + $this->assertEquals('post.index', $result); + } + + /** + * @test + */ + public function test_no_locale_prefix() + { + $this->router->shouldReceive('getLastGroupPrefix')->andReturn('admin'); + $this->languageRepository->shouldReceive('availableLocales')->andReturn(['en', 'es']); + $method = $this->getMethod(); + $result = $method->invoke($this->registrar, '', 'post', 'index'); + $this->assertEquals('admin.post.index', $result); + } + + /** + * @test + */ + public function test_no_prefix() + { + $this->router->shouldReceive('getLastGroupPrefix')->andReturn(''); + $this->languageRepository->shouldReceive('availableLocales')->andReturn(['en', 'es']); + $method = $this->getMethod(); + $result = $method->invoke($this->registrar, '', 'post', 'index'); + $this->assertEquals('post.index', $result); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..1782e05 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,106 @@ +app['cache']->clear(); + $this->setUpDatabase($this->app); + $this->setUpRoutes($this->app); + } + + /** + * @param \Illuminate\Foundation\Application $app + * + * @return array + */ + protected function getPackageProviders($app) + { + return [ + \Waavi\Translation\TranslationServiceProvider::class, + ]; + } + + /** + * @param $app + */ + protected function getPackageAliases($app) + { + return [ + 'UriLocalizer' => \Waavi\Translation\Facades\UriLocalizer::class, + 'TranslationCache' => \Waavi\Translation\Facades\TranslationCache::class, + ]; + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('app.key', 'sF5r4kJy5HEcOEx3NWxUcYj1zLZLHxuu'); + $app['config']->set('translator.source', 'database'); + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function setUpDatabase($app) + { + $this->artisan('migrate'); + // Seed the spanish and english languages + $languageRepository = \App::make(LanguageRepository::class); + $languageRepository->create(['locale' => 'en', 'name' => 'English']); + $languageRepository->create(['locale' => 'es', 'name' => 'Spanish']); + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function setUpRoutes($app) + { + \Route::get('/', ['middleware' => 'localize', function () { + return 'Whoops'; + }]); + \Route::get('/ca', ['middleware' => 'localize', function () { + return 'Whoops ca'; + }]); + \Route::post('/', ['middleware' => 'localize', function () { + return 'POST answer'; + }]); + \Route::get('/es', ['middleware' => 'localize', function () { + return 'Hola mundo'; + }]); + \Route::get('/en', ['middleware' => 'localize', function () { + return 'Hello world'; + }]); + \Route::get('/en/locale', ['middleware' => 'localize', function () { + return \App::getLocale(); + }]); + \Route::get('/es/locale', ['middleware' => 'localize', function () { + return \App::getLocale(); + }]); + \Route::get('/api/v1/en/locale', ['middleware' => 'localize:2', function () { + return \App::getLocale(); + }]); + \Route::get('/api/v1/es/locale', ['middleware' => 'localize:2', function () { + return \App::getLocale(); + }]); + \Route::get('/api/v1/ca/locale', ['middleware' => 'localize:2', function () { + return 'Whoops ca'; + }]); + \Route::post('/welcome', ['middleware' => 'localize', function () { + return trans('welcome.title'); + }]); + } +} diff --git a/tests/Traits/TranslatableTest.php b/tests/Traits/TranslatableTest.php new file mode 100644 index 0000000..4e24037 --- /dev/null +++ b/tests/Traits/TranslatableTest.php @@ -0,0 +1,109 @@ +increments('id'); + $table->string('title')->nullable(); + $table->string('title_translation')->nullable(); + $table->string('slug')->nullable(); + $table->string('text')->nullable(); + $table->string('text_translation')->nullable(); + $table->timestamps(); + }); + $this->languageRepository = \App::make(LanguageRepository::class); + $this->translationRepository = \App::make(TranslationRepository::class); + } + + /** + * @test + */ + public function it_saves_translations() + { + $dummy = new Dummy; + $dummy->title = 'Dummy title'; + $dummy->text = 'Dummy text'; + $saved = $dummy->save() ? true : false; + $this->assertTrue($saved); + $this->assertEquals(1, Dummy::count()); + $this->assertEquals('slug', $dummy->slug); + // Check that there is a language entry in the database: + $titleTranslation = $this->translationRepository->findByLangCode('en', $dummy->translationCodeFor('title')); + $this->assertEquals('Dummy title', $titleTranslation->text); + $this->assertEquals('Dummy title', $dummy->title); + $textTranslation = $this->translationRepository->findByLangCode('en', $dummy->translationCodeFor('text')); + $this->assertEquals('Dummy text', $textTranslation->text); + $this->assertEquals('Dummy text', $dummy->text); + // Delete it: + $deleted = $dummy->delete(); + $this->assertTrue($deleted); + $this->assertEquals(0, Dummy::count()); + $this->assertEquals(0, $this->translationRepository->count()); + } + + /** + * @test + */ + public function it_flushes_cache() + { + $cacheMock = Mockery::mock(\Waavi\Translation\Cache\SimpleRepository::class); + $this->app->bind('translation.cache.repository', function ($app) use ($cacheMock) {return $cacheMock;}); + $cacheMock->shouldReceive('flush')->with('en', 'translatable', '*'); + $dummy = new Dummy; + $dummy->title = 'Dummy title'; + $dummy->text = 'Dummy text'; + $saved = $dummy->save() ? true : false; + $this->assertTrue($saved); + } + + /** + * @test + */ + public function to_array_features_translated_attributes() + { + $dummy = Dummy::create(['title' => 'Dummy title', 'text' => 'Dummy text']); + $this->assertEquals(1, Dummy::count()); + // Change the text on the translation object: + $titleTranslation = $this->translationRepository->findByLangCode('en', $dummy->translationCodeFor('title')); + $titleTranslation->text = 'Translated text'; + $titleTranslation->save(); + // Verify that toArray pulls from the translation and not model's value, and that the _translation attributes are hidden + $this->assertEquals(['title' => 'Translated text', 'text' => 'Dummy text'], $dummy->makeHidden(['created_at', 'updated_at', 'slug', 'id'])->toArray()); + } +} + +class Dummy extends Model +{ + use Translatable; + + /** + * @var array + */ + protected $fillable = ['title', 'text']; + + /** + * @var array + */ + protected $translatableAttributes = ['title', 'text']; + + /** + * @param $value + */ + public function setTitleAttribute($value) + { + $this->attributes['title'] = $value; + $this->attributes['slug'] = 'slug'; + } +} diff --git a/tests/lang/ca/test.php b/tests/lang/ca/test.php new file mode 100644 index 0000000..71660bd --- /dev/null +++ b/tests/lang/ca/test.php @@ -0,0 +1,5 @@ + 'Entry should not be imported', +]; diff --git a/tests/lang/en/auth.php b/tests/lang/en/auth.php new file mode 100644 index 0000000..e885869 --- /dev/null +++ b/tests/lang/en/auth.php @@ -0,0 +1,9 @@ + [ + 'label' => 'Enter your credentials', + 'action' => 'Login', + ], + 'simple' => 'Simple', +]; diff --git a/tests/lang/en/empty.php b/tests/lang/en/empty.php new file mode 100644 index 0000000..caaf090 --- /dev/null +++ b/tests/lang/en/empty.php @@ -0,0 +1,6 @@ + '', + 'emptyArray' => [], +]; diff --git a/tests/lang/en/welcome/page.php b/tests/lang/en/welcome/page.php new file mode 100644 index 0000000..90c8e40 --- /dev/null +++ b/tests/lang/en/welcome/page.php @@ -0,0 +1,5 @@ + 'Welcome to the test suite', +]; diff --git a/tests/lang/es/auth.php b/tests/lang/es/auth.php new file mode 100644 index 0000000..3d02f8a --- /dev/null +++ b/tests/lang/es/auth.php @@ -0,0 +1,8 @@ + [ + //'label' => 'Enter your credentials', + 'action' => 'Identifícate', + ], +]; diff --git a/tests/lang/es/welcome/page.php b/tests/lang/es/welcome/page.php new file mode 100644 index 0000000..9d79136 --- /dev/null +++ b/tests/lang/es/welcome/page.php @@ -0,0 +1,5 @@ + 'Bienvenido', +]; diff --git a/tests/lang/vendor/package/en/example.php b/tests/lang/vendor/package/en/example.php new file mode 100644 index 0000000..1e403c7 --- /dev/null +++ b/tests/lang/vendor/package/en/example.php @@ -0,0 +1,5 @@ + 'Vendor text', +]; diff --git a/tests/lang/vendor/package/es/example.php b/tests/lang/vendor/package/es/example.php new file mode 100644 index 0000000..9be6ca5 --- /dev/null +++ b/tests/lang/vendor/package/es/example.php @@ -0,0 +1,5 @@ + 'Texto proveedor', +];