diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61ead86 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/README.md b/README.md index d690baa..ce17fd1 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,99 @@ Библиотека для Laravel, что бы общаться с сервисом перевода от yandex. В будущем (скорей всего в далёком будущем) планирую расширить поддержку разных сервисов перевода. Что бы можно было общаться через один интерфейс с разными сервисами. -Параметры в **.env**: +# Параметры в **.env**: -# TRANSLATE_SERVICE +### TRANSLATE_SERVICE По умолчанию через какой сервис обращаться за переводом. На данный момент доступен только один сервис - **yandex**. -# TRANSLATE_YANDEX_FOLDER_ID +### TRANSLATE_YANDEX_FOLDER_ID ID folder. Код можно увидеть в адресе console.yandex.cloud/folders/**{тут будет код}**. -# TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH +### TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH Абсолютный путь в рамках приложения Laravel к файлу **authorized_key.json**. -Например: положили файл в корень Laravel. Тогда будет путь такой: **/authorized_key.json**. \ No newline at end of file +Например: положили файл в корень Laravel. Тогда будет путь такой: **/authorized_key.json**. + +### TRANSLATE_YANDEX_LIMIT_MAX_REQUEST +Максимальное количество запросов в период, который указан в TRANSLATE_YANDEX_LIMIT_RATE_SECONDS. По умолчанию 20 запросов в секунду. **На данный момент работает только перевод через систему очередей.** + +### TRANSLATE_YANDEX_LIMIT_RATE_SECONDS +Период в котором считается максимальное количество запросов. По умолчанию стоит секунда. **На данный момент работает только перевод через систему очередей.** + +### TRANSLATE_YANDEX_LIMIT_MAX_SYMBOLS +Максимальное количество символов за один запрос. Если превышает, то делится на две части и делает по очереди два запроса. По умолчанию стоит 9000 символов. **На данный момент работает только перевод через систему очередей.** + +# Методы +### Перевести обычный текст +> \KorElf\TranslateLaravel\Facades\Translate::translateText( +>
      string | array \$text, +>
      string            \$targetLanguageCode, +>
      ?string          \$sourceLanguageCode = null +>
): string | array + +**\$text** - текст который нужно перевести. +
**\$targetLanguageCode** - язык на который надо перевести. +
**\$sourceLanguageCode** - язык с которого надо перевести. Если **null**, то сервис сам пытается определить язык. + +
+ +### Перевести текст в формате HTML +> \KorElf\TranslateLaravel\Facades\Translate::translateHtml( +>
      string | array \$text, +>
      string            \$targetLanguageCode, +>
      ?string          \$sourceLanguageCode = null +>
): string | array + +**\$text** - текст который нужно перевести. +
**\$targetLanguageCode** - язык на который надо перевести. +
**\$sourceLanguageCode** - язык с которого надо перевести. Если **null**, то сервис сам пытается определить язык. + +
+ +### Получить список языков, который сервис поддерживает +> \KorElf\TranslateLaravel\Facades\Translate::listLanguages(): \KorElf\TranslateLaravel\DTO\Languages + +
+ +### Перевести с помощью очередей (Queues) +> \KorElf\TranslateLaravel\Facades\Translate::runJob( +>
      \KorElf\TranslateLaravel\DTO\RunTranslateDto                             \$params, +>
      \KorElf\TranslateLaravel\Contracts\TranslationCompletedListener \$completedListener +
): \Illuminate\Foundation\Bus\PendingDispatch + +**\$params** - параметры перевода. +
**\$completedListener** - после завершения перевода отправляет результат в этот объект. Объект должен соблюдать контракт **\KorElf\TranslateLaravel\Contracts\TranslationCompletedListener**. + +**Пример:** +> Создаём файл TranslationListener например в папке app/Services +> +> +> namespace App\Services; +> +> use Illuminate\Support\Facades\Log; +> use KorElf\TranslateLaravel\Contracts\TranslationCompletedListener; +> +> final class TranslationListener implements TranslationCompletedListener +> { +> public function onTranslationCompleted(array $translatedText): void +> { +> foreach ($translatedText as $translatedTextKey => $translatedTextValue) { +> Log::info($translatedTextKey . ': ' . $translatedTextValue); +> } +> } +> } +> +> Потом например в каком-то контроллере (а лучше конечно в сервисе) пишем такой метод +> +> public function sendingForTranslation(\App\Services\TranslationListener $translationListener): View +> { +> // Вначале создаём объект с параметрами +> $params = (new \KorElf\TranslateLaravel\DTO\RunTranslateDto) +> ->addParamText('title', 'Заголовок', 'en', 'ru') +> ->addParamHtml('content', '

Привет, Мир!

', 'en', 'ru'); +> +> // Отправляем на очередь +> \KorElf\TranslateLaravel\Facades\Translate::runJob($params, $translationListener); +> +> return view('success'); +> } diff --git a/composer.json b/composer.json index 292d5fe..23ac26f 100644 --- a/composer.json +++ b/composer.json @@ -21,18 +21,20 @@ "php": "^8.2", "illuminate/support": "^10.0|^11.0", "guzzlehttp/guzzle": "^7.0.1", - "web-token/jwt-framework": "^3.0" + "web-token/jwt-framework": "^3.0", + "ext-libxml": "^2.11.7", + "ext-dom": "^2.11.7" }, "extra": { "laravel": { "providers": [ - "korElf\\TranslateLaravel\\TranslateLaravelProvider" + "KorElf\\TranslateLaravel\\TranslateLaravelProvider" ] } }, "autoload": { "psr-4": { - "korElf\\TranslateLaravel\\": "src/" + "KorElf\\TranslateLaravel\\": "src/" } } } diff --git a/config/translate.php b/config/translate.php index 35f7c4f..641347b 100644 --- a/config/translate.php +++ b/config/translate.php @@ -24,10 +24,15 @@ return [ 'services' => [ 'yandex' => [ - 'driver' => '\korElf\TranslateLaravel\Translate\YandexDriver', + 'driver' => '\KorElf\TranslateLaravel\Translate\YandexDriver', 'config' => [ 'folder_id' => env('TRANSLATE_YANDEX_FOLDER_ID'), 'authorized_key_path' => base_path(env('TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH')), + 'limit' => [ + 'max_request' => (int) env('TRANSLATE_YANDEX_LIMIT_MAX_REQUEST', 20), + 'rate_seconds' => (int) env('TRANSLATE_YANDEX_LIMIT_RATE_SECONDS', 1), + 'max_symbols' => (int) env('TRANSLATE_YANDEX_LIMIT_MAX_SYMBOLS', 9000), + ], ], ], ], diff --git a/src/Contracts/ProcessTranslateContract.php b/src/Contracts/ProcessTranslateContract.php new file mode 100644 index 0000000..c7bce08 --- /dev/null +++ b/src/Contracts/ProcessTranslateContract.php @@ -0,0 +1,10 @@ +text; + } + + public function getTextType(): TextType + { + return $this->textType; + } + + public function getTargetLanguageCode(): string + { + return $this->targetLanguageCode; + } + + public function getSourceLanguageCode(): ?string + { + return $this->sourceLanguageCode; + } + + public function getDriver(): ?string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/ProcessTranslateDto.php b/src/DTO/ProcessTranslateDto.php new file mode 100644 index 0000000..811b2da --- /dev/null +++ b/src/DTO/ProcessTranslateDto.php @@ -0,0 +1,59 @@ +groupName; + } + + public function getKey(): string + { + return $this->key; + } + + public function getPart(): int + { + return $this->part; + } + + public function getText(): string + { + return $this->text; + } + + public function getTextType(): TextType + { + return $this->textType; + } + + public function getTargetLanguageCode(): string + { + return $this->targetLanguageCode; + } + + public function getSourceLanguageCode(): ?string + { + return $this->sourceLanguageCode; + } + + public function getDriver(): ?string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/ProcessTranslateLimit.php b/src/DTO/ProcessTranslateLimit.php new file mode 100644 index 0000000..4e4ffbf --- /dev/null +++ b/src/DTO/ProcessTranslateLimit.php @@ -0,0 +1,27 @@ +maxRequest; + } + + public function getRateSeconds(): int + { + return $this->rateSeconds; + } + + public function getDriver(): string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/RunTranslateDto.php b/src/DTO/RunTranslateDto.php new file mode 100644 index 0000000..eaa2030 --- /dev/null +++ b/src/DTO/RunTranslateDto.php @@ -0,0 +1,65 @@ + \KorElf\TranslateLaravel\DTO\Parameter] + */ + private array $params = []; + + public function addParam( + string $key, + string $text, + TextType $textType, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + if (isset($this->params[$key])) { + throw new RunTranslateDtoException('Duplicate key: ' . $key); + } + + $this->params[$key] = new Parameter( + $text, + $textType, + $targetLanguageCode, + $sourceLanguageCode, + $driver, + ); + + return $this; + } + + public function addParamHtml( + string $key, + string $text, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + return $this->addParam($key, $text, TextType::Html, $targetLanguageCode, $sourceLanguageCode, $driver); + } + + public function addParamText( + string $key, + string $text, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + return $this->addParam($key, $text, TextType::Text, $targetLanguageCode, $sourceLanguageCode, $driver); + } + + public function getParams(): array + { + return $this->params; + } +} \ No newline at end of file diff --git a/src/DTO/Translated.php b/src/DTO/Translated.php new file mode 100644 index 0000000..3bcf5e3 --- /dev/null +++ b/src/DTO/Translated.php @@ -0,0 +1,20 @@ +data[$key] = $parts; + + return $this; + } + + public function toArray(): array + { + return $this->data; + } +} \ No newline at end of file diff --git a/src/Enums/TextType.php b/src/Enums/TextType.php new file mode 100644 index 0000000..b0750f1 --- /dev/null +++ b/src/Enums/TextType.php @@ -0,0 +1,17 @@ + 'translateText', + self::Html => 'translateHtml', + }; + } +} diff --git a/src/Exceptions/AfterTranslateException.php b/src/Exceptions/AfterTranslateException.php new file mode 100644 index 0000000..3c13907 --- /dev/null +++ b/src/Exceptions/AfterTranslateException.php @@ -0,0 +1,10 @@ +groupName, []); + + $errors = []; + foreach ($this->translated->toArray() as $key => $parts) { + if (!isset($data[$key]) || count($data[$key]) !== $parts) { + $errors[] = $key; + continue; + } + + $translated[$key] = implode(' ', $data[$key]); + } + + if (empty($data)) { + throw new AfterTranslateException('Part or all of the text has not been translated. Keys: ' . implode(', ', $errors)); + } + Cache::forget($this->groupName); + + $this->listener->onTranslationCompleted($translated); + } +} \ No newline at end of file diff --git a/src/Jobs/ProcessTranslate.php b/src/Jobs/ProcessTranslate.php new file mode 100644 index 0000000..d605868 --- /dev/null +++ b/src/Jobs/ProcessTranslate.php @@ -0,0 +1,86 @@ + + */ + public function middleware(): array + { + return [ + new RateLimited('kor-elf-translate'), + ]; + } + + public function getLimited(): ProcessTranslateLimit + { + $driver = $this->param->getDriver(); + if ($driver === null) { + $driver = Translate::getDefaultDriver(); + } + $limit = Translate::getLimit($driver); + + return new ProcessTranslateLimit( + maxRequest: $limit['max_request'] ?? 1000000000, + rateSeconds: $limit['rate_seconds'] ?? 1, + driver: $driver, + ); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $param = $this->param; + $groupName = $param->getGroupName(); + + $translated = Cache::get($groupName, []); + if (!isset($translated[$param->getKey()])) { + $translated[$param->getKey()] = []; + } + + $translate = Translate::service($param->getDriver()); + $function = $param->getTextType()->functionName(); + $key = $param->getKey(); + $part = $param->getPart(); + + $translated[$key][$part] = $param->getText(); + if (\trim($param->getText()) !== '') { + + $translated[$key][$part] = $translate->{$function}( + $param->getText(), + $param->getTargetLanguageCode(), + $param->getSourceLanguageCode() + ); + + } + + Cache::put($groupName, $translated, 86400); + } +} \ No newline at end of file diff --git a/src/Jobs/RunTranslate.php b/src/Jobs/RunTranslate.php new file mode 100644 index 0000000..935a89c --- /dev/null +++ b/src/Jobs/RunTranslate.php @@ -0,0 +1,62 @@ +toString(); + $translated = new Translated(); + foreach ($this->runTranslateDto->getParams() as $key => $param) { + /* @var \KorElf\TranslateLaravel\DTO\Parameter $param */ + + $texts = $splitTextIntoPartsCommand->execute($param->getText(), $param->getTextType(), $param->getDriver()); + foreach ($texts as $part => $partText) { + $processTranslateDto = new ProcessTranslateDto( + $groupName, + $key, + $part, + $partText, + $param->getTextType(), + $param->getTargetLanguageCode(), + $param->getSourceLanguageCode(), + $param->getDriver(), + ); + $chains[] = new ProcessTranslate($processTranslateDto); + } + $translated->add($key, count($texts)); + } + $chains[] = new AfterTranslate($groupName, $this->translationCompletedListener, $translated); + + Bus::chain($chains)->dispatch(); + } +} \ No newline at end of file diff --git a/src/Translate/SplitTextIntoPartsCommand.php b/src/Translate/SplitTextIntoPartsCommand.php new file mode 100644 index 0000000..ce985c4 --- /dev/null +++ b/src/Translate/SplitTextIntoPartsCommand.php @@ -0,0 +1,115 @@ + mb_strlen($text)) { + return [$text]; + } + + if ($textType === TextType::Html) { + return $this->splitHtmlText($text, $maxLength); + } + + return $this->splitTextBySentences($text, $maxLength); + } + + private function splitTextBySentences(string $text, int $maxLength): array + { + // Common expression for searching for sentences. + $sentenceEndings = '/(?<=[.?!])\s+(?=[A-ZА-Я])/u'; + + //Dividing the text into sentences + $sentences = preg_split($sentenceEndings, $text, -1, PREG_SPLIT_NO_EMPTY); + + $parts = []; + $currentPart = ''; + + foreach ($sentences as $sentence) { + // If adding a sentence does not exceed the limit, add it to the current part. + if (mb_strlen($currentPart . ' ' . $sentence) <= $maxLength) { + $currentPart .= (empty($currentPart) ? '' : ' ') . $sentence; + continue; + } + + // Otherwise, save the current part and start a new one. + if (!empty($currentPart)) { + $parts[] = $currentPart; + $currentPart = ''; + } + $currentPart = $sentence; + } + + if (!empty($currentPart)) { + $parts[] = $currentPart; + } + + return $parts; + } + + private function splitHtmlText(string $html, int $maxLength): array + { + libxml_use_internal_errors(true); + + $dom = new DOMDocument(); + $dom->loadHTML(mb_convert_encoding('' . $html . '', 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $parts = []; + $currentPart = new DOMDocument(); + $currentLength = 0; + + // Iterate through all child nodes of the root element + foreach ($dom->documentElement->childNodes as $node) { + if ($node->nodeType === XML_TEXT_NODE || $node->nodeType === XML_CDATA_SECTION_NODE) { + // Dividing text nodes into sentences + $sentences = preg_split('/(?<=[.?!])\s+(?=[A-ZА-Я])/', $node->textContent, -1, PREG_SPLIT_NO_EMPTY); + + foreach ($sentences as $sentence) { + $sentenceHtml = htmlspecialchars($sentence); + if ($currentLength + mb_strlen($sentenceHtml) > $maxLength) { + $parts[] = $currentPart->saveHTML(); + + // Start new part + $currentPart = new DOMDocument(); + $currentLength = 0; + } + + $textNode = $currentPart->createTextNode($sentence . ' '); + $currentPart->appendChild($textNode); + $currentLength += mb_strlen($sentenceHtml); + } + continue; + } + + $nodeHtml = $dom->saveHTML($node); + + if ($currentLength + mb_strlen($nodeHtml) > $maxLength) { + $parts[] = $currentPart->saveHTML(); + + // Start new part + $currentPart = new DOMDocument(); + $currentLength = mb_strlen($nodeHtml); + } + + $currentPart->appendChild($currentPart->importNode($node, true)); + $currentLength += mb_strlen($nodeHtml); + } + + if ($currentPart->hasChildNodes()) { + $parts[] = $currentPart->saveHTML(); + } + + libxml_clear_errors(); + + return $parts; + } +} \ No newline at end of file diff --git a/src/Translate/TranslateManager.php b/src/Translate/TranslateManager.php index d7214cd..2a56ef3 100644 --- a/src/Translate/TranslateManager.php +++ b/src/Translate/TranslateManager.php @@ -1,10 +1,15 @@ getConfig($name); if (\is_null($config)) { - throw new InvalidArgumentException("Translate service [{$name}] is not defined."); + throw new InvalidArgumentException('Translate service [' . $name . '] is not defined.'); } return $config['driver']::init($this->app, $config['config']); @@ -60,6 +65,25 @@ final class TranslateManager unset($this->translates[$name]); } + /** + * Run through queues. + */ + public function runJob(RunTranslateDto $params, TranslationCompletedListener $completedListener): PendingDispatch + { + return RunTranslate::dispatch($params, $completedListener); + } + + public function getLimit(?string $driver = null): array + { + if ($driver === null) { + $driver = $this->getDefaultDriver(); + } + + $config = $this->getConfig($driver); + + return $config['config']['limit'] ?? []; + } + /** * Dynamically call the default driver instance. */ diff --git a/src/Translate/Yandex/Connection.php b/src/Translate/Yandex/Connection.php index 8c4f73c..7dd43f6 100644 --- a/src/Translate/Yandex/Connection.php +++ b/src/Translate/Yandex/Connection.php @@ -1,6 +1,6 @@ publishes([ __DIR__ . '/../config/translate.php' => config_path('translate.php'), ], 'config'); + + RateLimiter::for('kor-elf-translate', function (ProcessTranslateContract $job) { + $limited = $job->getLimited(); + + return Limit::perSecond( + $limited->getMaxRequest(), + $limited->getRateSeconds() + )->by($limited->getDriver()); + }); } /** @@ -26,7 +38,6 @@ final class TranslateLaravelProvider extends ServiceProvider __DIR__ . '/../config/translate.php', 'translate' ); - $this->app->singleton('translate', function (Application $app) { return new TranslateManager($app); });