diff --git a/.gitignore b/.gitignore index dfd6caa..0de6d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /vendor -composer.lock \ No newline at end of file +composer.lock +.idea/ +docker-compose.yml diff --git a/README.md b/README.md index 1fdf6ca..2537c7b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,30 @@ # laravel-google-translate -Translate translation files (under /resources/lang) or lang.json files from specified base locale to other languages using stichoza/google-translate-php or Google Translate API https://cloud.google.com/translate/ + +* Translate translation files (under /resources/lang) or lang.json files +* Provide extra facade functions Str::apiTranslate and Str::apiTranslateWithAttributes + +by using stichoza/google-translate-php or Google Translate API https://cloud.google.com/translate/ or Yandex Translatin API https://tech.yandex.com/translate/ + +## Str facade api-translation helpers +This package provides two translation methods for Laravel helper Str +* `Illuminate\Support\Str::apiTranslate` -> Translates texts using your selected api in config +* `Illuminate\Support\Str::apiTranslateWithAttributes` -> Again translates texts using your selected api in config + in addition to that this function ***respects Laravel translation text attributes*** like :name + +## how to use your own translation api + +* Create your own translation api class by implementing Tanmuhittin\LaravelGoogleTranslate\Contracts\ApiTranslatorContract +* Write your classname in config laravel_google_translate.custom_api_translator . Example : Myclass::class +* Write your custom apikey for your custom class in laravel_google_translate.custom_api_translator_key + +Now all translations will use your custom api. ## this project needs refactor Project code needs to be designed much better. Refactoring is in progress in refactor-code branch ## installation ```console -composer require tanmuhittin/laravel-google-translate --dev +composer require tanmuhittin/laravel-google-translate php artisan vendor:publish --provider="Tanmuhittin\LaravelGoogleTranslate\LaravelGoogleTranslateServiceProvider" ``` @@ -37,12 +55,7 @@ This package can be used with https://github.com/andrey-helldar/laravel-lang-pub * Add base Laravel translation files using https://github.com/andrey-helldar/laravel-lang-publisher * Translate your custom files using this package -Done
- -## todo -* Handle vendor translations too -* Prepare Web Interface -* Add other translation API support (Bing, Yandex...) +Done ## finally Thank you for using laravel-google-translate :) diff --git a/composer.json b/composer.json index 9e92086..f601624 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,10 @@ "php": ">=7.0.0", "illuminate/support": "^5.5|^6|^7", "illuminate/translation": "^5.5|^6|^7", - "stichoza/google-translate-php": "^4.0" + "stichoza/google-translate-php": "^4.0", + "google/cloud-translate": "dev-master", + "yandex/translate-api": "dev-master", + "ext-json": "*" }, "extra": { "laravel": { @@ -31,9 +34,10 @@ "email": "tanmuhittin@gmail.com" } ], - "minimum-stability": "dev", "require-dev": { - "phpunit/phpunit": "^8.3@dev", - "ext-json": "*" - } + "phpunit/phpunit": "^8.3", + "orchestra/testbench": "5.x-dev" + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/Api/GoogleApiTranslate.php b/src/Api/GoogleApiTranslate.php new file mode 100644 index 0000000..a507d9c --- /dev/null +++ b/src/Api/GoogleApiTranslate.php @@ -0,0 +1,34 @@ +handle = new TranslateClient([ + 'key' => $api_key + ]); + + } + + public function translate(string $text, string $locale, string $base_locale): string + { + if (is_null($base_locale)) + $result = $this->handle->translate($text, [ + 'target' => $locale + ]); + else + $result = $this->handle->translate($text, [ + 'source' => $base_locale, + 'target' => $locale + ]); + + return $result['text']; + } +} diff --git a/src/Api/StichozaApiTranslate.php b/src/Api/StichozaApiTranslate.php new file mode 100644 index 0000000..aed9277 --- /dev/null +++ b/src/Api/StichozaApiTranslate.php @@ -0,0 +1,35 @@ +handle = new GoogleTranslate(); + } + + public function translate(string $text, string $locale, string $base_locale): string + { + if (is_null($base_locale)) + $this->handle->setSource(); + else + $this->handle->setSource($base_locale); + $this->handle->setTarget($locale); + try { + return $this->handle->translate($text); + } catch (\ErrorException $e) { + return false; + } + } +} diff --git a/src/Api/YandexApiTranslate.php b/src/Api/YandexApiTranslate.php new file mode 100644 index 0000000..ca3cdd1 --- /dev/null +++ b/src/Api/YandexApiTranslate.php @@ -0,0 +1,28 @@ +handle = new \Yandex\Translate\Translator($api_key); + + } + + public function translate(string $text, string $locale, string $base_locale): string + { + try { + $translation = $this->handle->translate($text, $base_locale . '-' . $locale); + } catch (\Exception $e) { + return false; + } + return $translation['text'][0]; //todo test if works Yandex code is old + } +} diff --git a/src/ApiTranslateWithAttribute.php b/src/ApiTranslateWithAttribute.php new file mode 100644 index 0000000..5b8ccae --- /dev/null +++ b/src/ApiTranslateWithAttribute.php @@ -0,0 +1,106 @@ +translator = resolve(ApiTranslatorContract::class); + } + + /** + * Holds the logic for replacing laravel translation attributes like :attribute + * @param $base_locale + * @param $locale + * @param $text + * @return mixed|string + */ + public function translate($text, $locale, $base_locale = null) + { + $this->api_limit_check(); + + $text = $this->pre_handle_parameters($text); + + $translated = $this->translator->translate($text, $locale, $base_locale); + + $translated = $this->post_handle_parameters($translated); + + return $translated; + } + + /** + * Check if the API request limit reached. + */ + private function api_limit_check() + { + if ($this->request_count >= $this->request_per_sec) { + sleep($this->sleep_for_sec); + $this->request_count = 0; + } + $this->request_count++; + } + + + private function find_parameters($text) + { + preg_match_all("/(^:|([\s|\:])\:)([a-zA-z])+/", $text, $matches); + return $matches[0]; + } + + + private function replace_parameters_with_placeholders($text, $parameters) + { + $parameter_map = []; + $i = 1; + foreach ($parameters as $match) { + $parameter_map ["x" . $i] = $match; + $text = str_replace($match, " x" . $i, $text); + $i++; + } + return ['parameter_map' => $parameter_map, 'text' => $text]; + } + + private function pre_handle_parameters($text) + { + $parameters = $this->find_parameters($text); + $replaced_text_and_parameter_map = $this->replace_parameters_with_placeholders($text, $parameters); + $this->parameter_map = $replaced_text_and_parameter_map['parameter_map']; + return $replaced_text_and_parameter_map['text']; + } + + /** + * Put back parameters to translated text + * @param $text + * @return mixed + */ + private function post_handle_parameters($text) + { + foreach ($this->parameter_map as $key => $attribute) { + $combinations = [ + $key, + substr($key, 0, 1) . " " . substr($key, 1), + strtoupper(substr($key, 0, 1)) . " " . substr($key, 1), + strtoupper(substr($key, 0, 1)) . substr($key, 1) + ]; + foreach ($combinations as $combination) { + $text = str_replace($combination, $attribute, $text, $count); + if ($count > 0) + break; + } + } + return str_replace(" :", " :", $text); + } +} diff --git a/src/Commands/TranslateFilesCommand.php b/src/Commands/TranslateFilesCommand.php index 99788b4..8470f71 100644 --- a/src/Commands/TranslateFilesCommand.php +++ b/src/Commands/TranslateFilesCommand.php @@ -3,15 +3,12 @@ namespace Tanmuhittin\LaravelGoogleTranslate\Commands; use Illuminate\Console\Command; -use Stichoza\GoogleTranslate\GoogleTranslate; -use Symfony\Component\Finder\Finder; +use Tanmuhittin\LaravelGoogleTranslate\TranslationFileTranslators\JsonArrayFileTranslator; +use Tanmuhittin\LaravelGoogleTranslate\TranslationFileTranslators\PhpArrayFileTranslator; +use Tanmuhittin\LaravelGoogleTranslate\Contracts\ApiTranslatorContract; class TranslateFilesCommand extends Command { - public static $request_count = 0; - public static $request_per_sec = 5; - public static $sleep_for_sec = 1; - public $base_locale; public $locales; public $excluded_files; @@ -19,6 +16,8 @@ class TranslateFilesCommand extends Command public $json; public $force; public $verbose; + + protected $translator; /** * The name and signature of the console command. * @@ -35,13 +34,14 @@ class TranslateFilesCommand extends Command /** * TranslateFilesCommand constructor. + * @param ApiTranslatorContract $translator * @param string $base_locale * @param string $locales + * @param string $target_files * @param bool $force * @param bool $json - * @param string $target_files - * @param string $excluded_files * @param bool $verbose + * @param string $excluded_files */ public function __construct($base_locale = 'en', $locales = 'tr,it', $target_files = '', $force = false, $json = false, $verbose = true, $excluded_files = 'auth,pagination,validation,passwords') { @@ -61,54 +61,46 @@ public function __construct($base_locale = 'en', $locales = 'tr,it', $target_fil public function handle() { //Collect input - $this->base_locale = $this->ask('What is base locale?',config('app.locale','en')); - $this->locales = array_filter(explode(",", $this->ask('What are the target locales? Comma seperate each lang key','tr,it'))); - $should_force = $this->choice('Force overwrite existing translations?',['No','Yes'],'No'); + $this->base_locale = $this->ask('What is base locale?', config('app.locale', 'en')); + $this->locales = array_filter(explode(",", $this->ask('What are the target locales? Comma seperate each lang key', 'tr,it'))); + $should_force = $this->choice('Force overwrite existing translations?', ['No', 'Yes'], 'No'); $this->force = false; - if($should_force === 'Yes'){ + if ($should_force === 'Yes') { $this->force = true; } - $mode = $this->choice('Use text exploration and json translation or php files?',['json','php'],'php'); + $should_verbose = $this->choice('Verbose each translation?', ['No', 'Yes'], 'Yes'); + $this->verbose = false; + if ($should_verbose === 'Yes') { + $this->verbose = true; + } + $mode = $this->choice('Use text exploration and json translation or php files?', ['json', 'php'], 'php'); $this->json = false; - if($mode === 'json'){ + if ($mode === 'json') { $this->json = true; + $file_translator = new JsonArrayFileTranslator($this->base_locale, $this->verbose, $this->force); } - if(!$this->json){ - $this->target_files = array_filter(explode(",", $this->ask('Are there specific target files to translate only? ex: file1,file2',''))); - foreach ($this->target_files as $key=>$target_file){ - $this->target_files[$key] = $target_file.'.php'; + else { + $file_translator = new PhpArrayFileTranslator($this->base_locale, $this->verbose, $this->force); + $this->target_files = array_filter(explode(",", $this->ask('Are there specific target files to translate only? ex: file1,file2', ''))); + foreach ($this->target_files as $key => $target_file) { + $this->target_files[$key] = $target_file . '.php'; } - $this->excluded_files = array_filter(explode(",", $this->ask('Are there specific files to exclude?','auth,pagination,validation,passwords'))); - } - $should_verbose = $this->choice('Verbose each translation?',['No','Yes'],'Yes'); - $this->verbose = false; - if($should_verbose === 'Yes'){ - $this->verbose = true; + $file_translator->setTargetFiles($this->target_files); + $this->excluded_files = array_filter(explode(",", $this->ask('Are there specific files to exclude?', 'auth,pagination,validation,passwords'))); + $file_translator->setExcludedFiles($this->excluded_files); } //Start Translating $bar = $this->output->createProgressBar(count($this->locales)); $bar->start(); $this->line(""); // loop target locales - if($this->json){ - $this->line("Exploring strings..."); - $stringKeys = $this->explore_strings(); - $this->line('Exploration completed. Let\'s get started'); - } foreach ($this->locales as $locale) { if ($locale == $this->base_locale) { continue; } $this->line($this->base_locale . " -> " . $locale . " translating..."); - if($this->json){ - $this->translate_json_array_file($locale,$stringKeys); - } - else if ($locale !== 'vendor') { - if(!is_dir(resource_path('lang/' . $locale))){ - mkdir(resource_path('lang/' . $locale)); - } - $this->translate_php_array_files($locale); - } + $file_translator->handle($locale); + $this->line($this->base_locale . " -> " . $locale . " translated."); $bar->advance(); $this->line(""); } @@ -116,298 +108,4 @@ public function handle() $this->line(""); $this->line("Translations Completed."); } - - /** - * @param $base_locale - * @param $locale - * @param $text - * @return mixed|null|string - * @throws \ErrorException - * @throws \Exception - */ - public static function translate($base_locale, $locale, $text) - { - preg_match_all("/(^:|([\s|\:])\:)([a-zA-z])+/",$text,$matches); - $parameter_map = []; - $i = 1; - foreach($matches[0] as $match){ - $parameter_map ["x".$i]= $match; - $text = str_replace($match," x".$i,$text); - $i++; - } - - // Check if the API request limit reached. - if( self::$request_count >= self::$request_per_sec ){ - sleep(self::$sleep_for_sec); - self::$request_count = 0; //Reset the $request_count - } - self::$request_count++; //Increase the request_count by 1 - - if(config('laravel_google_translate.google_translate_api_key', false)){ - $translated = self::translate_via_api_key($base_locale, $locale, $text); - }else{ - $translated = self::translate_via_stichoza($base_locale, $locale, $text); - } - foreach ($parameter_map as $key=>$attribute){ - $combinations = [ - $key, - substr($key,0,1)." ".substr($key,1), - strtoupper(substr($key,0,1))." ".substr($key,1), - strtoupper(substr($key,0,1)).substr($key,1) - ]; - foreach ($combinations as $combination){ - $translated = str_replace($combination,$attribute,$translated,$count); - if($count > 0) - break; - } - } - $translated = str_replace(" :"," :",$translated); - return $translated; - } - - /** - * @param $base_locale - * @param $locale - * @param $text - * @return null|string - * @throws \ErrorException - */ - private static function translate_via_stichoza($base_locale, $locale, $text){ - $tr = new GoogleTranslate(); - $tr->setSource($base_locale); - $tr->setTarget($locale); - return $tr->translate($text); - } - - /** - * @param $base_locale - * @param $locale - * @param $text - * @return mixed - * @throws \Exception - */ - private static function translate_via_api_key($base_locale, $locale, $text){ - $apiKey = config('laravel_google_translate.google_translate_api_key', false); - $url = 'https://www.googleapis.com/language/translate/v2?key=' . $apiKey . '&q=' . rawurlencode($text) . '&source=' . substr($base_locale, 0, strpos($base_locale."_", "_")) . '&target=' . substr($locale, 0, strpos($locale."_", "_")); - $handle = curl_init(); - curl_setopt($handle, CURLOPT_URL, $url); - curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); - $response = curl_exec($handle); - if ($response === false) { - throw new \Exception(curl_error($handle), curl_errno($handle)); - } - $responseDecoded = json_decode($response, true); - curl_close($handle); - - if (isset($responseDecoded['error'])) { - /*$this->error("Google Translate API returned error"); - if (isset($responseDecoded["error"]["message"])) { - $this->error($responseDecoded["error"]["message"]); - }*/ - var_dump($responseDecoded); - exit; - } - - return $responseDecoded['data']['translations'][0]['translatedText']; - } - - /** - * @param $locale - * @throws \Exception - */ - public function translate_php_array_files($locale) - { - $files = preg_grep('/^([^.])/', scandir(resource_path('lang/' . $this->base_locale))); - - if (count($this->target_files) > 0) { - $files = $this->target_files; - } - foreach ($files as $file) { - $file = substr($file, 0, -4); - $already_translateds = []; - if (file_exists(resource_path('lang/' . $locale . '/' . $file . '.php'))) { - if($this->verbose) - $this->line('File already exists: lang/' . $locale . '/' . $file . '.php. Checking missing translations'); - $already_translateds = trans($file, [], $locale); - } - if (in_array($file, $this->excluded_files)) { - continue; - } - $to_be_translateds = trans($file, [], $this->base_locale); - $new_lang = []; - if(is_array($to_be_translateds)){ - $new_lang = $this->skipMultidensional($to_be_translateds, $already_translateds, $locale); - } - //save new lang to new file - if(!file_exists(resource_path('lang/' . $locale ))){ - mkdir(resource_path('lang/' . $locale )); - } - $file = fopen(resource_path('lang/' . $locale . '/' . $file . '.php'), "w+"); - $write_text = " - * - * @param array $to_be_translateds - * @param array $already_translateds - * @param String $locale - * - * @return array - */ - private function skipMultidensional($to_be_translateds, $already_translateds, $locale){ - $data = []; - foreach($to_be_translateds as $key => $to_be_translated){ - if ( is_array($to_be_translateds[$key]) ) { - if( !isset($already_translateds[$key]) ) { - $already_translateds[$key] = []; - } - $data[$key] = $this->skipMultidensional($to_be_translateds[$key], $already_translateds[$key], $locale); - } else { - if ( isset($already_translateds[$key]) && $already_translateds[$key] != '' && !$this->force) { - $data[$key] = $already_translateds[$key]; - if ($this->verbose) { - $this->line('Exists Skipping -> ' . $to_be_translated . ' : ' . $data[$key]); - } - continue; - } else { - $data[$key] = $this->translate_attribute($to_be_translated,$locale); - } - } - } - return $data; - } - - private function translate_attribute($attribute,$locale){ - if(is_array($attribute)){ - $return = []; - foreach ($attribute as $k => $t){ - $return[$k] = $this->translate_attribute($t,$locale); - } - return $return; - }else{ - $translated = self::translate($this->base_locale, $locale, $attribute); - if ($this->verbose) { - $this->line($attribute . ' : ' . $translated); - } - return $translated; - } - } - - /** - * @return array - */ - public function explore_strings(){ - $groupKeys = []; - $stringKeys = []; - $functions = config('laravel_google_translate.trans_functions', [ - 'trans', - 'trans_choice', - 'Lang::get', - 'Lang::choice', - 'Lang::trans', - 'Lang::transChoice', - '@lang', - '@choice', - '__', - '\$trans.get', - '\$t' - ]); - $groupPattern = // See https://regex101.com/r/WEJqdL/6 - "[^\w|>]" . // Must not have an alphanum or _ or > before real method - '(' . implode( '|', $functions ) . ')' . // Must start with one of the functions - "\(" . // Match opening parenthesis - "[\'\"]" . // Match " or ' - '(' . // Start a new group to match: - '[a-zA-Z0-9_-]+' . // Must start with group - "([.](?! )[^\1)]+)+" . // Be followed by one or more items/keys - ')' . // Close group - "[\'\"]" . // Closing quote - "[\),]"; // Close parentheses or new parameter - $stringPattern = - "[^\w]" . // Must not have an alphanum before real method - '(' . implode( '|', $functions ) . ')' . // Must start with one of the functions - "\(" . // Match opening parenthesis - "(?P['\"])" . // Match " or ' and store in {quote} - "(?P(?:\\\k{quote}|(?!\k{quote}).)*)" . // Match any string that can be {quote} escaped - "\k{quote}" . // Match " or ' previously matched - "[\),]"; // Close parentheses or new parameter - $finder = new Finder(); - $finder->in( base_path() )->exclude( 'storage' )->exclude( 'vendor' )->name( '*.php' )->name( '*.twig' )->name( '*.vue' )->files(); - /** @var \Symfony\Component\Finder\SplFileInfo $file */ - foreach ( $finder as $file ) { - // Search the current file for the pattern - if ( preg_match_all( "/$groupPattern/siU", $file->getContents(), $matches ) ) { - // Get all matches - foreach ( $matches[ 2 ] as $key ) { - $groupKeys[] = $key; - } - } - if ( preg_match_all( "/$stringPattern/siU", $file->getContents(), $matches ) ) { - foreach ( $matches[ 'string' ] as $key ) { - if ( preg_match( "/(^[a-zA-Z0-9_-]+([.][^\1)\ ]+)+$)/siU", $key, $groupMatches ) ) { - // group{.group}.key format, already in $groupKeys but also matched here - // do nothing, it has to be treated as a group - continue; - } - //TODO: This can probably be done in the regex, but I couldn't do it. - //skip keys which contain namespacing characters, unless they also contain a - //space, which makes it JSON. - if ( !( mb_strpos( $key, '::' ) !== FALSE && mb_strpos( $key, '.' ) !== FALSE ) - || mb_strpos( $key, ' ' ) !== FALSE ) { - $stringKeys[] = $key; - if($this->verbose){ - $this->line('Found : '.$key); - } - } - } - } - } - // Remove duplicates - $groupKeys = array_unique( $groupKeys ); // todo: not supporting group keys for now add this feature! - $stringKeys = array_unique( $stringKeys ); - return $stringKeys; - } - - /** - * @param $locale - * @param $stringKeys - * @throws \ErrorException - * @throws \Exception - */ - public function translate_json_array_file($locale,$stringKeys) - { - $new_lang = []; - $json_existing_translations = []; - if(file_exists(resource_path('lang/' . $locale . '.json'))){ - $json_translations_string = file_get_contents(resource_path('lang/' . $locale . '.json')); - $json_existing_translations = json_decode($json_translations_string, true); - } - foreach ($stringKeys as $to_be_translated){ - //check existing translations - if(isset($json_existing_translations[$to_be_translated]) && - $json_existing_translations[$to_be_translated]!='' && - !$this->force) - { - $new_lang[$to_be_translated] = $json_existing_translations[$to_be_translated]; - if($this->verbose) - $this->line('Exists Skipping -> ' . $to_be_translated . ' : ' . $new_lang[$to_be_translated]); - continue; - } - $new_lang[$to_be_translated] = addslashes(self::translate($this->base_locale, $locale, $to_be_translated)); - if ($this->verbose) { - $this->line($to_be_translated . ' : ' . $new_lang[$to_be_translated]); - } - } - $file = fopen(resource_path('lang/' . $locale . '.json'), "w+"); - $write_text = json_encode($new_lang, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); - fwrite($file, $write_text); - fclose($file); - } } diff --git a/src/Contracts/ApiTranslatorContract.php b/src/Contracts/ApiTranslatorContract.php new file mode 100644 index 0000000..9cb0e30 --- /dev/null +++ b/src/Contracts/ApiTranslatorContract.php @@ -0,0 +1,24 @@ + $p) { - deleteAll($p); - } - return @rmdir($path); - } -} diff --git a/src/Helpers/ConsoleHelper.php b/src/Helpers/ConsoleHelper.php new file mode 100644 index 0000000..dab869f --- /dev/null +++ b/src/Helpers/ConsoleHelper.php @@ -0,0 +1,13 @@ +verbose) || (isset($this->verbose) && $this->verbose)) + echo $text . "\n"; + } +} diff --git a/src/LaravelGoogleTranslateServiceProvider.php b/src/LaravelGoogleTranslateServiceProvider.php index f9d7026..7bcd01d 100644 --- a/src/LaravelGoogleTranslateServiceProvider.php +++ b/src/LaravelGoogleTranslateServiceProvider.php @@ -3,7 +3,12 @@ namespace Tanmuhittin\LaravelGoogleTranslate; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; +use Tanmuhittin\LaravelGoogleTranslate\Api\GoogleApiTranslate; +use Tanmuhittin\LaravelGoogleTranslate\Api\StichozaApiTranslate; +use Tanmuhittin\LaravelGoogleTranslate\Api\YandexApiTranslate; use Tanmuhittin\LaravelGoogleTranslate\Commands\TranslateFilesCommand; +use Tanmuhittin\LaravelGoogleTranslate\Contracts\ApiTranslatorContract; class LaravelGoogleTranslateServiceProvider extends ServiceProvider { @@ -18,7 +23,7 @@ public function boot() TranslateFilesCommand::class ]); $this->publishes([ - __DIR__.'/laravel_google_translate.php' => config_path('laravel_google_translate.php'), + __DIR__ . '/laravel_google_translate.php' => config_path('laravel_google_translate.php'), ]); } @@ -29,8 +34,43 @@ public function boot() */ public function register() { - if ($this->app['config']->get('laravel_google_translate') === null) { - $this->app['config']->set('laravel_google_translate', require __DIR__.'/laravel_google_translate.php'); - } + $this->app->singleton(ApiTranslatorContract::class, function ($app) { + $config = $app->make('config')->get('laravel_google_translate'); + if ($config['custom_api_translator']!==null){ + $custom_translator = new $config['custom_api_translator']($config['custom_api_translator_key']); + if($custom_translator instanceof ApiTranslatorContract) + return $custom_translator; + else + throw new \Exception($config['custom_api_translator'].' must implement '.ApiTranslatorContract::class); + } + elseif ($config['google_translate_api_key'] !== null) { + return new GoogleApiTranslate($config['google_translate_api_key']); + } elseif ($config['yandex_translate_api_key'] !== null) { + return new YandexApiTranslate($config['yandex_translate_api_key']); + } else { + return new StichozaApiTranslate(null); + } + }); + + Str::macro('apiTranslate', function (string $text, string $locale, string $base_locale = null) { + if ($base_locale === null) { + $config = resolve('config')->get('app'); + if (!is_null($config['locale'])) { + $base_locale = $config['locale']; + } + } + $translator = resolve(ApiTranslatorContract::class); + return $translator->translate($text, $locale, $base_locale); + }); + Str::macro('apiTranslateWithAttributes', function (string $text, string $locale, string $base_locale = null) { + if ($base_locale === null) { + $config = resolve('config')->get('app'); + if (!is_null($config['locale'])) { + $base_locale = $config['locale']; + } + } + $translator = new ApiTranslateWithAttribute; + return $translator->translate($text, $locale, $base_locale); + }); } } diff --git a/src/TranslationFileTranslators/JsonArrayFileTranslator.php b/src/TranslationFileTranslators/JsonArrayFileTranslator.php new file mode 100644 index 0000000..02cf84a --- /dev/null +++ b/src/TranslationFileTranslators/JsonArrayFileTranslator.php @@ -0,0 +1,135 @@ +base_locale = $base_locale; + $this->verbose = $verbose; + $this->force = $force; + } + + public function handle($target_locale) : void + { + $stringKeys = $this->explore_strings(); + $existing_translations = $this->fetch_existing_translations($target_locale); + $translated_strings = []; + foreach ($stringKeys as $to_be_translated) { + //check existing translations + if (isset($existing_translations[$to_be_translated]) && + $existing_translations[$to_be_translated] != '' && + !$this->force) { + $translated_strings[$to_be_translated] = $existing_translations[$to_be_translated]; + $this->line('Exists Skipping -> ' . $to_be_translated . ' : ' . $translated_strings[$to_be_translated]); + continue; + } + $translated_strings[$to_be_translated] = addslashes(Str::apiTranslateWithAttributes($to_be_translated, $target_locale, $this->base_locale)); + $this->line($to_be_translated . ' : ' . $translated_strings[$to_be_translated]); + } + $this->write_translated_strings_to_file($translated_strings, $target_locale); + return; + } + + private function write_translated_strings_to_file($translated_strings,$target_locale){ + $file = fopen(resource_path('lang/' . $target_locale . '.json'), "w+"); + $write_text = json_encode($translated_strings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + fwrite($file, $write_text); + fclose($file); + } + + private function fetch_existing_translations($target_locale){ + $existing_translations = []; + if (file_exists(resource_path('lang/' . $target_locale . '.json'))) { + $json_translations_string = file_get_contents(resource_path('lang/' . $target_locale . '.json')); + $existing_translations = json_decode($json_translations_string, true); + } + return $existing_translations; + } + + /** + * copied from Barryvdh\TranslationManager\Manager findTranslations + * @return array + */ + private function explore_strings() + { + $groupKeys = []; + $stringKeys = []; + $functions = config('laravel_google_translate.trans_functions', [ + 'trans', + 'trans_choice', + 'Lang::get', + 'Lang::choice', + 'Lang::trans', + 'Lang::transChoice', + '@lang', + '@choice', + '__', + '\$trans.get', + '\$t' + ]); + $groupPattern = // See https://regex101.com/r/WEJqdL/6 + "[^\w|>]" . // Must not have an alphanum or _ or > before real method + '(' . implode('|', $functions) . ')' . // Must start with one of the functions + "\(" . // Match opening parenthesis + "[\'\"]" . // Match " or ' + '(' . // Start a new group to match: + '[a-zA-Z0-9_-]+' . // Must start with group + "([.](?! )[^\1)]+)+" . // Be followed by one or more items/keys + ')' . // Close group + "[\'\"]" . // Closing quote + "[\),]"; // Close parentheses or new parameter + $stringPattern = + "[^\w]" . // Must not have an alphanum before real method + '(' . implode('|', $functions) . ')' . // Must start with one of the functions + "\(" . // Match opening parenthesis + "(?P['\"])" . // Match " or ' and store in {quote} + "(?P(?:\\\k{quote}|(?!\k{quote}).)*)" . // Match any string that can be {quote} escaped + "\k{quote}" . // Match " or ' previously matched + "[\),]"; // Close parentheses or new parameter + $finder = new Finder(); + $finder->in(base_path())->exclude('storage')->exclude('vendor')->name('*.php')->name('*.twig')->name('*.vue')->files(); + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($finder as $file) { + // Search the current file for the pattern + if (preg_match_all("/$groupPattern/siU", $file->getContents(), $matches)) { + // Get all matches + foreach ($matches[2] as $key) { + $groupKeys[] = $key; + } + } + if (preg_match_all("/$stringPattern/siU", $file->getContents(), $matches)) { + foreach ($matches['string'] as $key) { + if (preg_match("/(^[a-zA-Z0-9_-]+([.][^\1)\ ]+)+$)/siU", $key, $groupMatches)) { + // group{.group}.key format, already in $groupKeys but also matched here + // do nothing, it has to be treated as a group + continue; + } + //TODO: This can probably be done in the regex, but I couldn't do it. + //skip keys which contain namespacing characters, unless they also contain a + //space, which makes it JSON. + if (!(mb_strpos($key, '::') !== FALSE && mb_strpos($key, '.') !== FALSE) + || mb_strpos($key, ' ') !== FALSE) { + $stringKeys[] = $key; + $this->line('Found : ' . $key); + } + } + } + } + // Remove duplicates + $groupKeys = array_unique($groupKeys); // todo: not supporting group keys for now add this feature! + $stringKeys = array_unique($stringKeys); + return $stringKeys; + } +} diff --git a/src/TranslationFileTranslators/PhpArrayFileTranslator.php b/src/TranslationFileTranslators/PhpArrayFileTranslator.php new file mode 100644 index 0000000..0baa0db --- /dev/null +++ b/src/TranslationFileTranslators/PhpArrayFileTranslator.php @@ -0,0 +1,170 @@ +base_locale = $base_locale; + $this->verbose = $verbose; + $this->force = $force; + } + + public function handle($target_locale) : void + { + $files = $this->get_translation_files(); + $this->create_missing_target_folders($target_locale, $files); + foreach ($files as $file) { + $existing_translations = []; + $file_address = $this->get_language_file_address($target_locale, $file.'.php'); + $this->line($file_address.' is preparing'); + if (file_exists($file_address)) { + $this->line('File already exists'); + $existing_translations = trans($file, [], $target_locale); + $this->line('Existing translations collected'); + } + $to_be_translateds = trans($file, [], $this->base_locale); + $this->line('Source text collected'); + $translations = []; + if (is_array($to_be_translateds)) { + $translations = $this->handleTranslations($to_be_translateds, $existing_translations, $target_locale); + } + $this->write_translations_to_file($target_locale, $file, $translations); + } + return; + } + + // file, folder operations: + + private function create_missing_target_folders($target_locale, $files) + { + $target_locale_folder = $this->get_language_file_address($target_locale); + if(!is_dir($target_locale_folder)){ + mkdir($target_locale_folder); + } + foreach ($files as $file){ + if(Str::contains($file, '/')){ + $folder_address = $this->get_language_file_address($target_locale, Str::of($file)->dirname()); + if(!is_dir($folder_address)){ + mkdir($folder_address, 0777, true); + } + } + } + } + + private function write_translations_to_file($target_locale, $file, $translations){ + $file = fopen($this->get_language_file_address($target_locale, $file.'.php'), "w+"); + $export = var_export($translations, true); + + //use [] notation instead of array() + $patterns = [ + "/array \(/" => '[', + "/^([ ]*)\)(,?)$/m" => '$1]$2', + "/=>[ ]?\n[ ]+\[/" => '=> [', + "/([ ]*)(\'[^\']+\') => ([\[\'])/" => '$1$2 => $3', + ]; + $export = preg_replace(array_keys($patterns), array_values($patterns), $export); + + + $write_text = "target_files) > 0) { + $files = $this->target_files; + } + else{ + $files = []; + $dir_contents = preg_grep('/^([^.])/', scandir($this->get_language_file_address($this->base_locale, $folder))); + foreach ($dir_contents as $dir_content){ + if(!is_null($folder)) + $dir_content = $folder.'/'.$dir_content; + if (in_array($this->strip_php_extension($dir_content), $this->excluded_files)) { + continue; + } + if(is_dir($this->get_language_file_address($this->base_locale, $dir_content))){ + $files = array_merge($files,$this->get_translation_files($dir_content)); + } + else{ + $files[] = $this->strip_php_extension($dir_content); + } + } + } + return $files; + } + + + // in file operations : + + /** + * Walks array recursively to find and translate strings + * + * @param array $to_be_translateds + * @param array $existing_translations + * @param String $target_locale + * + * @return array + */ + private function handleTranslations($to_be_translateds, $existing_translations, $target_locale) + { + $translations = []; + foreach ($to_be_translateds as $key => $to_be_translated) { + if (is_array($to_be_translated)) { + if (!isset($existing_translations[$key])) { + $existing_translations[$key] = []; + } + $translations[$key] = $this->handleTranslations($to_be_translated, $existing_translations[$key], $target_locale); + } else { + if (isset($existing_translations[$key]) && $existing_translations[$key] != '' && !$this->force) { + $translations[$key] = $existing_translations[$key]; + $this->line('Exists Skipping -> ' . $to_be_translated . ' : ' . $translations[$key]); + continue; + } else { + $translations[$key] = Str::apiTranslateWithAttributes($to_be_translated, $target_locale, $this->base_locale); + $this->line($to_be_translated . ' : ' . $translations[$key]); + } + } + } + return $translations; + } + + // others + + public function setTargetFiles($target_files) + { + $this->target_files = $target_files; + } + + public function setExcludedFiles($excluded_files) + { + $this->excluded_files = $excluded_files; + } +} diff --git a/src/laravel_google_translate.php b/src/laravel_google_translate.php index ef134c4..7df08e3 100644 --- a/src/laravel_google_translate.php +++ b/src/laravel_google_translate.php @@ -1,6 +1,9 @@ null, + 'yandex_translate_api_key'=>null, + 'custom_api_translator' => null, + 'custom_api_translator_key' => null, 'trans_functions' => [ 'trans', 'trans_choice', diff --git a/tests/TestCase.php b/tests/TestCase.php index 6d1c4c1..39707ad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,13 @@ namespace Tanmuhittin\LaravelGoogleTranslateTests; -abstract class TestCase extends \PHPUnit\Framework\TestCase +use Tanmuhittin\LaravelGoogleTranslate\LaravelGoogleTranslateServiceProvider; +use Tests\Laravel\App; + +abstract class TestCase extends \Orchestra\Testbench\TestCase { - public function __construct() + protected function getPackageProviders($app) { - parent::__construct(); - include_once __DIR__ . './../src/DeclareFunctionsIfMissing.php'; + return [LaravelGoogleTranslateServiceProvider::class]; } } diff --git a/tests/Unit/CommandTest.php b/tests/Unit/CommandTest.php deleted file mode 100644 index 67ed5ff..0000000 --- a/tests/Unit/CommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -translate_php_array_files('tr'); - $command->translate_php_array_files('it'); - $translated_texts_tr = trans('tests', [], 'tr'); - $translated_texts_it = trans('tests', [], 'it'); - deleteAll(__DIR__ . '/../../test-resources/resources/lang/tr'); - deleteAll(__DIR__ . '/../../test-resources/resources/lang/it'); - $this->assertEquals(count($to_be_translated_texts_sv), count($translated_texts_tr)); - $this->assertEquals(count($to_be_translated_texts_sv), count($translated_texts_it)); - } - - public function testTextExplorationAndJsonTranslationsCommand() - { - $command = new TranslateFilesCommand('en', 'tr,it', '', $force = false, $json = true, $verbose = false, $excluded_files = 'auth,pagination,validation,passwords'); - $stringKeys = $command->explore_strings(); - $command->translate_json_array_file('tr', $stringKeys); - $command->translate_json_array_file('it', $stringKeys); - $tr_translations = json_decode(file_get_contents(__DIR__ . '/../../test-resources/resources/lang/tr.json'), true); - $it_translations = json_decode(file_get_contents(__DIR__ . '/../../test-resources/resources/lang/it.json'), true); - unlink(__DIR__ . '/../../test-resources/resources/lang/tr.json'); - unlink(__DIR__ . '/../../test-resources/resources/lang/it.json'); - $this->assertNotEquals(0, count($tr_translations)); - $this->assertNotEquals(0, count($it_translations)); - } -} diff --git a/tests/Unit/TranslateFilesCommandTest.php b/tests/Unit/TranslateFilesCommandTest.php new file mode 100644 index 0000000..ec3b6b1 --- /dev/null +++ b/tests/Unit/TranslateFilesCommandTest.php @@ -0,0 +1,41 @@ +app->setBasePath(__DIR__.'/../test-resources'); + $this->artisan('translate:files') + ->expectsQuestion('What is base locale?', 'sv') + ->expectsQuestion('What are the target locales? Comma seperate each lang key', 'tr') + ->expectsQuestion('Force overwrite existing translations?','1') + ->expectsQuestion('Verbose each translation?','1') + ->expectsQuestion('Use text exploration and json translation or php files?','php') + ->expectsQuestion('Are there specific target files to translate only? ex: file1,file2','') + ->expectsQuestion('Are there specific files to exclude?','') + ->assertExitCode(0); + $this->assertFileExists(resource_path('lang/tr/tests.php')); + unlink(resource_path('lang/tr/tests.php')); + rmdir(resource_path('lang/tr')); + } + + public function testTranslateJsonFilesCommand() + { + $this->app->setBasePath(__DIR__.'/../test-resources'); + $this->artisan('translate:files') + ->expectsQuestion('What is base locale?', 'sv') + ->expectsQuestion('What are the target locales? Comma seperate each lang key', 'tr') + ->expectsQuestion('Force overwrite existing translations?','Yes') + ->expectsQuestion('Verbose each translation?','Yes') + ->expectsQuestion('Use text exploration and json translation or php files?','json') + ->assertExitCode(0); + $this->assertFileExists(resource_path('lang/tr.json')); + unlink(resource_path('lang/tr.json')); + } +} + diff --git a/tests/Unit/TranslateTest.php b/tests/Unit/TranslateTest.php index 78f0f07..3ba5a69 100644 --- a/tests/Unit/TranslateTest.php +++ b/tests/Unit/TranslateTest.php @@ -2,15 +2,22 @@ namespace Tanmuhittin\LaravelGoogleTranslateTests\Unit; -use Tanmuhittin\LaravelGoogleTranslate\Commands\TranslateFilesCommand; +use Illuminate\Support\Str; use Tanmuhittin\LaravelGoogleTranslateTests\TestCase; class TranslateTest extends TestCase { public function testTranslate() { - $test_text = "Hello :yourname"; - $translated_test_text = TranslateFilesCommand::translate("en", "tr", $test_text); - $this->assertStringContainsString(":yourname", $translated_test_text); + $test_text = 'Hello World'; + $translated_test_text = Str::apiTranslate($test_text, 'tr', 'en'); + $this->assertStringContainsStringIgnoringCase('Dünya', $translated_test_text); + } + + public function testTranslateWithAttributes(){ + $test_text = 'My name is :attribute'; + $translated_test_text = Str::apiTranslateWithAttributes($test_text, 'tr', 'en'); + $this->assertStringContainsString(':attribute', $translated_test_text); } } + diff --git a/test-resources/exploration_files/test.blade.php b/tests/test-resources/exploration-resources/test.blade.php similarity index 100% rename from test-resources/exploration_files/test.blade.php rename to tests/test-resources/exploration-resources/test.blade.php diff --git a/test-resources/exploration_files/test.vue b/tests/test-resources/exploration-resources/test.vue similarity index 100% rename from test-resources/exploration_files/test.vue rename to tests/test-resources/exploration-resources/test.vue diff --git a/test-resources/resources/lang/sv/tests.php b/tests/test-resources/resources/lang/sv/tests.php similarity index 100% rename from test-resources/resources/lang/sv/tests.php rename to tests/test-resources/resources/lang/sv/tests.php