Версия 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 300 additions and 5 deletions
Showing only changes of commit 4dc6060fb3 - Show all commits

View File

@ -7,6 +7,7 @@ final readonly class Content
public function __construct( public function __construct(
private int $languageId, private int $languageId,
private string $title, private string $title,
private bool $isTranslateAutomatically = false,
) { } ) { }
public function getLanguageId(): int public function getLanguageId(): int
@ -18,4 +19,9 @@ final readonly class Content
{ {
return $this->title; return $this->title;
} }
public function isTranslateAutomatically(): bool
{
return $this->isTranslateAutomatically;
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Enums; namespace App\Enums;
use App\Models\DocumentationCategoryContent;
use App\Models\DocumentationContent; use App\Models\DocumentationContent;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectContent; use App\Models\ProjectContent;
@ -11,6 +12,7 @@ enum Morph: int
case Project = 1; case Project = 1;
case DocumentationContent = 2; case DocumentationContent = 2;
case ProjectContent = 3; case ProjectContent = 3;
case DocumentationCategoryContent = 4;
public function getPathModel(): string public function getPathModel(): string
{ {
@ -18,6 +20,7 @@ enum Morph: int
self::Project => Project::class, self::Project => Project::class,
self::DocumentationContent => DocumentationContent::class, self::DocumentationContent => DocumentationContent::class,
self::ProjectContent => ProjectContent::class, self::ProjectContent => ProjectContent::class,
self::DocumentationCategoryContent => DocumentationCategoryContent::class,
}; };
} }

View File

@ -21,6 +21,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto
'sort' => ['required', 'integer', 'min:-1000', 'max:1000'], 'sort' => ['required', 'integer', 'min:-1000', 'max:1000'],
'parent_id' => ['nullable', 'integer', 'exists:documentation_categories,id'], 'parent_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'content.*.title' => ['required', 'string', 'max:255'], 'content.*.title' => ['required', 'string', 'max:255'],
'translate-automatically.*' => ['nullable', 'boolean'],
]; ];
} }
@ -47,6 +48,7 @@ final class StoreUpdateRequest extends FormRequest implements FormRequestDto
$contents->addContent(new Content( $contents->addContent(new Content(
languageId: (int) $languageId, languageId: (int) $languageId,
title: $content['title'], title: $content['title'],
isTranslateAutomatically: (bool) $this->input('translate-automatically.' . $languageId, false),
)); ));
} }
return $contents; return $contents;

View File

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

View File

