diff --git a/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php b/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php index 8f69b70..532463d 100644 --- a/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php +++ b/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php @@ -11,6 +11,7 @@ final readonly class Content private string $title, private string $content, private Storages $storages, + private bool $isTranslateAutomatically = false, ) { } public function getLanguageId(): int @@ -32,4 +33,9 @@ final readonly class Content { return $this->storages; } + + public function isTranslateAutomatically(): bool + { + return $this->isTranslateAutomatically; + } } diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceHash/Fields.php b/app/application/app/Dto/Service/ProjectTranslationServiceHash/Fields.php new file mode 100644 index 0000000..b174256 --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceHash/Fields.php @@ -0,0 +1,25 @@ +fields[$name] = $value; + + return $this; + } + + public function getFields(): array + { + return $this->fields; + } + + public function getNames(): array + { + return \array_keys($this->fields); + } +} diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceHash/HashStatusWaiting.php b/app/application/app/Dto/Service/ProjectTranslationServiceHash/HashStatusWaiting.php new file mode 100644 index 0000000..1bcbdc5 --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceHash/HashStatusWaiting.php @@ -0,0 +1,18 @@ +hash[$fieldName] = $isWaiting; + } + + public function isStatusWaiting(string $fieldName): bool + { + return $this->hash[$fieldName] ?? false; + } +} diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceHash/Hashes.php b/app/application/app/Dto/Service/ProjectTranslationServiceHash/Hashes.php new file mode 100644 index 0000000..1dccb6f --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceHash/Hashes.php @@ -0,0 +1,28 @@ +hashes[$hashId] = [ + 'field' => $fieldName, + 'hash' => $hash, + ]; + $this->ids[] = $hashId; + } + + public function getHash(int $hashId): ?array + { + return $this->hashes[$hashId] ?? null; + } + + public function getIds(): array + { + return $this->ids; + } +} diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceHash/TranslateFields.php b/app/application/app/Dto/Service/ProjectTranslationServiceHash/TranslateFields.php new file mode 100644 index 0000000..ba3b179 --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceHash/TranslateFields.php @@ -0,0 +1,37 @@ +fields[$languageId])) { + $this->fields[$languageId] = []; + } + $this->fields[$languageId][] = $fieldName; + + if (!isset($this->hashes[$languageId])) { + $this->hashes[$languageId] = []; + } + $this->hashes[$languageId][$fieldName] = [ + 'hash' => $hash, + 'hashId' => $hashId, + ]; + + return $this; + } + + public function getFields(): array + { + return $this->fields; + } + + public function getHashesByLanguage(int $languageId): array + { + return $this->hashes[$languageId] ?? []; + } +} diff --git a/app/application/app/Enums/ProjectTranslationServiceHashes/Status.php b/app/application/app/Enums/ProjectTranslationServiceHashes/Status.php new file mode 100644 index 0000000..dd8a006 --- /dev/null +++ b/app/application/app/Enums/ProjectTranslationServiceHashes/Status.php @@ -0,0 +1,9 @@ + ['required', 'string', 'max:255'], 'content.*.content' => ['nullable', 'string'], 'content.*.content_images.*.file' => ['numeric', 'min:1'], + 'translate-automatically.*' => ['nullable', 'boolean'], ]; } @@ -60,6 +61,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto title: $content['title'], content: $content['content'] ?? '', storages: $this->contentStorages($content), + isTranslateAutomatically: (bool) $this->input('translate-automatically.' . $languageId, false), )); } return $contents; diff --git a/app/application/app/Jobs/Translate/ProcessProjectDocumentationContent.php b/app/application/app/Jobs/Translate/ProcessProjectDocumentationContent.php new file mode 100644 index 0000000..0c353a9 --- /dev/null +++ b/app/application/app/Jobs/Translate/ProcessProjectDocumentationContent.php @@ -0,0 +1,58 @@ + + */ + public function middleware(): array + { + return [ + (new WithoutOverlapping($this->uniqueId()))->expireAfter(180), + ]; + } + + /** + * Create a new job instance. + */ + public function __construct( + private readonly int $projectDocumentId, + private readonly array $contentIds = [], + private readonly array $exceptLanguages = [], + ) { } + + /** + * Get the unique ID for the job. + */ + public function uniqueId(): string + { + return 'documentation-' . $this->projectDocumentId; + } + + /** + * Execute the job. + * @throws Exception + */ + public function handle(DocumentationContentService $documentationContentService): void + { + $result = $documentationContentService->translate($this->projectDocumentId, $this->contentIds, $this->exceptLanguages); + if ($result->isError() && $result->getCode() !== 404) { + cache()->lock($this->uniqueId())->forceRelease(); + throw new Exception($result->getMessage()); + } + } +} diff --git a/app/application/app/Models/ProjectTranslationServiceHash.php b/app/application/app/Models/ProjectTranslationServiceHash.php new file mode 100644 index 0000000..6eb7177 --- /dev/null +++ b/app/application/app/Models/ProjectTranslationServiceHash.php @@ -0,0 +1,51 @@ + Status::Waiting, + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'language_id', + 'field', + 'status', + 'hash', + 'morph_type', + 'morph_id', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => Status::class, + 'morph_type' => Morph::class, + ]; + } +} diff --git a/app/application/app/Repositories/ProjectTranslationServiceHashRepository.php b/app/application/app/Repositories/ProjectTranslationServiceHashRepository.php new file mode 100644 index 0000000..2513031 --- /dev/null +++ b/app/application/app/Repositories/ProjectTranslationServiceHashRepository.php @@ -0,0 +1,38 @@ +where('morph_type', $morph) + ->where('morph_id', $morphId) + ->whereIn('language_id', $languages) + ->when($languages, function (Builder $query) use ($languages) { + $query->whereIn('language_id', $languages); + }) + ->when($fields, function (Builder $query) use ($fields) { + $query->whereIn('field', $fields); + }); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function getHashesByIds(array $ids): Search + { + $query = ProjectTranslationServiceHash::query()->whereIn('id', $ids); + return $this->createSearchInstanceCommand->execute($query); + } +} diff --git a/app/application/app/Services/Admin/Project/DocumentationService.php b/app/application/app/Services/Admin/Project/DocumentationService.php index 26e1693..4a1313c 100644 --- a/app/application/app/Services/Admin/Project/DocumentationService.php +++ b/app/application/app/Services/Admin/Project/DocumentationService.php @@ -5,7 +5,10 @@ namespace App\Services\Admin\Project; use App\Dto\Builder\Documentation as DocumentationBuilderDto; use App\Dto\QuerySettingsDto; use App\Dto\Service\Admin\Project\Documentation\StoreUpdate; +use App\Dto\Service\Admin\Project\DocumentationContent\Content; +use App\Dto\Service\Admin\Project\DocumentationContent\Contents; use App\Exceptions\Services\DocumentationContent\StorageCommandException; +use App\Jobs\Translate\ProcessProjectDocumentationContent; use App\Models\Documentation; use App\Models\ProjectLanguage; use App\Models\User; @@ -90,6 +93,7 @@ final class DocumentationService extends Service 'project' => $project, 'documentation' => new Documentation(), 'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage), + 'serviceTranslationEnable' => config('translation_service.enable', false), ]); } @@ -120,6 +124,7 @@ final class DocumentationService extends Service 'project' => $project, 'documentation' => $documentation, 'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage, null, $withCategories), + 'serviceTranslationEnable' => config('translation_service.enable', false), ]); } @@ -150,6 +155,10 @@ final class DocumentationService extends Service return $documentation; }); + + if (\config('translation_service.enable', false)) { + $this->translateContent($documentation, $data->getContents()); + } } catch (StorageCommandException $e) { return $e->getResultError(); } catch (\Throwable $e) { @@ -192,6 +201,10 @@ final class DocumentationService extends Service return $documentation; }); + + if (\config('translation_service.enable', false)) { + $this->translateContent($documentation, $data->getContents()); + } } catch (StorageCommandException $e) { return $e->getResultError(); } catch (\Throwable $e) { @@ -240,4 +253,27 @@ final class DocumentationService extends Service 'category_id' => $data->getCategoryId(), ]; } + + private function translateContent(Documentation $documentation, Contents $contents): void + { + $translateExceptLanguages = []; + + $translateLanguages = []; + foreach ($contents->getContents() as $content) { + /** @var Content $content */ + $translateExceptLanguages[] = $content->getLanguageId(); + if ($content->isTranslateAutomatically()) { + $translateLanguages[] = $content->getLanguageId(); + } + } + + if (empty($translateLanguages)) { + return; + } + + $translateContentIds = $documentation->contents()->select('id') + ->whereIn('language_id', $translateLanguages) + ->get()->pluck('id')->toArray(); + ProcessProjectDocumentationContent::dispatch($documentation->id, $translateContentIds, $translateExceptLanguages); + } } diff --git a/app/application/app/Services/ProjectTranslationService/ModelSyncCommand.php b/app/application/app/Services/ProjectTranslationService/ModelSyncCommand.php new file mode 100644 index 0000000..d1cd2f9 --- /dev/null +++ b/app/application/app/Services/ProjectTranslationService/ModelSyncCommand.php @@ -0,0 +1,54 @@ +languages()->with(['serviceTranslate'])->get(); + foreach ($translations->getTranslations() as $translation) { + /* @var \App\Dto\Service\Admin\Project\ServiceTranslate\Translation $translation */ + $language = $languages->firstWhere('id', '=', $translation->getLanguageId()); + if ($language === null) { + continue; + } + $serviceTranslate = $language->serviceTranslate; + if ($serviceTranslate === null) { + if ($translation->getCode() !== null) { + $insert[] = [ + 'language_id' => $translation->getLanguageId(), + 'code' => $translation->getCode(), + 'source_language_id' => $translation->getSourceLanguageId(), + ]; + } + continue; + } + + if ($translation->getCode() === null) { + $delete[] = [$serviceTranslate->id]; + continue; + } + + $serviceTranslate->update([ + 'code' => $translation->getCode(), + 'source_language_id' => $translation->getSourceLanguageId(), + ]); + } + + if (!empty($insert)) { + ProjectTranslationService::query()->insert($insert); + } + + if (!empty($delete)) { + ProjectTranslationService::query()->whereIn('id', $delete)->delete(); + } + } +} diff --git a/app/application/app/Services/ProjectTranslationService/NoTranslateAttributeHandler.php b/app/application/app/Services/ProjectTranslationService/NoTranslateAttributeHandler.php new file mode 100644 index 0000000..ee9205f --- /dev/null +++ b/app/application/app/Services/ProjectTranslationService/NoTranslateAttributeHandler.php @@ -0,0 +1,18 @@ +hashRepository->getHashesByIds($hashesDto->getIds())->all(); + $hashStatusWaiting = new HashStatusWaiting(); + $hashSuccessIds = []; + foreach ($hashes as $hash) { + $dataHash = $hashesDto->getHash($hash->id); + if ($dataHash === null) { + continue; + } + + $isWaiting = false; + if ($hash->status === Status::Waiting && $hash->hash === $dataHash['hash'] && $hash->field === $dataHash['field']) { + $isWaiting = true; + $hashSuccessIds[] = $hash->id; + } + $hashStatusWaiting->add($hash['field'], $isWaiting); + } + + $this->commandHandler->handleSetStatusById($hashSuccessIds, Status::Success); + + return $hashStatusWaiting; + } +} diff --git a/app/application/app/Services/ProjectTranslationServiceHash/HashTrackerCommand.php b/app/application/app/Services/ProjectTranslationServiceHash/HashTrackerCommand.php new file mode 100644 index 0000000..2c72dcf --- /dev/null +++ b/app/application/app/Services/ProjectTranslationServiceHash/HashTrackerCommand.php @@ -0,0 +1,69 @@ +hashRepository->getHashes($morph, $morphId, $languages, $fields->getNames())->all(); + + $translateFields = new TranslateFields(); + foreach ($fields->getFields() as $fieldName => $fieldValue) { + $textHash = $this->generateHashFromText((string) $fieldValue); + foreach ($languages as $language) { + $modelHash = $hashes->where('language_id', $language)->firstWhere('field', $fieldName); + if ($modelHash === null) { + $modelHash = $this->commandHandler->handleStore([ + 'language_id' => $language, + 'morph_type' => $morph, + 'morph_id' => $morphId, + 'field' => $fieldName, + 'hash' => $textHash, + 'status' => Status::Waiting, + ]); + $translateFields->add($language, $fieldName, $textHash, $modelHash->id); + continue; + } + + if ($modelHash->hash === $textHash && $this->isSuccess($modelHash)) { + continue; + } + + $translateFields->add($language, $fieldName, $textHash, $modelHash->id); + $this->commandHandler->handleUpdate($modelHash, [ + 'hash' => $textHash, + 'status' => Status::Waiting, + ]); + } + } + + return $translateFields; + } + + private function generateHashFromText(string $text): string + { + return \hash('sha256', $text); + } + + private function isSuccess(ProjectTranslationServiceHash $modelHash): bool + { + if ($modelHash->status === Status::Success) { + return true; + } + + return ($modelHash->updated_at >= now()->subMinutes(10)); + } +} diff --git a/app/application/app/Services/ProjectTranslationServiceHash/ProjectTranslationServiceHashCommandHandler.php b/app/application/app/Services/ProjectTranslationServiceHash/ProjectTranslationServiceHashCommandHandler.php new file mode 100644 index 0000000..15c9dc6 --- /dev/null +++ b/app/application/app/Services/ProjectTranslationServiceHash/ProjectTranslationServiceHashCommandHandler.php @@ -0,0 +1,26 @@ +update($data); + $hash->touch(); + return $hash; + } + + public function handleSetStatusById(array $ids, Status $status): void + { + ProjectTranslationServiceHash::query()->whereIn('id', $ids)->update(['status' => $status->value]); + } +} diff --git a/app/application/app/Services/Translate/Completed/Project/DocumentationContentService.php b/app/application/app/Services/Translate/Completed/Project/DocumentationContentService.php new file mode 100644 index 0000000..bc57e25 --- /dev/null +++ b/app/application/app/Services/Translate/Completed/Project/DocumentationContentService.php @@ -0,0 +1,90 @@ +documentationRepository->getDocumentationById((int) $data['projectDocumentId']); + if ($documentation === null) { + return; + } + + $documentationContent = DB::transaction(function () use ($data, $documentation, $translatedText) { + $values = []; + $hashes = $this->completionChecker->execute( + $this->getHashes($data['hashes']), + ); + + foreach ($translatedText as $translatedTextKey => $translatedTextValue) { + if ($hashes->isStatusWaiting($translatedTextKey) !== true) { + continue; + } + + $translatedTextValue = $this->noTranslateAttributeHandler->handleRemoveAttribute($translatedTextValue); + $values[$translatedTextKey] = $translatedTextValue; + } + if (\count($values) === 0) { + return null; + } + + $documentationContent = $documentation->contents()->where('language_id', $data['languageId'])->first(); + if ($documentationContent !== null) { + $documentationContent->update($values); + return $documentationContent; + } + + $values['language_id'] = $data['languageId']; + return $documentation->contents()->create($values); + }); + if (\config('translation_service.enable', false) && $documentationContent !== null) { + $this->translateContent($documentation, $documentationContent, $data); + } + } + + private function translateContent(Documentation $documentation, DocumentationContent $documentationContent, array $data): void + { + $translateExceptLanguages = $data['exceptLanguages'] ?? []; + $translateExceptLanguages[] = $data['languageId']; + ProcessProjectDocumentationContent::dispatch($documentation->id, [$documentationContent->id], $translateExceptLanguages); + } + + private function getHashes(array $hashes): Hashes + { + $hashesDto = new Hashes(); + foreach ($hashes as $field => $hash) { + $hashesDto->add((int) $hash['hashId'], $field, $hash['hash']); + } + return $hashesDto; + } +} diff --git a/app/application/app/Services/Translate/Project/DocumentationContentService.php b/app/application/app/Services/Translate/Project/DocumentationContentService.php new file mode 100644 index 0000000..99fa014 --- /dev/null +++ b/app/application/app/Services/Translate/Project/DocumentationContentService.php @@ -0,0 +1,95 @@ +documentationRepository->getDocumentationById($projectDocumentId); + if ($documentation === null) { + return $this->errNotFound(__('Documentation not found')); + } + + $contents = $documentation->contents() + ->whereIn('id', $contentIds) + ->cursor(); + + $sourceLanguagesCode = []; + foreach ($contents as $content) { + if (!isset($sourceLanguagesCode[$content->language_id])) { + $sourceLanguagesCode[$content->language_id] = $this->projectTranslationServiceRepository->getLanguageCodeByLanguageId($content->language_id) ?? 'null'; + } + $translateIntoLanguage = $this->projectTranslationServiceRepository + ->getLanguagesBySourceLanguage($content->language_id, $exceptLanguages) + ->all()->pluck('code', 'language_id')->toArray(); + + if (empty($translateIntoLanguage)) { + continue; + } + + $fields = (new Fields()) + ->add('title', $content->title ?? '') + ->add('content', $content->content ?? ''); + $translations = DB::transaction(function () use ($content, $translateIntoLanguage, $fields) { + return $this->hashTrackerCommand->execute(Morph::DocumentationContent, $content->id, \array_keys($translateIntoLanguage), $fields); + }); + /** @var TranslateFields $translations */ + unset($fields); + + foreach ($translations->getFields() as $languageId => $fields) { + $params = new \KorElf\TranslateLaravel\DTO\RunTranslateDto(); + $sourceLanguageCode = null; + if (isset($sourceLanguagesCode[$content->language_id]) && $sourceLanguagesCode[$content->language_id] !== 'null') { + $sourceLanguageCode = $sourceLanguagesCode[$content->language_id]; + } + foreach ($fields as $field) { + $text = $this->noTranslateAttributeHandler->handleAddAttribute((string) $content->{$field} ?? ''); + $params->addParamHtml($field, $text, $translateIntoLanguage[$languageId], $sourceLanguageCode); + } + + $afterTranslateDto = new AfterTranslateDto(DocumentationContentServiceCompleted::class, [ + 'projectDocumentId' => $projectDocumentId, + 'languageId' => $languageId, + 'hashes' => $translations->getHashesByLanguage($languageId), + 'exceptLanguages' => $exceptLanguages, + ]); + Translate::runJob($params, $afterTranslateDto); + unset($params, $afterTranslateDto); + } + + unset($translations, $translateIntoLanguage); + } + unset($contents, $documentation); + + } catch (\Throwable $e) { + \report($e); + return $this->errService($e->getMessage()); + } + + return $this->ok(); + } +} diff --git a/app/application/database/migrations/2024_12_10_055548_create_project_translation_service_hashes.php b/app/application/database/migrations/2024_12_10_055548_create_project_translation_service_hashes.php new file mode 100644 index 0000000..ea9b6ed --- /dev/null +++ b/app/application/database/migrations/2024_12_10_055548_create_project_translation_service_hashes.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('language_id')->index(); + $table->foreign('language_id')->references('id')->on('project_languages'); + $table->unsignedInteger('morph_type'); + $table->unsignedBigInteger('morph_id'); + $table->string('field', 255); + $table->unsignedInteger('status')->index()->default(0); + $table->char('hash', 64); + $table->timestamps(); + + $table->index(['morph_type', 'morph_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_translation_service_hashes'); + } +}; diff --git a/app/application/lang/en.json b/app/application/lang/en.json index cb138ce..56d5005 100644 --- a/app/application/lang/en.json +++ b/app/application/lang/en.json @@ -256,6 +256,7 @@ "The link has been deleted": "The link has been deleted", "Language not found": "Language not found", "Project not found": "Project not found", + "Documentation not found": "Documentation not found", "Translations successfully updated": "Translations successfully updated", "allowed characters:": "allowed characters:", "Documentation version created successfully": "Documentation version created successfully", @@ -268,5 +269,7 @@ "Category successfully created": "Category successfully created", "Category updated successfully": "Category updated successfully", "Category successfully deleted": "Category successfully deleted", - "Old Files deleted": "Old Files deleted" + "Old Files deleted": "Old Files deleted", + "Automatic translation": "Automatic translation", + "The settings were saved successfully": "The settings were saved successfully" } diff --git a/app/application/lang/ru.json b/app/application/lang/ru.json index 6914efc..9e8bcf7 100644 --- a/app/application/lang/ru.json +++ b/app/application/lang/ru.json @@ -256,6 +256,7 @@ "The link has been deleted": "Ссылка удалена", "Language not found": "Язык не найден", "Project not found": "Проект не найден", + "Documentation not found": "Документация не найдена", "Translations successfully updated": "Переводы успешно обновлены", "allowed characters:": "разрешенные символы:", "Documentation version created successfully": "Версия документации успешно создана", @@ -268,5 +269,7 @@ "Category successfully created": "Категория успешно создана", "Category updated successfully": "Категория успешно обновлена", "Category successfully deleted": "Категория успешно удалена", - "Old Files deleted": "Старые файлы удалены" + "Old Files deleted": "Старые файлы удалены", + "Automatic translation": "Автоматический перевод", + "The settings were saved successfully": "Настройки успешно сохранены" } diff --git a/app/application/resources/views/admin/projects/documentations/_from.blade.php b/app/application/resources/views/admin/projects/documentations/_from.blade.php index defcf3a..ba71ebb 100644 --- a/app/application/resources/views/admin/projects/documentations/_from.blade.php +++ b/app/application/resources/views/admin/projects/documentations/_from.blade.php @@ -29,7 +29,13 @@ role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab"> + not-checked-value="0"/> + @if($serviceTranslationEnable) + + @endif + diff --git a/app/application/resources/views/admin/projects/index.blade.php b/app/application/resources/views/admin/projects/index.blade.php index 637b497..f903382 100644 --- a/app/application/resources/views/admin/projects/index.blade.php +++ b/app/application/resources/views/admin/projects/index.blade.php @@ -40,6 +40,15 @@ @endif + @if($serviceTranslationEnable) + @can('settingUpAutomaticTranslation', $project) + + + + @endcan + @endif