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); });