Версия 0.4.0 #7

Merged
kor-elf merged 13 commits from develop into main 2025-02-15 00:07:48 +05:00
10 changed files with 277 additions and 0 deletions
Showing only changes of commit 844264fb62 - Show all commits

View File

@ -11,6 +11,7 @@ final readonly class StoreUpdate extends Dto
private string $title, private string $title,
private string $description, private string $description,
private Storages $storages, private Storages $storages,
private bool $isTranslateAutomatically = false,
) { } ) { }
public function getTitle(): string public function getTitle(): string
@ -27,4 +28,9 @@ final readonly class StoreUpdate extends Dto
{ {
return $this->storages; return $this->storages;
} }
public function isTranslateAutomatically(): bool
{
return $this->isTranslateAutomatically;
}
} }

View File

@ -26,6 +26,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto
'title' => ['required', 'string', 'max:255',], 'title' => ['required', 'string', 'max:255',],
'description' => ['nullable', 'string',], 'description' => ['nullable', 'string',],
'storage.content_images.*.file' => ['numeric', 'min:1'], 'storage.content_images.*.file' => ['numeric', 'min:1'],
'translate-automatically' => ['nullable', 'boolean'],
]; ];
} }
@ -35,6 +36,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto
title: $this->input('title'), title: $this->input('title'),
description: $this->input('description'), description: $this->input('description'),
storages: $this->storages(), storages: $this->storages(),
isTranslateAutomatically: (bool) $this->input('translate-automatically', false),
); );
} }

View File

@ -0,0 +1,58 @@
<?php declare(strict_types=1);
namespace App\Jobs\Translate;
use App\Services\Translate\Project\ProjectContentService;
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 ProcessProjectContent 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 $projectId,
private readonly array $contentIds = [],
private readonly array $exceptLanguages = [],
) { }
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'project-' . $this->projectId;
}
/**
* Execute the job.
* @throws Exception
*/
public function handle(ProjectContentService $projectContentService): void
{
$result = $projectContentService->translate($this->projectId, $this->contentIds, $this->exceptLanguages);
if ($result->isError() && $result->getCode() !== 404) {
cache()->lock($this->uniqueId())->forceRelease();
throw new Exception($result->getMessage());
}
}
}

View File

@ -17,5 +17,6 @@ final class ProjectContent extends Model implements StorageContract
protected $fillable = [ protected $fillable = [
'title', 'title',
'description', 'description',
'language_id',
]; ];
} }

View File

@ -5,6 +5,7 @@ namespace App\Services\Admin\Project;
use App\Contracts\ServiceResultError; use App\Contracts\ServiceResultError;
use App\Dto\Service\Admin\Project\About\StoreUpdate; use App\Dto\Service\Admin\Project\About\StoreUpdate;
use App\Enums\Morph; use App\Enums\Morph;
use App\Jobs\Translate\ProcessProjectContent;
use App\Models\ProjectContent; use App\Models\ProjectContent;
use App\Models\User; use App\Models\User;
use App\Repositories\ProjectContentRepository; use App\Repositories\ProjectContentRepository;
@ -69,6 +70,7 @@ final class AboutService extends Service
'project' => $project, 'project' => $project,
'language' => $language, 'language' => $language,
'content' => $content, 'content' => $content,
'serviceTranslationEnable' => config('translation_service.enable', false),
]); ]);
} }
@ -105,6 +107,9 @@ final class AboutService extends Service
$this->storageService->saveAndDelete($aboutProject, $storages); $this->storageService->saveAndDelete($aboutProject, $storages);
return $aboutProject; return $aboutProject;
}); });
if (\config('translation_service.enable', false)) {
$this->translateContent($aboutProject, $data);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -133,6 +138,9 @@ final class AboutService extends Service
return $aboutProject; return $aboutProject;
}); });
if (\config('translation_service.enable', false)) {
$this->translateContent($aboutProject, $data);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -148,4 +156,15 @@ final class AboutService extends Service
'description' => $data->getDescription(), 'description' => $data->getDescription(),
]; ];
} }
private function translateContent(ProjectContent $projectContent, StoreUpdate $data): void
{
if (! $data->isTranslateAutomatically()) {
return;
}
$translateExceptLanguages = [$projectContent->language_id];
$translateContentIds = [$projectContent->id];
ProcessProjectContent::dispatch($projectContent->project_id, $translateContentIds, $translateExceptLanguages);
}
} }

View File

