Версия 0.4.0 #7
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,25 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Dto\Service\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| final class Fields | ||||
| { | ||||
|     private array $fields = []; | ||||
| 
 | ||||
|     public function add(string $name, string $value): self | ||||
|     { | ||||
|         $this->fields[$name] = $value; | ||||
| 
 | ||||
|         return $this; | ||||
|     } | ||||
| 
 | ||||
|     public function getFields(): array | ||||
|     { | ||||
|         return $this->fields; | ||||
|     } | ||||
| 
 | ||||
|     public function getNames(): array | ||||
|     { | ||||
|         return \array_keys($this->fields); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Dto\Service\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| final class HashStatusWaiting | ||||
| { | ||||
|     private array $hash = []; | ||||
| 
 | ||||
|     public function add(string $fieldName, bool $isWaiting): void | ||||
|     { | ||||
|         $this->hash[$fieldName] = $isWaiting; | ||||
|     } | ||||
| 
 | ||||
|     public function isStatusWaiting(string $fieldName): bool | ||||
|     { | ||||
|         return $this->hash[$fieldName] ?? false; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Dto\Service\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| final class Hashes | ||||
| { | ||||
|     private array $hashes = []; | ||||
|     private array $ids = []; | ||||
| 
 | ||||
|     public function add(int $hashId, string $fieldName, string $hash): void | ||||
|     { | ||||
|         $this->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; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Dto\Service\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| final class TranslateFields | ||||
| { | ||||
|     private array $fields = []; | ||||
|     private array $hashes = []; | ||||
| 
 | ||||
|     public function add(int $languageId, string $fieldName, string $hash, int $hashId): self | ||||
|     { | ||||
|         if (!isset($this->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] ?? []; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Enums\ProjectTranslationServiceHashes; | ||||
| 
 | ||||
| enum Status: int | ||||
| { | ||||
|     case Waiting = 0; | ||||
|     case Success = 10; | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Exceptions\Services\Translate; | ||||
| 
 | ||||
| final class CompletedException extends \Exception | ||||
| { | ||||
| 
 | ||||
| } | ||||
| @@ -32,6 +32,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto | ||||
|             'content.*.title' => ['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; | ||||
|   | ||||
| @@ -0,0 +1,58 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Jobs\Translate; | ||||
| 
 | ||||
| use App\Services\Translate\Project\DocumentationContentService; | ||||
| use Exception; | ||||
| use Illuminate\Contracts\Queue\ShouldBeEncrypted; | ||||
| use Illuminate\Contracts\Queue\ShouldQueue; | ||||
| use Illuminate\Foundation\Bus\Dispatchable; | ||||
| use Illuminate\Foundation\Queue\Queueable; | ||||
| use Illuminate\Queue\Middleware\WithoutOverlapping; | ||||
| 
 | ||||
| final class ProcessProjectDocumentationContent implements ShouldQueue, ShouldBeEncrypted | ||||
| { | ||||
|     use Dispatchable, Queueable; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the middleware the job should pass through. | ||||
|      * | ||||
|      * @return array<int, object> | ||||
|      */ | ||||
|     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()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										51
									
								
								app/application/app/Models/ProjectTranslationServiceHash.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/application/app/Models/ProjectTranslationServiceHash.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Models; | ||||
| 
 | ||||
| use App\Enums\Morph; | ||||
| use App\Enums\ProjectTranslationServiceHashes\Status; | ||||
| use Illuminate\Database\Eloquent\Factories\HasFactory; | ||||
| use Illuminate\Database\Eloquent\Model; | ||||
| 
 | ||||
| final class ProjectTranslationServiceHash extends Model | ||||
| { | ||||
|     use HasFactory; | ||||
| 
 | ||||
|     protected $table = 'project_translation_service_hashes'; | ||||
| 
 | ||||
|     /** | ||||
|      * The model's default values for attributes. | ||||
|      * | ||||
|      * @var array | ||||
|      */ | ||||
|     protected $attributes = [ | ||||
|         'status' => 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<string, string> | ||||
|      */ | ||||
|     protected function casts(): array | ||||
|     { | ||||
|         return [ | ||||
|             'status'     => Status::class, | ||||
|             'morph_type' => Morph::class, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Repositories; | ||||
| 
 | ||||
| use App\Contracts\Search; | ||||
| use App\Enums\Morph; | ||||
| use App\Models\ProjectTranslationServiceHash; | ||||
| use App\Services\Search\CreateSearchInstanceCommand; | ||||
| use Illuminate\Database\Eloquent\Builder; | ||||
| 
 | ||||
| final readonly class ProjectTranslationServiceHashRepository | ||||
| { | ||||
|     public function __construct( | ||||
|         private CreateSearchInstanceCommand $createSearchInstanceCommand, | ||||
|     ) { } | ||||
| 
 | ||||
|     public function getHashes(Morph $morph, int $morphId, ?array $languages = null, ?array $fields = null): Search | ||||
|     { | ||||
|         $query = ProjectTranslationServiceHash::query() | ||||
|             ->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); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\ProjectTranslationService; | ||||
| 
 | ||||
| use App\Models\Project; | ||||
| use App\Dto\Service\Admin\Project\ServiceTranslate\Translations; | ||||
| use App\Models\ProjectTranslationService; | ||||
| 
 | ||||
| final readonly class ModelSyncCommand | ||||
| { | ||||
|     public function execute(Project $project, Translations $translations): void | ||||
|     { | ||||
|         $insert = []; | ||||
|         $delete = []; | ||||
| 
 | ||||
|         $languages = $project->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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\ProjectTranslationService; | ||||
| 
 | ||||
| use Illuminate\Support\Str; | ||||
| 
 | ||||
| final readonly class NoTranslateAttributeHandler | ||||
| { | ||||
|     public function handleAddAttribute(string $text): string | ||||
|     { | ||||
|         return Str::replace('<code', '<code translate="no"', $text); | ||||
|     } | ||||
| 
 | ||||
|     public function handleRemoveAttribute(string $text): string | ||||
|     { | ||||
|         return Str::replace('<code translate="no"', '<code', $text); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,40 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\Hashes; | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\HashStatusWaiting; | ||||
| use App\Enums\ProjectTranslationServiceHashes\Status; | ||||
| use App\Repositories\ProjectTranslationServiceHashRepository; | ||||
| 
 | ||||
| final readonly class CompletionChecker | ||||
| { | ||||
|     public function __construct( | ||||
|         private ProjectTranslationServiceHashRepository     $hashRepository, | ||||
|         private ProjectTranslationServiceHashCommandHandler $commandHandler, | ||||
|     ) { } | ||||
| 
 | ||||
|     public function execute(Hashes $hashesDto): HashStatusWaiting | ||||
|     { | ||||
|         $hashes = $this->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; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\Fields; | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\TranslateFields; | ||||
| use App\Enums\Morph; | ||||
| use App\Enums\ProjectTranslationServiceHashes\Status; | ||||
| use App\Models\ProjectTranslationServiceHash; | ||||
| use App\Repositories\ProjectTranslationServiceHashRepository; | ||||
| 
 | ||||
| final readonly class HashTrackerCommand | ||||
| { | ||||
|     public function __construct( | ||||
|         private ProjectTranslationServiceHashRepository     $hashRepository, | ||||
|         private ProjectTranslationServiceHashCommandHandler $commandHandler, | ||||
|     ) { } | ||||
| 
 | ||||
|     public function execute(Morph $morph, int $morphId, array $languages, Fields $fields): TranslateFields | ||||
|     { | ||||
|         $hashes = $this->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)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| use App\Enums\ProjectTranslationServiceHashes\Status; | ||||
| use App\Models\ProjectTranslationServiceHash; | ||||
| 
 | ||||
| final readonly class ProjectTranslationServiceHashCommandHandler | ||||
| { | ||||
|     public function handleStore(array $data): ProjectTranslationServiceHash | ||||
|     { | ||||
|         return ProjectTranslationServiceHash::create($data); | ||||
|     } | ||||
| 
 | ||||
|     public function handleUpdate(ProjectTranslationServiceHash $hash, array $data): ProjectTranslationServiceHash | ||||
|     { | ||||
|         $hash->update($data); | ||||
|         $hash->touch(); | ||||
|         return $hash; | ||||
|     } | ||||
| 
 | ||||
|     public function handleSetStatusById(array $ids, Status $status): void | ||||
|     { | ||||
|         ProjectTranslationServiceHash::query()->whereIn('id', $ids)->update(['status' => $status->value]); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\Translate\Completed\Project; | ||||
| 
 | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\Hashes; | ||||
| use App\Exceptions\Services\Translate\CompletedException; | ||||
| use App\Jobs\Translate\ProcessProjectDocumentationContent; | ||||
| use App\Models\Documentation; | ||||
| use App\Models\DocumentationContent; | ||||
| use App\Repositories\DocumentationRepository; | ||||
| use App\Services\ProjectTranslationService\NoTranslateAttributeHandler; | ||||
| use App\Services\ProjectTranslationServiceHash\CompletionChecker; | ||||
| use Illuminate\Support\Facades\DB; | ||||
| use Illuminate\Support\Facades\Log; | ||||
| use KorElf\TranslateLaravel\Contracts\TranslationCompletedListener; | ||||
| 
 | ||||
| final readonly class DocumentationContentService implements TranslationCompletedListener | ||||
| { | ||||
|     public function __construct( | ||||
|         private DocumentationRepository     $documentationRepository, | ||||
|         private NoTranslateAttributeHandler $noTranslateAttributeHandler, | ||||
|         private CompletionChecker           $completionChecker, | ||||
|     ) { } | ||||
| 
 | ||||
|     /** | ||||
|      * @throws CompletedException | ||||
|      */ | ||||
|     public function onTranslationCompleted(array $translatedText, array $data = []): void | ||||
|     { | ||||
|         if ( | ||||
|             !isset($data['projectDocumentId']) | ||||
|             || !isset($data['languageId']) | ||||
|             || !isset($data['hashes']) | ||||
|         ) { | ||||
|             throw new CompletedException('Required data is missing: projectDocumentId, languageId, or hashes.'); | ||||
|         } | ||||
| 
 | ||||
|         $documentation = $this->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; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,95 @@ | ||||
| <?php declare(strict_types=1); | ||||
| 
 | ||||
| namespace App\Services\Translate\Project; | ||||
| 
 | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\Fields; | ||||
| use App\Dto\Service\ProjectTranslationServiceHash\TranslateFields; | ||||
| use App\Enums\Morph; | ||||
| use App\Repositories\DocumentationRepository; | ||||
| use App\Repositories\ProjectTranslationServiceRepository; | ||||
| use App\ServiceResults\ServiceResultError; | ||||
| use App\ServiceResults\ServiceResultSuccess; | ||||
| use App\Services\ProjectTranslationService\NoTranslateAttributeHandler; | ||||
| use App\Services\ProjectTranslationServiceHash\HashTrackerCommand; | ||||
| use App\Services\Service; | ||||
| use App\Services\Translate\Completed\Project\DocumentationContentService as DocumentationContentServiceCompleted; | ||||
| use Illuminate\Support\Facades\DB; | ||||
| use KorElf\TranslateLaravel\DTO\AfterTranslateDto; | ||||
| use KorElf\TranslateLaravel\Facades\Translate; | ||||
| 
 | ||||
| final class DocumentationContentService extends Service | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly DocumentationRepository             $documentationRepository, | ||||
|         private readonly ProjectTranslationServiceRepository $projectTranslationServiceRepository, | ||||
|         private readonly HashTrackerCommand                  $hashTrackerCommand, | ||||
|         private readonly NoTranslateAttributeHandler         $noTranslateAttributeHandler, | ||||
|     ) {} | ||||
| 
 | ||||
|     public function translate(int $projectDocumentId, array $contentIds, array $exceptLanguages): ServiceResultSuccess | ServiceResultError | ||||
|     { | ||||
|         try { | ||||
|             $documentation = $this->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(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| <?php | ||||
| 
 | ||||
| use Illuminate\Database\Migrations\Migration; | ||||
| use Illuminate\Database\Schema\Blueprint; | ||||
| use Illuminate\Support\Facades\Schema; | ||||
| 
 | ||||
| return new class extends Migration | ||||
| { | ||||
|     /** | ||||
|      * Run the migrations. | ||||
|      */ | ||||
|     public function up(): void | ||||
|     { | ||||
|         Schema::create('project_translation_service_hashes', function (Blueprint $table) { | ||||
|             $table->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'); | ||||
|     } | ||||
| }; | ||||
| @@ -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" | ||||
| } | ||||
|   | ||||
| @@ -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": "Настройки успешно сохранены" | ||||
| } | ||||
|   | ||||
| @@ -29,7 +29,13 @@ | ||||
|              role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab"> | ||||
|             <x-volt.forms.checkbox :title="__('Edit')" :name="'content-enable-' . $language->id" | ||||
|                                    :user-value="($index === 0) ? 1 : 0" class="content-enable" checkbox-value="1" | ||||
|                                    notCheckedValue="0"/> | ||||
|                                    not-checked-value="0"/> | ||||
|             @if($serviceTranslationEnable) | ||||
|                 <x-volt.forms.checkbox :title="__('Automatic translation')" :name="'translate-automatically[' . $language->id . ']'" | ||||
|                                        :user-value="1" class="language-content" checkbox-value="1" | ||||
|                                        not-checked-value="0"/> | ||||
|             @endif | ||||
| 
 | ||||
|             <x-volt.forms.input :title="__('validation.attributes.title')" | ||||
|                                 :name="'content[' . $language->id . '][title]'" type="text" class="language-content" | ||||
|                                 :disabled="$index !== 0" :value="$content?->title ?? ''" required/> | ||||
|   | ||||
| @@ -40,6 +40,15 @@ | ||||
|                                 @endif | ||||
|                             </td> | ||||
|                             <td> | ||||
|                                 @if($serviceTranslationEnable) | ||||
|                                     @can('settingUpAutomaticTranslation', $project) | ||||
|                                         <a href="{{ route('admin.projects.service-translate.view', $project)  }}" class="btn btn-primary" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ __('Automatic translation') }}"> | ||||
|                                             <svg class="align-text-top" width="16" height="16" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> | ||||
|                                                 <path stroke-linecap="round" stroke-linejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802"></path> | ||||
|                                             </svg> | ||||
|                                         </a> | ||||
|                                     @endcan | ||||
|                                 @endif | ||||
|                                 <a href="{{ route('admin.projects.edit', $project)  }}" class="btn btn-primary" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ __('Edit') }}"> | ||||
|                                     <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="align-text-top" viewBox="0 0 16 16"> | ||||
|                                         <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user