diff --git a/app/application/app/Dto/Service/Admin/Project/Translation/Update.php b/app/application/app/Dto/Service/Admin/Project/Translation/Update.php index 3ce9e24..697ec8b 100644 --- a/app/application/app/Dto/Service/Admin/Project/Translation/Update.php +++ b/app/application/app/Dto/Service/Admin/Project/Translation/Update.php @@ -8,10 +8,16 @@ final readonly class Update extends Dto { public function __construct( private Translations $translations, + private bool $isTranslateAutomatically = false, ) { } public function getTranslations(): Translations { return $this->translations; } + + public function isTranslateAutomatically(): bool + { + return $this->isTranslateAutomatically; + } } diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Codes.php b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Codes.php new file mode 100644 index 0000000..1d1550a --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Codes.php @@ -0,0 +1,25 @@ +codes[$code] = $value; + + return $this; + } + + public function getCodes(): array + { + return $this->codes; + } + + public function getCodeNames(): array + { + return \array_keys($this->codes); + } +} diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/HashStatusWaiting.php b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/HashStatusWaiting.php new file mode 100644 index 0000000..967e2f0 --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/HashStatusWaiting.php @@ -0,0 +1,18 @@ +hash[$code] = $isWaiting; + } + + public function isStatusWaiting(string $code): bool + { + return $this->hash[$code] ?? false; + } +} diff --git a/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Hashes.php b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Hashes.php new file mode 100644 index 0000000..f39dcde --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/Hashes.php @@ -0,0 +1,28 @@ +hashes[$hashId] = [ + 'code' => $code, + '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/ProjectTranslationServiceTextHash/TranslateCodes.php b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/TranslateCodes.php new file mode 100644 index 0000000..2a8f6c9 --- /dev/null +++ b/app/application/app/Dto/Service/ProjectTranslationServiceTextHash/TranslateCodes.php @@ -0,0 +1,37 @@ +codes[$languageId])) { + $this->codes[$languageId] = []; + } + $this->codes[$languageId][] = $code; + + if (!isset($this->hashes[$languageId])) { + $this->hashes[$languageId] = []; + } + $this->hashes[$languageId][$code] = [ + 'hash' => $hash, + 'hashId' => $hashId, + ]; + + return $this; + } + + public function getCodes(): array + { + return $this->codes; + } + + public function getHashesByLanguage(int $languageId): array + { + return $this->hashes[$languageId] ?? []; + } +} diff --git a/app/application/app/Http/Requests/Admin/Projects/Translations/UpdateRequest.php b/app/application/app/Http/Requests/Admin/Projects/Translations/UpdateRequest.php index f185a2d..6af6a06 100644 --- a/app/application/app/Http/Requests/Admin/Projects/Translations/UpdateRequest.php +++ b/app/application/app/Http/Requests/Admin/Projects/Translations/UpdateRequest.php @@ -20,6 +20,7 @@ final class UpdateRequest extends FormRequest implements FormRequestDto 'translations' => ['nullable', 'array'], 'translations.*.code' => ['required', 'string', new In(Translations::getTranslationCodes())], 'translations.*.text' => ['nullable', 'string', 'max:1000'], + 'translate-automatically' => ['nullable', 'boolean'], ]; } @@ -33,6 +34,9 @@ final class UpdateRequest extends FormRequest implements FormRequestDto ); } - return new Update($translations); + return new Update( + translations: $translations, + isTranslateAutomatically: (bool) $this->input('translate-automatically', false), + ); } } diff --git a/app/application/app/Jobs/Translate/ProcessTranslationText.php b/app/application/app/Jobs/Translate/ProcessTranslationText.php new file mode 100644 index 0000000..eb0f50a --- /dev/null +++ b/app/application/app/Jobs/Translate/ProcessTranslationText.php @@ -0,0 +1,59 @@ + + */ + public function middleware(): array + { + return [ + (new WithoutOverlapping($this->uniqueId()))->expireAfter(180), + ]; + } + + /** + * Create a new job instance. + */ + public function __construct( + private readonly int $projectId, + private readonly int $languageId, + private readonly array $translateTextCode = [], + private readonly array $exceptLanguages = [], + ) { } + + /** + * Get the unique ID for the job. + */ + public function uniqueId(): string + { + return 'ProcessTranslationText-' . $this->projectId; + } + + /** + * Execute the job. + * @throws Exception + */ + public function handle(TranslationTextService $translationTextService): void + { + $result = $translationTextService->translate($this->projectId, $this->languageId, $this->translateTextCode, $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/ProjectTranslationServiceTextHash.php b/app/application/app/Models/ProjectTranslationServiceTextHash.php new file mode 100644 index 0000000..beba2ed --- /dev/null +++ b/app/application/app/Models/ProjectTranslationServiceTextHash.php @@ -0,0 +1,47 @@ + Status::Waiting, + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'language_id', + 'code', + 'status', + 'hash', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => Status::class, + ]; + } +} diff --git a/app/application/app/Repositories/ProjectTranslationRepository.php b/app/application/app/Repositories/ProjectTranslationRepository.php index 10bce6a..20ba7a3 100644 --- a/app/application/app/Repositories/ProjectTranslationRepository.php +++ b/app/application/app/Repositories/ProjectTranslationRepository.php @@ -22,4 +22,13 @@ final readonly class ProjectTranslationRepository $query = ProjectTranslation::query()->withTrashed()->where('project_id', $projectId)->where('language_id', $languageId); return $this->createSearchInstanceCommand->execute($query); } + + public function getProjectTranslationByCodes(int $projectId, int $languageId, array $codes): Search + { + $query = ProjectTranslation::query() + ->where('project_id', $projectId) + ->where('language_id', $languageId) + ->whereIn('code', $codes); + return $this->createSearchInstanceCommand->execute($query); + } } diff --git a/app/application/app/Repositories/ProjectTranslationServiceTextHashRepository.php b/app/application/app/Repositories/ProjectTranslationServiceTextHashRepository.php new file mode 100644 index 0000000..49f8654 --- /dev/null +++ b/app/application/app/Repositories/ProjectTranslationServiceTextHashRepository.php @@ -0,0 +1,33 @@ +whereIn('code', $codes) + ->when($languages, function (Builder $query) use ($languages) { + $query->whereIn('language_id', $languages); + }); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function getHashesByIds(array $ids): Search + { + $query = ProjectTranslationServiceTextHash::query()->whereIn('id', $ids); + return $this->createSearchInstanceCommand->execute($query); + } +} diff --git a/app/application/app/Services/Admin/Project/TranslationService.php b/app/application/app/Services/Admin/Project/TranslationService.php index b8482e5..2a3d6fd 100644 --- a/app/application/app/Services/Admin/Project/TranslationService.php +++ b/app/application/app/Services/Admin/Project/TranslationService.php @@ -3,8 +3,10 @@ namespace App\Services\Admin\Project; use App\Dto\Service\Admin\Project\Translation\Translations; +use App\Dto\Service\Admin\Project\Translation\Translation; use App\Dto\Service\Admin\Project\Translation\Update; use App\Enums\CacheTag; +use App\Jobs\Translate\ProcessTranslationText; use App\Models\ProjectTranslation; use App\Models\User; use App\Repositories\ProjectRepository; @@ -60,6 +62,7 @@ final class TranslationService extends Service 'language' => $language, 'projectTranslations' => $this->projectTranslationRepository->getProjectTranslations($projectId, $languageId)->all()->pluck('text', 'code')->toArray(), 'translations' => Translations::getTranslationCodes(), + 'serviceTranslationEnable' => config('translation_service.enable', false), ]); } @@ -81,6 +84,9 @@ final class TranslationService extends Service $this->translationModelSyncCommand->execute($project, $language, $data->getTranslations()); }); $this->clearCacheCommandHandler->byTag(CacheTag::ProjectTranslation); + if (\config('translation_service.enable', false)) { + $this->translateContent($projectId, $languageId, $data); + } } catch (\Throwable $e) { report($e); return $this->errService(__('Server Error')); @@ -88,4 +94,19 @@ final class TranslationService extends Service return $this->ok(__('Translations successfully updated')); } + + private function translateContent(int $projectId, int $languageId, Update $data): void + { + if (! $data->isTranslateAutomatically()) { + return; + } + $translateExceptLanguages = [$languageId]; + $translateTextCode = []; + foreach ($data->getTranslations()->getTranslations() as $translation) { + /** @var Translation $translation */ + $translateTextCode[] = $translation->getCode(); + } + + ProcessTranslationText::dispatch($projectId, $languageId, $translateTextCode, $translateExceptLanguages); + } } diff --git a/app/application/app/Services/ProjectTranslation/TranslationText.php b/app/application/app/Services/ProjectTranslation/TranslationText.php new file mode 100644 index 0000000..59b2795 --- /dev/null +++ b/app/application/app/Services/ProjectTranslation/TranslationText.php @@ -0,0 +1,23 @@ +translations[$code] ?? __($code, [], $this->language->system_lang?->getLocale()); + } +} diff --git a/app/application/app/Services/ProjectTranslation/TranslationTextCommand.php b/app/application/app/Services/ProjectTranslation/TranslationTextCommand.php new file mode 100644 index 0000000..52ee266 --- /dev/null +++ b/app/application/app/Services/ProjectTranslation/TranslationTextCommand.php @@ -0,0 +1,23 @@ +translationRepository + ->getProjectTranslationByCodes($project->id, $language->id, $codes) + ->all()->pluck('text', 'code'); + + return TranslationText::init($language, $translations->toArray()); + } +} diff --git a/app/application/app/Services/ProjectTranslationServiceTextHash/CompletionChecker.php b/app/application/app/Services/ProjectTranslationServiceTextHash/CompletionChecker.php new file mode 100644 index 0000000..a179f51 --- /dev/null +++ b/app/application/app/Services/ProjectTranslationServiceTextHash/CompletionChecker.php @@ -0,0 +1,40 @@ +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->code === $dataHash['code']) { + $isWaiting = true; + $hashSuccessIds[] = $hash->id; + } + $hashStatusWaiting->add($hash->code, $isWaiting); + } + + $this->commandHandler->handleSetStatusById($hashSuccessIds, Status::Success); + + return $hashStatusWaiting; + } +} diff --git a/app/application/app/Services/ProjectTranslationServiceTextHash/HashTrackerCommand.php b/app/application/app/Services/ProjectTranslationServiceTextHash/HashTrackerCommand.php new file mode 100644 index 0000000..b32f06d --- /dev/null +++ b/app/application/app/Services/ProjectTranslationServiceTextHash/HashTrackerCommand.php @@ -0,0 +1,66 @@ +hashRepository->getHashes($codes->getCodeNames(), $languages)->all(); + + $translateCodes = new TranslateCodes(); + foreach ($codes->getCodes() as $code => $value) { + $textHash = $this->generateHashFromText((string) $value); + foreach ($languages as $language) { + $modelHash = $hashes->where('language_id', $language)->firstWhere('code', $code); + if ($modelHash === null) { + $modelHash = $this->commandHandler->handleStore([ + 'language_id' => $language, + 'code' => $code, + 'hash' => $textHash, + 'status' => Status::Waiting, + ]); + $translateCodes->add($language, $code, $textHash, $modelHash->id); + continue; + } + + if ($modelHash->hash === $textHash && $this->isSuccess($modelHash)) { + continue; + } + + $translateCodes->add($language, $code, $textHash, $modelHash->id); + $this->commandHandler->handleUpdate($modelHash, [ + 'hash' => $textHash, + 'status' => Status::Waiting, + ]); + } + } + + return $translateCodes; + } + + private function generateHashFromText(string $text): string + { + return \hash('sha256', $text); + } + + private function isSuccess(ProjectTranslationServiceTextHash $modelHash): bool + { + if ($modelHash->status === Status::Success) { + return true; + } + + return ($modelHash->updated_at >= now()->subMinutes(10)); + } +} diff --git a/app/application/app/Services/ProjectTranslationServiceTextHash/ProjectTranslationServiceTextHashCommandHandler.php b/app/application/app/Services/ProjectTranslationServiceTextHash/ProjectTranslationServiceTextHashCommandHandler.php new file mode 100644 index 0000000..2f146d4 --- /dev/null +++ b/app/application/app/Services/ProjectTranslationServiceTextHash/ProjectTranslationServiceTextHashCommandHandler.php @@ -0,0 +1,26 @@ +update($data); + $hash->touch(); + return $hash; + } + + public function handleSetStatusById(array $ids, Status $status): void + { + ProjectTranslationServiceTextHash::query()->whereIn('id', $ids)->update(['status' => $status->value]); + } +} diff --git a/app/application/app/Services/Translate/Completed/Project/TranslationTextService.php b/app/application/app/Services/Translate/Completed/Project/TranslationTextService.php new file mode 100644 index 0000000..921fc31 --- /dev/null +++ b/app/application/app/Services/Translate/Completed/Project/TranslationTextService.php @@ -0,0 +1,94 @@ +projectRepository->getProjectById((int) $data['projectId']); + if ($project === null) { + return; + } + + $translations = $this->projectTranslationRepository->getProjectTranslationByCodes($project->id, $data['languageId'], \array_keys($translatedText))->all(); + + $translateTextCode = DB::transaction(function () use ($data, $translatedText, $translations) { + $translateTextCode = []; + $hashes = $this->completionChecker->execute( + $this->getHashes($data['hashes']), + ); + + foreach ($translatedText as $translatedTextKey => $translatedTextValue) { + if ($hashes->isStatusWaiting($translatedTextKey) !== true) { + continue; + } + $translateTextCode[] = $translatedTextKey; + $translation = $translations->firstWhere('code', $translatedTextKey); + if ($translation !== null) { + $translation->update([ + 'text' => $translatedTextValue, + ]); + continue; + } + + ProjectTranslation::create([ + 'project_id' => (int) $data['projectId'], + 'language_id' => (int) $data['languageId'], + 'code' => $translatedTextKey, + 'text' => $translatedTextValue, + ]); + } + + return $translateTextCode; + }); + + if (\config('translation_service.enable', false) && !empty($translateTextCode)) { + $this->translateContent($project, $translateTextCode, $data); + } + } + + private function translateContent(Project $project, array $translateTextCode, array $data): void + { + $translateExceptLanguages = $data['exceptLanguages'] ?? []; + $translateExceptLanguages[] = $data['languageId']; + ProcessTranslationText::dispatch($project->id, $data['languageId'], $translateTextCode, $translateExceptLanguages); + } + + private function getHashes(array $hashes): Hashes + { + $hashesDto = new Hashes(); + foreach ($hashes as $code => $hash) { + $hashesDto->add((int) $hash['hashId'], $code, $hash['hash']); + } + return $hashesDto; + } +} diff --git a/app/application/app/Services/Translate/Project/TranslationTextService.php b/app/application/app/Services/Translate/Project/TranslationTextService.php new file mode 100644 index 0000000..68083e7 --- /dev/null +++ b/app/application/app/Services/Translate/Project/TranslationTextService.php @@ -0,0 +1,94 @@ +projectRepository->getProjectById($projectId); + if ($project === null) { + return $this->errNotFound(__('Project not found')); + } + $language = $project->languages()->firstWhere('id', $languageId); + if ($language === null) { + return $this->errNotFound(__('Language not found')); + } + + $translateIntoLanguage = $this->projectTranslationServiceRepository + ->getLanguagesBySourceLanguage($languageId, $exceptLanguages) + ->all()->pluck('code', 'language_id')->toArray(); + + if (empty($translateIntoLanguage)) { + return $this->ok(); + } + + $translationText = $this->translationTextCommand->execute($project, $language, $translateTextCode); + $codes = $this->getCodes($translationText, $translateTextCode); + $translations = DB::transaction(function () use ($translateIntoLanguage, $codes) { + return $this->hashTrackerCommand->execute(\array_keys($translateIntoLanguage), $codes); + }); + /** @var TranslateCodes $translations */ + unset($codes); + + $sourceLanguageCode = $this->projectTranslationServiceRepository->getLanguageCodeByLanguageId($language->id); + foreach ($translations->getCodes() as $currentLanguageId => $codes) { + $params = new \KorElf\TranslateLaravel\DTO\RunTranslateDto(); + foreach ($codes as $code) { + $text = $translationText->translate($code); + $params->addParamHtml($code, $text, $translateIntoLanguage[$currentLanguageId], $sourceLanguageCode); + } + + $afterTranslateDto = new AfterTranslateDto(TranslationTextServiceCompleted::class, [ + 'projectId' => $projectId, + 'languageId' => $currentLanguageId, + 'hashes' => $translations->getHashesByLanguage($currentLanguageId), + 'exceptLanguages' => $exceptLanguages, + ]); + Translate::runJob($params, $afterTranslateDto); + unset($params, $afterTranslateDto); + } + + unset($translations, $translateIntoLanguage, $project, $language, $translationText, $sourceLanguageCode); + + } catch (\Throwable $e) { + \report($e); + return $this->errService($e->getMessage()); + } + + return $this->ok(); + } + + private function getCodes(TranslationText $translationText, array $translateTextCode): Codes + { + $codes = new Codes(); + foreach ($translateTextCode as $code) { + $codes->add($code, $translationText->translate($code)); + } + + return $codes; + } +} diff --git a/app/application/database/migrations/2025_02_12_161856_create_project_translation_service_text_hashes.php b/app/application/database/migrations/2025_02_12_161856_create_project_translation_service_text_hashes.php new file mode 100644 index 0000000..c6b851b --- /dev/null +++ b/app/application/database/migrations/2025_02_12_161856_create_project_translation_service_text_hashes.php @@ -0,0 +1,34 @@ +id(); + $table->string('code'); + $table->unsignedBigInteger('language_id')->index(); + $table->foreign('language_id')->references('id')->on('project_languages'); + $table->unsignedInteger('status')->index()->default(0); + $table->char('hash', 64); + $table->timestamps(); + + $table->unique(['language_id', 'code'], 'unique_language_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_translation_service_text_hashes'); + } +}; diff --git a/app/application/resources/views/admin/projects/translations/edit.blade.php b/app/application/resources/views/admin/projects/translations/edit.blade.php index f3504d3..679b2ee 100644 --- a/app/application/resources/views/admin/projects/translations/edit.blade.php +++ b/app/application/resources/views/admin/projects/translations/edit.blade.php @@ -8,6 +8,11 @@

{{ __('admin-sections.Translations') }}

+ @if($serviceTranslationEnable) +
+ @endif