@ -0,0 +1,89 @@
<?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\ProcessProjectContent;
use App\Models\Project;
use App\Models\ProjectContent;
use App\Repositories\ProjectRepository;
use App\Services\ProjectTranslationService\NoTranslateAttributeHandler;
use App\Services\ProjectTranslationServiceHash\CompletionChecker;
use Illuminate\Support\Facades\DB;
use KorElf\TranslateLaravel\Contracts\TranslationCompletedListener;
final readonly class ProjectContentService implements TranslationCompletedListener
{
public function __construct(
private ProjectRepository $projectRepository,
private NoTranslateAttributeHandler $noTranslateAttributeHandler,
private CompletionChecker $completionChecker,
) { }
/**
* @throws CompletedException
*/
public function onTranslationCompleted(array $translatedText, array $data = []): void
{
if (
!isset($data['projectId'])
|| !isset($data['languageId'])
|| !isset($data['hashes'])
) {
throw new CompletedException('Required data is missing: projectId, languageId, or hashes.');
}
$project = $this->projectRepository->getProjectById((int) $data['projectId']);
if ($project === null) {
return;
}
$projectContent = DB::transaction(function () use ($data, $project, $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;
}
$projectContent = $project->contents()->where('language_id', $data['languageId'])->first();
if ($projectContent !== null) {
$projectContent->update($values);
return $projectContent;
}
$values['language_id'] = $data['languageId'];
return $project->contents()->create($values);
});
if (\config('translation_service.enable', false) && $projectContent !== null) {
$this->translateContent($project, $projectContent, $data);
}
}
private function translateContent(Project $project, ProjectContent $projectContent, array $data): void
{
$translateExceptLanguages = $data['exceptLanguages'] ?? [];
$translateExceptLanguages[] = $data['languageId'];
ProcessProjectContent::dispatch($project->id, [$projectContent->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;
}
}

View File

@ -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\ProjectRepository;
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\ProjectContentService as ProjectContentServiceCompleted;
use Illuminate\Support\Facades\DB;
use KorElf\TranslateLaravel\DTO\AfterTranslateDto;
use KorElf\TranslateLaravel\Facades\Translate;
final class ProjectContentService extends Service
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly ProjectTranslationServiceRepository $projectTranslationServiceRepository,
private readonly HashTrackerCommand $hashTrackerCommand,
private readonly NoTranslateAttributeHandler $noTranslateAttributeHandler,
) {}
public function translate(int $projectId, array $contentIds, array $exceptLanguages): ServiceResultSuccess | ServiceResultError
{
try {
$project = $this->projectRepository->getProjectById($projectId);
if ($project === null) {
return $this->errNotFound(__('Project not found'));
}
$contents = $project->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('description', $content->description ?? '');
$translations = DB::transaction(function () use ($content, $translateIntoLanguage, $fields) {
return $this->hashTrackerCommand->execute(Morph::ProjectContent, $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(ProjectContentServiceCompleted::class, [
'projectId' => $projectId,
'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();
}
}

View File

@ -310,6 +310,7 @@ return [
'content_images' => 'content images', 'content_images' => 'content images',
'source_language_id' => 'source language identifier', 'source_language_id' => 'source language identifier',
'translate_from_language' => 'translate from language', 'translate_from_language' => 'translate from language',
'translate-automatically' => 'translate automatically',
'translate-automatically.*' => 'translate automatically', 'translate-automatically.*' => 'translate automatically',
], ],
]; ];

View File

@ -310,6 +310,7 @@ return [
'content_images' => 'изображения контента', 'content_images' => 'изображения контента',
'source_language_id' => 'идентификатор исходного языка', 'source_language_id' => 'идентификатор исходного языка',
'translate_from_language' => 'перевести с языка', 'translate_from_language' => 'перевести с языка',
'translate-automatically' => 'переводить автоматически',
'translate-automatically.*' => 'переводить автоматически', 'translate-automatically.*' => 'переводить автоматически',
], ],
]; ];

View File

@ -1,4 +1,9 @@
@csrf @csrf
@if($serviceTranslationEnable)
<x-volt.forms.checkbox :title="__('Automatic translation')" name="translate-automatically"
:user-value="1" class="language-content" checkbox-value="1"
not-checked-value="0"/>
@endif
<x-volt.forms.input :title="__('validation.attributes.title')" name="title" type="text" :value="$content->title" required autofocus /> <x-volt.forms.input :title="__('validation.attributes.title')" name="title" type="text" :value="$content->title" required autofocus />
<x-volt.forms.textarea-wysiwyg <x-volt.forms.textarea-wysiwyg
:title="__('validation.attributes.description')" :title="__('validation.attributes.description')"