@ -5,8 +5,11 @@ namespace App\Services\Admin\Project;
use App\Dto\QuerySettingsDto; use App\Dto\QuerySettingsDto;
use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto; use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto;
use App\Dto\Service\Admin\Project\DocumentationCategory\StoreUpdate; use App\Dto\Service\Admin\Project\DocumentationCategory\StoreUpdate;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Content;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Contents;
use App\Exceptions\Services\DocumentationCategory\ParentException; use App\Exceptions\Services\DocumentationCategory\ParentException;
use App\Exceptions\Services\ServiceException; use App\Exceptions\Services\ServiceException;
use App\Jobs\Translate\ProcessProjectDocumentationCategoryContent;
use App\Models\DocumentationCategory; use App\Models\DocumentationCategory;
use App\Models\DocumentationCategoryContent; use App\Models\DocumentationCategoryContent;
use App\Models\ProjectLanguage; use App\Models\ProjectLanguage;
@ -90,6 +93,7 @@ final class DocumentationCategoryService extends Service
'project' => $project, 'project' => $project,
'category' => new DocumentationCategory(), 'category' => new DocumentationCategory(),
'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage), 'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage),
'serviceTranslationEnable' => config('translation_service.enable', false),
]); ]);
} }
@ -120,6 +124,7 @@ final class DocumentationCategoryService extends Service
'project' => $project, 'project' => $project,
'category' => $category, 'category' => $category,
'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage, $category, $withCategories), 'categories' => $this->documentationCategoryRepository->getForSelect($version, $defaultLanguage, $category, $withCategories),
'serviceTranslationEnable' => config('translation_service.enable', false),
]); ]);
} }
@ -151,6 +156,10 @@ final class DocumentationCategoryService extends Service
return $category; return $category;
}); });
if (\config('translation_service.enable', false)) {
$this->translateContent($category, $data->getContents());
}
} catch (ServiceException $e) { } catch (ServiceException $e) {
return $e->getServiceResultError(); return $e->getServiceResultError();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -201,6 +210,10 @@ final class DocumentationCategoryService extends Service
return $category; return $category;
}); });
if (\config('translation_service.enable', false)) {
$this->translateContent($category, $data->getContents());
}
} catch (ServiceException $e) { } catch (ServiceException $e) {
return $e->getServiceResultError(); return $e->getServiceResultError();
} catch (ParentException $e) { } catch (ParentException $e) {
@ -254,4 +267,27 @@ final class DocumentationCategoryService extends Service
'parent_id' => $data->getParentId(), 'parent_id' => $data->getParentId(),
]; ];
} }
private function translateContent(DocumentationCategory $category, 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 = $category->contents()->select('id')
->whereIn('language_id', $translateLanguages)
->get()->pluck('id')->toArray();
ProcessProjectDocumentationCategoryContent::dispatch($category->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\ProcessProjectDocumentationCategoryContent;
use App\Models\DocumentationCategory;
use App\Models\DocumentationCategoryContent;
use App\Repositories\DocumentationCategoryRepository;
use App\Services\ProjectTranslationService\NoTranslateAttributeHandler;
use App\Services\ProjectTranslationServiceHash\CompletionChecker;
use Illuminate\Support\Facades\DB;
use KorElf\TranslateLaravel\Contracts\TranslationCompletedListener;
final class DocumentationCategoryContentService implements TranslationCompletedListener
{
public function __construct(
private DocumentationCategoryRepository $categoryRepository,
private NoTranslateAttributeHandler $noTranslateAttributeHandler,
private CompletionChecker $completionChecker,
) { }
/**
* @throws CompletedException
*/
public function onTranslationCompleted(array $translatedText, array $data = []): void
{
if (
!isset($data['categoryId'])
|| !isset($data['languageId'])
|| !isset($data['hashes'])
) {
throw new CompletedException('Required data is missing: categoryId, languageId, or hashes.');
}
$category = $this->categoryRepository->getCategoryById((int) $data['categoryId']);
if ($category === null) {
return;
}
$categoryContent = DB::transaction(function () use ($data, $category, $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;
}
$categoryContent = $category->contents()->where('language_id', $data['languageId'])->first();
if ($categoryContent !== null) {
$categoryContent->update($values);
return $categoryContent;
}
$values['language_id'] = $data['languageId'];
return $category->contents()->create($values);
});
if (\config('translation_service.enable', false) && $categoryContent !== null) {
$this->translateContent($category, $categoryContent, $data);
}
}
private function translateContent(DocumentationCategory $category, DocumentationCategoryContent $categoryContent, array $data): void
{
$translateExceptLanguages = $data['exceptLanguages'] ?? [];
$translateExceptLanguages[] = $data['languageId'];
ProcessProjectDocumentationCategoryContent::dispatch($category->id, [$categoryContent->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,94 @@
<?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\DocumentationCategoryRepository;
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\Translate\Completed\Project\DocumentationCategoryContentService as DocumentationCategoryContentServiceCompleted;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use KorElf\TranslateLaravel\DTO\AfterTranslateDto;
use KorElf\TranslateLaravel\Facades\Translate;
final class DocumentationCategoryContentService extends Service
{
public function __construct(
private readonly DocumentationCategoryRepository $documentationCategoryRepository,
private readonly ProjectTranslationServiceRepository $projectTranslationServiceRepository,
private readonly HashTrackerCommand $hashTrackerCommand,
private readonly NoTranslateAttributeHandler $noTranslateAttributeHandler,
) {}
public function translate(int $categoryId, array $contentIds, array $exceptLanguages): ServiceResultSuccess | ServiceResultError
{
try {
$category = $this->documentationCategoryRepository->getCategoryById($categoryId);
if ($category === null) {
return $this->errNotFound(__('Category not found'));
}
$contents = $category->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 ?? '');
$translations = DB::transaction(function () use ($content, $translateIntoLanguage, $fields) {
return $this->hashTrackerCommand->execute(Morph::DocumentationCategoryContent, $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(DocumentationCategoryContentServiceCompleted::class, [
'categoryId' => $categoryId,
'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

@ -271,5 +271,6 @@
"Category successfully deleted": "Category successfully deleted", "Category successfully deleted": "Category successfully deleted",
"Old Files deleted": "Old Files deleted", "Old Files deleted": "Old Files deleted",
"Automatic translation": "Automatic translation", "Automatic translation": "Automatic translation",
"The settings were saved successfully": "The settings were saved successfully" "The settings were saved successfully": "The settings were saved successfully",
"Category not found": "Category not found"
} }

View File

@ -271,5 +271,6 @@
"Category successfully deleted": "Категория успешно удалена", "Category successfully deleted": "Категория успешно удалена",
"Old Files deleted": "Старые файлы удалены", "Old Files deleted": "Старые файлы удалены",
"Automatic translation": "Автоматический перевод", "Automatic translation": "Автоматический перевод",
"The settings were saved successfully": "Настройки успешно сохранены" "The settings were saved successfully": "Настройки успешно сохранены",
"Category not found": "Категория не найдена"
} }

View File

@ -20,6 +20,11 @@
@endphp @endphp
<div class="tab-pane fade @if($index === 0) show active @endif" id="language-{{ $language->id }}" role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab"> <div class="tab-pane fade @if($index === 0) show active @endif" id="language-{{ $language->id }}" 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"/> <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"/>
@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 /> <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 />
</div> </div>
@endforeach @endforeach