diff --git a/bundle/Command/TranslateContentCommand.php b/bundle/Command/TranslateContentCommand.php index 77d486c..49ee5aa 100644 --- a/bundle/Command/TranslateContentCommand.php +++ b/bundle/Command/TranslateContentCommand.php @@ -8,48 +8,77 @@ namespace EzSystems\EzPlatformAutomatedTranslationBundle\Command; +use Doctrine\ORM\EntityManagerInterface; +use \Exception; +use EzSystems\EzPlatformAutomatedTranslationBundle\Entity\AutoTranslationActions; +use EzSystems\EzPlatformAutomatedTranslationBundle\Handler\AutoTranslationActionsHandler; use Ibexa\Contracts\Core\Repository\ContentService; +use Ibexa\Contracts\Core\Repository\LocationService; +use Ibexa\Contracts\Core\Repository\Exceptions\BadStateException; +use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; +use Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException; use Ibexa\Contracts\Core\Repository\PermissionResolver; +use Ibexa\Contracts\Core\Repository\Repository; use Ibexa\Contracts\Core\Repository\UserService; use EzSystems\EzPlatformAutomatedTranslation\ClientProvider; use EzSystems\EzPlatformAutomatedTranslation\Translator; +use Ibexa\Contracts\Core\Repository\Values\Content\Content; +use Pagerfanta\Doctrine\DBAL\QueryAdapter; +use Pagerfanta\Pagerfanta; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Ibexa\Migration\Log\LoggerAwareTrait; +use RuntimeException; +use Ibexa\Core\Base\Exceptions\InvalidArgumentException; +use Symfony\Component\Console\Command\LockableTrait; +use Symfony\Component\Process\Process; +use Pagerfanta\Adapter\CallbackAdapter; final class TranslateContentCommand extends Command { - private const ADMINISTRATOR_USER_ID = 14; - - /** @var Translator */ - private $translator; - - /** @var ClientProvider */ - private $clientProvider; + use LockableTrait; + use LoggerAwareTrait; - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ - private $contentService; - - /** @var \Ibexa\Contracts\Core\Repository\PermissionResolver */ - private $permissionResolver; + private const ADMINISTRATOR_USER_ID = 14; - /** @var \Ibexa\Contracts\Core\Repository\UserService */ - private $userService; + protected EntityManagerInterface $em; + protected Translator $translator; + protected ClientProvider $clientProvider; + protected ContentService $contentService; + protected LocationService $locationService; + protected PermissionResolver $permissionResolver; + protected UserService $userService; + protected Repository $repository; + protected AutoTranslationActionsHandler $handler; public function __construct( + EntityManagerInterface $em, Translator $translator, ClientProvider $clientProvider, ContentService $contentService, + LocationService $locationService, PermissionResolver $permissionResolver, - UserService $userService + UserService $userService, + Repository $repository, + AutoTranslationActionsHandler $handler, + ?LoggerInterface $logger = null ) { + $this->em = $em; $this->clientProvider = $clientProvider; $this->translator = $translator; $this->contentService = $contentService; + $this->locationService = $locationService; $this->permissionResolver = $permissionResolver; $this->userService = $userService; + $this->repository = $repository; + $this->handler = $handler; + $this->logger = $logger ?? new NullLogger(); parent::__construct(); } @@ -60,38 +89,318 @@ protected function configure(): void ->setName('ezplatform:automated:translate') ->setAliases(['eztranslate']) ->setDescription('Translate a Content in a new Language') - ->addArgument('contentId', InputArgument::REQUIRED, 'ContentId') ->addArgument( 'service', InputArgument::REQUIRED, 'Remote Service for Translation. [' . implode(' ', array_keys($this->clientProvider->getClients())) . ']' ) - ->addOption('from', '--from', InputOption::VALUE_REQUIRED, 'Source Language') - ->addOption('to', '--to', InputOption::VALUE_REQUIRED, 'Target Language'); + ->addArgument('contentId', InputArgument::OPTIONAL, 'ContentId') + ->addOption('from', '--from', InputOption::VALUE_OPTIONAL, 'Source Language Code') + ->addOption('to', '--to', InputOption::VALUE_OPTIONAL, 'Target Language Code') + ->addOption('user', '--user', InputOption::VALUE_OPTIONAL, 'The user id to publish new version with.') + ->addOption('overwrite', '--overwrite', InputOption::VALUE_NONE, 'Overwrites existing translations') + ->addOption('sudo', '--sudo', InputOption::VALUE_NONE, 'Force publication with admin user.') + ; } - protected function execute(InputInterface $input, OutputInterface $output): int + { + $cmdStatus = Command::SUCCESS; + $logMessage = ''; + if ($input->hasArgument('contentId') && (int)$input->getArgument('contentId')) { + $this->subExecute($input, $output); + } else { + if (!$this->lock()) { + $output->writeln('The command ezplatform:automated:translate is already running in another process.'); + return 0; + } + $action = $this->handler->getFirstPendingAction(); + + $message = sprintf('Start the automated translate.'); + $this->getLogger()->info($message); + $output->writeln($message); + if(empty($action)){ + $message = sprintf('There is no pending action.'); + $this->getLogger()->info($message); + $output->writeln($message); + } + if (!empty($action)) { + unset( + $action['created_at'], + $action['finished_at'], + $action['content_name'], + $action['user_name'], + $action['user_name'], + $action['content_id'] + ); + [ + $actionId, + $subtreeId, + $userId, + $targetLanguage, + $overwrite + ] = array_values($action); + + $overwrite = (bool) $overwrite; + /** @var AutoTranslationActions $autoTranslationActions */ + $autoTranslationActions = $this->em->getRepository(AutoTranslationActions::class)->find($actionId); + $autoTranslationActions->setStatus(AutoTranslationActions::STATUS_IN_PROGRESS); + $this->em->flush($autoTranslationActions); + try { + $subtreeLocation = $this->locationService->loadLocation($subtreeId); + + $message = sprintf('Start Translation of subtree %s.', $subtreeLocation->pathString); + $this->getLogger()->info($message); + $output->writeln($message); + $logMessage .= $message .'
'; + + $adapter = new CallbackAdapter( + function () use ($subtreeLocation): int { + return $this->handler->countContentWithRelationsInSubtree($subtreeLocation->pathString); + }, + function (int $offset, int $limit) use ($subtreeLocation): iterable { + return $this->handler->getContentsWithRelationsInSubtree($subtreeLocation->pathString, $offset, $limit); + } + ); + $currentPage = 1; + $maxPerPage = 1; + $pager = new Pagerfanta( + $adapter + ); + $pager->setMaxPerPage($maxPerPage); + $pager->setCurrentPage($currentPage); + $i = 0; + $message = sprintf('Translate "%s" contents.', $pager->count()); + $this->getLogger()->info($message); + $output->writeln($message); + $logMessage .= $message .'
'; + + $progressBar = new ProgressBar($output, $pager->count()); + + if ($pager->count() > 0) { + do { + $i++; + $adapter = new CallbackAdapter( + function () use ($subtreeLocation): int { + return $this->handler->countContentWithRelationsInSubtree($subtreeLocation->pathString); + }, + function (int $offset, int $limit) use ($subtreeLocation): iterable { + return $this->handler->getContentsWithRelationsInSubtree($subtreeLocation->pathString, $offset, $limit); + } + ); + $pager = new Pagerfanta( + $adapter + ); + $pager->setMaxPerPage($maxPerPage); + $pager->setCurrentPage($currentPage); + $contentIds = []; + /** @var Content $content */ + foreach ($pager->getCurrentPageResults() as $result) { + $contentIds[] = $result['contentId']; + } + $processes = $this->getPhpProcess( + $contentIds[0], + $targetLanguage, + $userId, + $overwrite, + $input + ); + $processes->run(); + $processes->getErrorOutput(); + $logMessage .= $processes->getOutput() .'
'; + if (!empty($processes->getErrorOutput())) { + $message = $processes->getErrorOutput(); + $this->getLogger()->info($message); + $logMessage .= $message .'
'; + } else { + $logMessage .= $processes->getOutput() .'
'; + } + $currentPage++; + $progressBar->advance($maxPerPage); + } while ($pager->hasNextPage() && $i < 2000); + $progressBar->finish(); + } + // clear leftover progress bar parts + $progressBar->clear(); + } catch (Exception $e) { + $logMessage = $e->getMessage(); + $this->logException($e); + $cmdStatus = Command::FAILURE; + } + + $autoTranslationActions->setStatus($cmdStatus === Command::FAILURE ? + AutoTranslationActions::STATUS_FAILED : AutoTranslationActions::STATUS_FINISHED + ); + $autoTranslationActions->setLogMessage($logMessage); + $autoTranslationActions->setFinishedAt(new \DateTime()); + $this->em->flush($autoTranslationActions); + } + $output->writeln(''); + $output->writeln('Finished'); + $this->getLogger()->info('Finished'); + $output->writeln(''); + } + + return $cmdStatus; + } + protected function subExecute(InputInterface $input, OutputInterface $output): int { $contentId = (int) $input->getArgument('contentId'); + $languageCodeFrom = $input->getOption('from'); + $languageCodeTo = $input->getOption('to'); + $serviceApi = $input->getArgument('service'); + $overwrite = $input->getOption('overwrite'); + + $status = Command::SUCCESS; + if ($input->getOption('sudo')) { + $status = $this->permissionResolver->sudo(function () use ( + $contentId, + $languageCodeFrom, + $languageCodeTo, + $serviceApi, + $overwrite, + $input, + $output + ) { + try { + $this->publishVersion($contentId, $languageCodeFrom, $languageCodeTo, $serviceApi, $overwrite); + $this->logDoneMessage($contentId, $languageCodeFrom, $languageCodeTo, $output); + return Command::SUCCESS; + } catch (Exception $e) { + $this->logFailedMessage($contentId, $e); + return Command::FAILURE; + } + }, $this->repository); + } else { + try { + $this->publishVersion($contentId, $languageCodeFrom, $languageCodeTo, $serviceApi, $overwrite); + $this->logDoneMessage($contentId, $languageCodeFrom, $languageCodeTo, $output); + } catch (\Throwable $e) { + $this->logFailedMessage($contentId, $e); + $status = Command::FAILURE; + } + } + + return $status; + } + + /** + * @throws BadStateException + * @throws NotFoundException + * @throws UnauthorizedException + * @throws InvalidArgumentException + */ + protected function publishVersion( + int $contentId, + ?string &$languageCodeFrom, + string $languageCodeTo, + string $serviceApi, + bool $overwrite = false + ): array { $content = $this->contentService->loadContent($contentId); + $languageCodeFrom = $languageCodeFrom ?? $content->contentInfo->mainLanguageCode; + + if($languageCodeFrom == $languageCodeTo) { + $message = sprintf('The target language from argument --to=%s must be different from the source language --from and the main language of the content %s .', + $languageCodeTo, + $languageCodeFrom, + ); + throw new InvalidArgumentException('--from', $message); + } + + if(!$overwrite && in_array($languageCodeTo,$content->getVersionInfo()->languageCodes)) { + $message = sprintf('The content %d already translated into language --to=%s . use the --overwrite option. ', + $contentId, + $languageCodeTo + ); + throw new InvalidArgumentException('--to', $message); + } + $draft = $this->translator->getTranslatedContent( - $input->getOption('from'), - $input->getOption('to'), - $input->getArgument('service'), + $languageCodeFrom, + $languageCodeTo, + $serviceApi, $content ); - $this->contentService->publishVersion($draft->versionInfo); - $output->writeln("Translation to {$contentId} Done."); + $newContentVersion = $this->contentService->publishVersion($draft->versionInfo); - return Command::SUCCESS; + return [$content, $newContentVersion]; } + protected function logDoneMessage( + int $contentId, + string $languageCodeFrom, + string $languageCodeTo, + OutputInterface $output + ): void + { + $message = sprintf( + 'Translation of content %d from %s to %s Done.', + $contentId, + $languageCodeFrom, + $languageCodeTo + ); + $this->getLogger()->info($message); + $output->writeln($message); + } + protected function logFailedMessage(int $contentId, Exception $e): void + { + $message = sprintf( + 'Translation to %d Failed', + $contentId + ); + $this->logException($e, $message); + } + protected function logException(Exception $e, string $message = ''): void + { + $message = sprintf( + '%s. %s', + $message, + $e->getMessage(), + ); + $exception = new RuntimeException($message, $e->getCode(), $e); + $this->getLogger()->error($message, [ + 'exception' => $exception, + ]); + } protected function initialize(InputInterface $input, OutputInterface $output): void { parent::initialize($input, $output); + $userId = (int) ($input->getOption('user') ?? self::ADMINISTRATOR_USER_ID); + $this->permissionResolver->setCurrentUserReference( - $this->userService->loadUser(self::ADMINISTRATOR_USER_ID) + $this->userService->loadUser($userId) ); } + + private function getPhpProcess(int $contentId, string $targetLanguage, int $userId, bool $overwrite, InputInterface $input): Process + { + $env = $input->getParameterOption(['--env', '-e'], getenv('APP_ENV') ?: 'dev', true); + $serviceApi = $input->getArgument('service'); + $sudo = $input->getOption('sudo'); + + $subProcessArgs = [ + 'php', + 'bin/console', + $this->getName(), + $serviceApi, + $contentId, + '--user=' . $userId, + '--to=' . $targetLanguage, + '--env=' . $env + ]; + + if ($overwrite) { + $subProcessArgs[] = '--overwrite'; + } + + if ($sudo) { + $subProcessArgs[] = '--sudo'; + } + + $process = new Process($subProcessArgs); + $process->setTimeout(null); + + return $process; + } } diff --git a/bundle/Controller/BaseController.php b/bundle/Controller/BaseController.php new file mode 100644 index 0000000..93cd1ba --- /dev/null +++ b/bundle/Controller/BaseController.php @@ -0,0 +1,87 @@ +permissionResolver = $permissionResolver; + } + + /** + * @required + */ + public function setTranslator(TranslatorInterface $translator): void + { + $this->translator = $translator; + } + + /** + * @required + */ + public function setNotificationHandler(NotificationHandlerInterface $notificationHandler): void + { + $this->notificationHandler = $notificationHandler; + } + + /** + * @throws InvalidArgumentException + */ + protected function permissionAccess(string $module, string $function) + { + if (!$this->permissionResolver->hasAccess($module, $function)) { + $exception = $this->createAccessDeniedException($this->translator->trans( + 'auto_translation.permission.failed' + )); + $exception->setAttributes(null); + $exception->setSubject(null); + + throw $exception; + } + + return null; + } + + /** + * @throws InvalidArgumentException + */ + protected function permissionManageAccess(string $module, array $functions): array + { + $access = []; + foreach ($functions as $function) { + $access[$function] = true; + if (!$this->permissionResolver->hasAccess($module, $function)) { + $access[$function] = false; + } + } + + return $access; + } +} diff --git a/bundle/Controller/TranslationSubtreeController.php b/bundle/Controller/TranslationSubtreeController.php new file mode 100644 index 0000000..ab2b8c7 --- /dev/null +++ b/bundle/Controller/TranslationSubtreeController.php @@ -0,0 +1,113 @@ +permissionAccess('auto_translation', 'view'); + + $formSearch = $this->createForm(AutoTranslationActionsSearchType::class , null, [ + 'action' => $this->generateUrl('automated_translation_index'), + 'method' => 'GET', + ]); + + $formSearch->handleRequest($request); + $page = $request->query->get('page' ,1); + + $adapter = new QueryAdapter( + $handler->getAllQuery($formSearch->getData()['sort'] ?? []), + function ($queryBuilder) use ($handler) { + return $handler->countAll($queryBuilder); + }); + $pagerfanta = new Pagerfanta( + $adapter + ); + + $pagerfanta->setMaxPerPage($this->defaultPaginationLimit); + $pagerfanta->setCurrentPage($page); + + $autoTranslationActions = new AutoTranslationActions(); + $form = $this->createForm(AutoTranslationActionsType::class, $autoTranslationActions, [ + 'action' => $this->generateUrl('automated_translation_add'), + 'method' => 'POST', + ]); + + return $this->render( '@ibexadesign/auto_translation/view.html.twig', [ + 'form' => $form->createView(), + 'form_search' => $formSearch->createView(), + 'pager' => $pagerfanta, + 'canCreate' => $this->permissionResolver->hasAccess('auto_translation', 'create'), + 'canDelete' => $this->permissionResolver->hasAccess('auto_translation', 'delete'), + ]); + } + /** + * @Route("/add", name="automated_translation_add") + */ + public function addAction( + Request $request, + EntityManagerInterface $em, + LocationService $locationService, + TranslatableNotificationHandlerInterface $notificationHandler + ): RedirectResponse { + $this->permissionAccess('auto_translation', 'create'); + + $autoTranslationActions = new AutoTranslationActions(); + $form = $this->createForm(AutoTranslationActionsType::class, $autoTranslationActions); + $form->handleRequest($request); + /** + * @var User $user + */ + $user = $this->getUser(); + + if ($form->isSubmitted() && $form->isValid()) { + $autoTranslationActions->setStatus(AutoTranslationActions::STATUS_PENDING); + $autoTranslationActions->setUserId($user->getAPIUser()->id); + $em->persist($autoTranslationActions); + $em->flush(); + try { + $location = $locationService->loadLocation($autoTranslationActions->getSubtreeId()); + $this->notificationHandler->success($this->translator->trans( + 'auto_translation.add.success' ,['%subtree_name%' => $location->contentInfo->name ] + )); + } catch (NotFoundException|UnauthorizedException $e) { + $this->notificationHandler->error($e->getMessage()); + } + } + + return new RedirectResponse($this->generateUrl( + 'automated_translation_index' + )); + } +} diff --git a/bundle/DependencyInjection/Configuration.php b/bundle/DependencyInjection/Configuration.php index 59b720a..5392c0b 100644 --- a/bundle/DependencyInjection/Configuration.php +++ b/bundle/DependencyInjection/Configuration.php @@ -8,7 +8,7 @@ namespace EzSystems\EzPlatformAutomatedTranslationBundle\DependencyInjection; -use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Configuration\SiteAccessAware; +use Ibexa\Bundle\Core\DependencyInjection\Configuration\SiteAccessAware; use Symfony\Component\Config\Definition\Builder\TreeBuilder; class Configuration extends SiteAccessAware\Configuration @@ -22,8 +22,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->variableNode('configurations')->end() ->arrayNode('non_translatable_characters')->end() ->arrayNode('non_translatable_tags')->end() - ->arrayNode('non_translatable_self_closed_tags')->end(); - + ->arrayNode('non_translatable_self_closed_tags')->end() + ->arrayNode('exclude_attribute')->end(); return $treeBuilder; } } diff --git a/bundle/DependencyInjection/EzPlatformAutomatedTranslationExtension.php b/bundle/DependencyInjection/EzPlatformAutomatedTranslationExtension.php index 12bdbd7..ca8cdc7 100644 --- a/bundle/DependencyInjection/EzPlatformAutomatedTranslationExtension.php +++ b/bundle/DependencyInjection/EzPlatformAutomatedTranslationExtension.php @@ -8,15 +8,18 @@ namespace EzSystems\EzPlatformAutomatedTranslationBundle\DependencyInjection; -use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Configuration\SiteAccessAware\ConfigurationProcessor; +use Ibexa\Bundle\Core\DependencyInjection\Configuration\SiteAccessAware\ConfigurationProcessor; use EzSystems\EzPlatformAutomatedTranslation\Encoder\BlockAttribute\BlockAttributeEncoderInterface; use EzSystems\EzPlatformAutomatedTranslation\Encoder\Field\FieldEncoderInterface; use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Yaml\Yaml; -class EzPlatformAutomatedTranslationExtension extends Extension +class EzPlatformAutomatedTranslationExtension extends Extension implements PrependExtensionInterface { /** * {@inheritdoc} @@ -27,8 +30,6 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - // always needed because of Bundle extension. - $loader->load('services_override.yml'); $container->registerForAutoconfiguration(FieldEncoderInterface::class) ->addTag('ezplatform.automated_translation.field_encoder'); @@ -44,9 +45,10 @@ public function load(array $configs, ContainerBuilder $container): void return; } - $loader->load('ezadminui.yml'); $loader->load('default_settings.yml'); $loader->load('services.yml'); + // always needed because of Bundle extension. + $loader->load('services_override.yml'); $processor = new ConfigurationProcessor($container, $this->getAlias()); $processor->mapSetting('configurations', $config); @@ -66,4 +68,21 @@ private function hasConfiguredClients(array $config, ContainerBuilder $container }); })); } + + /** + * Allow an extension to prepend the extension configurations. + */ + public function prepend(ContainerBuilder $container): void + { + $configs = [ + 'universal_discovery_widget.yaml' => 'ibexa', + ]; + + foreach ($configs as $fileName => $extensionName) { + $configFile = __DIR__.'/../Resources/config/'.$fileName; + $config = Yaml::parse(file_get_contents($configFile)); + $container->prependExtensionConfig($extensionName, $config); + $container->addResource(new FileResource($configFile)); + } + } } diff --git a/bundle/DependencyInjection/Security/Provider/AutoTranslationPolicyProvider.php b/bundle/DependencyInjection/Security/Provider/AutoTranslationPolicyProvider.php new file mode 100644 index 0000000..66e807c --- /dev/null +++ b/bundle/DependencyInjection/Security/Provider/AutoTranslationPolicyProvider.php @@ -0,0 +1,26 @@ +id; + } + + public function getSubtreeId(): int + { + return $this->subtreeId; + } + + public function setSubtreeId(int $subtreeId): self + { + $this->subtreeId = $subtreeId; + return $this; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function setUserId(int $userId): self + { + $this->userId = $userId; + return $this; + } + + public function getTargetLanguage(): string + { + return $this->targetLanguage; + } + + public function setTargetLanguage(string $targetLanguage): self + { + $this->targetLanguage = $targetLanguage; + return $this; + } + + public function isOverwrite(): bool + { + return $this->overwrite; + } + + public function setOverwrite(bool $overwrite): self + { + $this->overwrite = $overwrite; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getCreatedAt(): ?DateTime + { + return $this->createdAt; + } + + /** + * @return DateTime|null + */ + public function getFinishedAt(): ?DateTime + { + return $this->finishedAt; + } + + /** + * @param DateTime|null $finishedAt + */ + public function setFinishedAt(?DateTime $finishedAt = null): void + { + $this->finishedAt = $finishedAt; + } + + /** + * @return string|null + */ + public function getLogMessage(): ?string + { + return $this->logMessage; + } + + /** + * @param string|null $logMessage + */ + public function setLogMessage(?string $logMessage): void + { + $this->logMessage = $logMessage; + } + + /** + * @ORM\PrePersist + */ + public function updatedTimestamps(): void + { + $dateTimeNow = new DateTime('now'); + if ($this->getCreatedAt() === null) { + $this->setCreatedAt($dateTimeNow); + } + } +} diff --git a/bundle/Event/FieldEncodeEvent.php b/bundle/Event/FieldEncodeEvent.php index 6104c38..d74c299 100644 --- a/bundle/Event/FieldEncodeEvent.php +++ b/bundle/Event/FieldEncodeEvent.php @@ -12,7 +12,7 @@ final class FieldEncodeEvent { - /** @var \eZ\Publish\API\Repository\Values\Content\Field */ + /** @var Field */ private $field; /** @var string */ diff --git a/bundle/Event/RichTextDecodeEvent.php b/bundle/Event/RichTextDecodeEvent.php new file mode 100644 index 0000000..f7b277f --- /dev/null +++ b/bundle/Event/RichTextDecodeEvent.php @@ -0,0 +1,24 @@ +xmlValue; + } + + public function setValue(string $value): void + { + $this->xmlValue = $value; + } +} diff --git a/bundle/Event/RichTextEncodeEvent.php b/bundle/Event/RichTextEncodeEvent.php new file mode 100644 index 0000000..53bc043 --- /dev/null +++ b/bundle/Event/RichTextEncodeEvent.php @@ -0,0 +1,24 @@ +xmlValue; + } + + public function setValue(string $value): void + { + $this->xmlValue = $value; + } +} diff --git a/bundle/EventListener/ContentProxyTranslateListener.php b/bundle/EventListener/ContentProxyTranslateListener.php index 10c5f1f..dc73ac2 100644 --- a/bundle/EventListener/ContentProxyTranslateListener.php +++ b/bundle/EventListener/ContentProxyTranslateListener.php @@ -18,16 +18,16 @@ class ContentProxyTranslateListener implements EventSubscriberInterface { - /** @var \Symfony\Component\HttpFoundation\RequestStack */ + /** @var RequestStack */ private $requestStack; - /** @var \EzSystems\EzPlatformAutomatedTranslation\Translator */ + /** @var Translator */ private $translator; - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ + /** @var ContentService */ private $contentService; - /** @var \Symfony\Component\Routing\RouterInterface */ + /** @var RouterInterface */ private $router; public function __construct( diff --git a/bundle/EventListener/MenuSubscriber.php b/bundle/EventListener/MenuSubscriber.php new file mode 100644 index 0000000..5df77f4 --- /dev/null +++ b/bundle/EventListener/MenuSubscriber.php @@ -0,0 +1,46 @@ +permissionResolver = $permissionResolver; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + ConfigureMenuEvent::MAIN_MENU => ['onMainMenuConfigure', 0], + ]; + } + + public function onMainMenuConfigure(ConfigureMenuEvent $event): void + { + $menu = $event->getMenu(); + if ($this->permissionResolver->hasAccess('auto_translation', 'view')) { + $menu[MainMenuBuilder::ITEM_CONTENT]->addChild( + 'content_translation_translations_list', + [ + 'label' => 'content_translation', + 'route' => 'automated_translation_index', + ] + ); + } + } +} diff --git a/bundle/Events.php b/bundle/Events.php index 1628efc..fab8f42 100644 --- a/bundle/Events.php +++ b/bundle/Events.php @@ -19,4 +19,14 @@ final class Events * @Event("\EzSystems\EzPlatformAutomatedTranslationBundle\Event\FieldDecodeEvent") */ const POST_FIELD_DECODE = 'ez_automated_translation.post_field_decode'; + + /** + * @Event("\EzSystems\EzPlatformAutomatedTranslationBundle\Event\RichTextEncodeEvent") + */ + const PRE_RICHTEXT_ENCODE = 'ez_automated_translation.pre_richtext_encode'; + + /** + * @Event("\EzSystems\EzPlatformAutomatedTranslationBundle\Event\RichTextDecodeEvent") + */ + const PRE_RICHTEXT_DECODE = 'ez_automated_translation.pre_richtext_decode'; } diff --git a/bundle/EzPlatformAutomatedTranslationBundle.php b/bundle/EzPlatformAutomatedTranslationBundle.php index 5646e0f..11e2f53 100644 --- a/bundle/EzPlatformAutomatedTranslationBundle.php +++ b/bundle/EzPlatformAutomatedTranslationBundle.php @@ -8,6 +8,7 @@ namespace EzSystems\EzPlatformAutomatedTranslationBundle; +use EzSystems\EzPlatformAutomatedTranslationBundle\DependencyInjection\Security\Provider\AutoTranslationPolicyProvider; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -21,5 +22,7 @@ public function getParent(): ?string public function build(ContainerBuilder $container): void { parent::build($container); + $ibexaExtension = $container->getExtension('ibexa'); + $ibexaExtension->addPolicyProvider(new AutoTranslationPolicyProvider()); } } diff --git a/bundle/Form/AutoTranslationActionsSearchType.php b/bundle/Form/AutoTranslationActionsSearchType.php new file mode 100644 index 0000000..35ba88d --- /dev/null +++ b/bundle/Form/AutoTranslationActionsSearchType.php @@ -0,0 +1,49 @@ +add('sort', SortType::class, [ + 'row_attr' => [ + 'hidden' => 'hidden' + ], + 'sort_fields' => ['created_at', 'user_name', 'content_name', 'target_language' ,'overwrite' ,'status'], + 'default' => ['field' => 'created_at', 'direction' => '0'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'attr' => ['__template' => self::TEMPLATE], + 'method' => Request::METHOD_GET, + 'csrf_protection' => false, + ] + ); + } +} diff --git a/bundle/Form/AutoTranslationActionsType.php b/bundle/Form/AutoTranslationActionsType.php new file mode 100755 index 0000000..cd9c117 --- /dev/null +++ b/bundle/Form/AutoTranslationActionsType.php @@ -0,0 +1,162 @@ +em = $em; + $this->locationService = $locationService; + $this->languageService = $languageService; + $this->translator = $translator; + $this->notificationHandler = $notificationHandler; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $languages = $this->languageService->loadLanguages(); + $defaultLanguage = new Language([ + 'languageCode' => '', + 'name' => 'auto_translation.form.target_language.default_language' + ]); + + $builder + ->add( + 'subtree_id', + SubtreeLocationType::class, + [ + 'compound' => true, + 'label' => 'auto_translation.form.subtree_id.label', + 'row_attr' => [ + 'class' => 'ibexa-field-edit auto-translation-field--ezobjectrelationlist' + ] , + 'attr' => [ + 'class' => 'btn-udw-trigger ibexa-button-tree pure-button + ibexa-font-icon ibexa-btn btn ibexa-btn--secondary + js-auto_translation-select-location-id' + ], + 'empty_data' => [], + ] + ) + ->add( + 'target_language', + ChoiceType::class, + [ + 'required' => true, + 'compound' => false, + 'choices' => [...[$defaultLanguage], ...$languages], + 'setter' => function (AutoTranslationActions $autoTranslationActions, ?Language $language, FormInterface $form): void { + $autoTranslationActions->setTargetLanguage($language->getLanguageCode()); + }, + 'choice_value' => function (?Language $language): string { + return $language ? $language->getLanguageCode() : ''; + }, + 'choice_label' => function (?Language $language): string { + return $language ? $this->translator->trans($language->getName()) : ''; + }, + 'label' => 'auto_translation.form.target_language.label', + 'row_attr' => [ + 'class' => 'ibexa-field-edit auto-translation-field--ezselection' + ], + 'attr' => [ + 'class' => 'ibexa-data-source__input ibexa-data-source__input--selection ibexa-input ibexa-input--select form-select' + ], + ] + ) + ->add( + 'overwrite', + CheckboxType::class, + + [ + 'required' => false, + 'label' => 'auto_translation.form.overwrite.label' + ] + ) + ->add( + 'submit', + SubmitType::class, + [ + 'label' => false + ] + ) + ->addEventListener(FormEvents::POST_SUBMIT, function (PostSubmitEvent $event) { + /** @var Form $form */ + $form = $event->getForm(); + /** @var AutoTranslationActions $autoTranslationActions */ + $autoTranslationActions = $form->getData(); + $count = $this->em->getRepository(AutoTranslationActions::class)->count([ + 'subtreeId' => $autoTranslationActions->getSubtreeId(), + 'targetLanguage' => $autoTranslationActions->getTargetLanguage(), + 'status' => [AutoTranslationActions::STATUS_PENDING, AutoTranslationActions::STATUS_IN_PROGRESS], + ]); + if ($count) { + try { + $location = $this->locationService->loadLocation($autoTranslationActions->getSubtreeId()); + $message = $this->translator->trans('auto_translation.add.failed', [ + '%subtree_name%' => $location->contentInfo->name + ]); + $event->getForm()->addError(new FormError($message)); + $this->notificationHandler->error($message); + } catch (NotFoundException|UnauthorizedException $e) { + $event->getForm()->addError(new FormError($e->getMessage())); + $this->notificationHandler->error($e->getMessage()); + } + } + }) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => AutoTranslationActions::class, + 'attr' => ['__template' => self::TEMPLATE] + ] + ); + } +} diff --git a/bundle/Form/Extension/TranslationAddType.php b/bundle/Form/Extension/TranslationAddType.php index 481b8a5..0f2224c 100644 --- a/bundle/Form/Extension/TranslationAddType.php +++ b/bundle/Form/Extension/TranslationAddType.php @@ -8,6 +8,7 @@ namespace EzSystems\EzPlatformAutomatedTranslationBundle\Form\Extension; +use Ibexa\Contracts\Core\Repository\Values\Content\Language; use Ibexa\Core\MVC\Symfony\Locale\LocaleConverterInterface; use Ibexa\AdminUi\Form\Type\Content\Translation\TranslationAddType as BaseTranslationAddType; use EzSystems\EzPlatformAutomatedTranslation\Client\ClientInterface; @@ -105,7 +106,7 @@ public function buildView(FormView $view, FormInterface $form, array $options) $fillMap = function ($key, &$map) use ($form) { $languages = $form->get($key); $choices = $languages->getConfig()->getAttribute('choice_list')->getChoices(); - /** @var \eZ\Publish\API\Repository\Values\Content\Language $language */ + /** @var Language $language */ foreach ($choices as $language) { foreach ($this->clientProvider->getClients() as $client) { $posix = $this->localeConverter->convertToPOSIX($language->languageCode); diff --git a/bundle/Form/SubtreeLocationType.php b/bundle/Form/SubtreeLocationType.php new file mode 100644 index 0000000..6f9b8ef --- /dev/null +++ b/bundle/Form/SubtreeLocationType.php @@ -0,0 +1,64 @@ +add( + 'location', + IntegerType::class, + [ + 'required' => true, + 'attr' => ['hidden' => true], + 'label' => false, + 'empty_data' => [], + ] + ) + ->addModelTransformer($this->getDataTransformer()) + ; + } + + private function getDataTransformer(): DataTransformerInterface + { + return new CallbackTransformer( + function ($value) { + if (null === $value || 0 === $value) { + return $value; + } + + return ['location' => !empty($value) ? $value : null]; + }, + function ($value) { + if (\is_array($value) && array_key_exists('location', $value)) { + return $value['location'] ?? null; + } + + return $value; + } + ); + } + + public function getName(): string + { + return 'subtree_location'; + } +} diff --git a/bundle/Handler/AutoTranslationActionsHandler.php b/bundle/Handler/AutoTranslationActionsHandler.php new file mode 100644 index 0000000..091eb72 --- /dev/null +++ b/bundle/Handler/AutoTranslationActionsHandler.php @@ -0,0 +1,183 @@ +connection = $connection; + $this->criteriaConverter = $criteriaConverter; + $this->permissionCriterionResolver = $permissionCriterionResolver; + } + + public function getAllQuery(array $sort = []): QueryBuilder + { + $selectQuery = $this->getSelectQuery(); + + $selectQuery + ->addSelect('c.name as content_name') + ->addSelect('us.name as user_name') + ->addSelect('c.id as content_id') + ->innerJoin( + 'at', + LocationGateway::CONTENT_TREE_TABLE, + 't', + 't.node_id = at.subtree_id' + ) + ->innerJoin( + 't', + ContentGateway::CONTENT_ITEM_TABLE, + 'c', + 'c.id = t.contentobject_id' + ) + ->innerJoin( + 'at', + ContentGateway::CONTENT_ITEM_TABLE, + 'us', + 'us.id = at.user_id' + ) + ->andWhere( + $selectQuery->expr()->eq('c.status', ':content_status') + ) + ->setParameter(':content_status', ContentInfo::STATUS_PUBLISHED, ParameterType::INTEGER) + ; + if (isset($sort['field'])) { + $selectQuery->orderBy($sort['field'], ($sort['direction'] ?? 0) == 0 ? 'DESC' : 'ASC'); + } else { + $selectQuery->orderBy('created_at', 'DESC'); + } + // Check read access to whole source subtree + $permissionCriterion = $this->permissionCriterionResolver->getPermissionsCriterion(); + if ($permissionCriterion !== true && $permissionCriterion !== false) { + $query = new Query(); + $query->filter = new LogicalAnd( + [ + new Criterion\MatchAll(), + $permissionCriterion, + ] + ); + $selectQuery->andWhere($this->criteriaConverter->convertCriteria($selectQuery, $permissionCriterion, [])); + } + + return $selectQuery; + } + + + /** + * @return QueryBuilder + */ + private function getSelectQuery(): QueryBuilder + { + $selectQuery = $this->connection->createQueryBuilder(); + $selectQuery + ->select('at.*') + ->from(self::TABLE_NAME, 'at') + ; + + return $selectQuery; + } + + /** + * @throws Exception + * @throws \Doctrine\DBAL\Driver\Exception + */ + public function getFirstPendingAction() + { + $queryBuilder = $this->getAllQuery(['field' => 'created_at', 'direction' => 1]); + $queryBuilder + ->andWhere($queryBuilder->expr()->in('at.status', ':action_status')) + ->setParameter( + ':action_status', + [AutoTranslationActions::STATUS_PENDING, AutoTranslationActions::STATUS_IN_PROGRESS], + Connection::PARAM_STR_ARRAY + ); + $queryBuilder->setMaxResults(1); + + return $queryBuilder->execute()->fetchAllAssociative()[0] ?? null; + } + + public function countAll(QueryBuilder $queryBuilder): int + { + $queryBuilder->select('COUNT(at.id)'); + $queryBuilder->orderBy('at.id'); + $queryBuilder->setMaxResults(1); + + return (int) $queryBuilder->execute()->fetchOne(); + } + + public function getContentsWithRelationsInSubtree(string $locationPath, int $offset = 0 , int $limit = 10): array + { + $sql = $this->getSqlContentsWithRelationsInSubtree(); + $query = $this->connection->prepare( + "SELECT * FROM ($sql) x LIMIT $offset,$limit"); + $query->bindValue( ':status', ContentInfo::STATUS_PUBLISHED, ParameterType::INTEGER); + $query->bindValue(':path', $locationPath . '%', ParameterType::STRING); + $result = $query->executeQuery(); + + return $result->fetchAllAssociative(); + } + public function countContentWithRelationsInSubtree(string $locationPath): int + { + $sql = $this->getSqlContentsWithRelationsInSubtree(); + $query = $this->connection->prepare( + "SELECT COUNT(*) FROM ($sql) x"); + $query->bindValue( ':status', ContentInfo::STATUS_PUBLISHED, ParameterType::INTEGER); + $query->bindValue(':path', $locationPath . '%', ParameterType::STRING); + $result = $query->executeQuery(); + + return $result->fetchOne(); + } + public function getSqlContentsWithRelationsInSubtree(): string + { + $query = $this->connection->createQueryBuilder() + ->from(ContentGateway::CONTENT_ITEM_TABLE, 'c') + ->innerJoin('c', LocationGateway::CONTENT_TREE_TABLE, 't', 't.contentobject_id = c.id') + ->where('c.status = :status') + ->andWhere('t.path_string LIKE :path'); + + $contentsSqlQuery = $query + ->select('DISTINCT c.id as contentId') + ->getSQL(); + + $relationContentsSqlQuery = $query + ->select('DISTINCT c_rel.to_contentobject_id as contentId') + ->innerJoin('c', ContentGateway::CONTENT_RELATION_TABLE, 'c_rel', 'c_rel.from_contentobject_id = c.id AND c_rel.from_contentobject_version = c.current_version') + ->getSQL(); + + return "$contentsSqlQuery UNION ALL $relationContentsSqlQuery"; + } + +} + diff --git a/bundle/Repository/AutoTranslationActionsRepository.php b/bundle/Repository/AutoTranslationActionsRepository.php new file mode 100644 index 0000000..ba743fc --- /dev/null +++ b/bundle/Repository/AutoTranslationActionsRepository.php @@ -0,0 +1,9 @@ + { - Encore.addEntry('ezplatform-automated-translation-js', [path.resolve(__dirname, '../public/admin/js/ezplatformautomatedtranslation.js')]); -}; \ No newline at end of file diff --git a/bundle/Resources/encore/ibexa.config.js b/bundle/Resources/encore/ibexa.config.js new file mode 100644 index 0000000..8034383 --- /dev/null +++ b/bundle/Resources/encore/ibexa.config.js @@ -0,0 +1,16 @@ +const path = require('path'); + +module.exports = (Encore) => { + Encore.addEntry('ezplatform-automated-translation-js', [ + path.resolve('./public/bundles/ibexaadminui/js/scripts/admin.content.edit.js'), + path.resolve('./public/bundles/ibexaadminui/js/scripts/fieldType/base/base-field.js'), + path.resolve(__dirname, '../public/admin/js/validator/auto-translation-ezselection.js'), + path.resolve(__dirname, '../public/admin/js/validator/auto-translation-ezobjectrelationlist.js'), + path.resolve(__dirname, '../public/admin/js/ezplatformautomatedtranslation.js'), + path.resolve(__dirname, '../public/admin/js/auto-translation.js'), + path.resolve(__dirname, '../public/admin/js/show.history.js'), + ]); + Encore.addEntry('ezplatform-automated-translation-css', [ + path.resolve(__dirname, '../public/admin/scss/auto-translation.scss'), + ]); +}; \ No newline at end of file diff --git a/bundle/Resources/public/admin/js/auto-translation.js b/bundle/Resources/public/admin/js/auto-translation.js new file mode 100644 index 0000000..856ac35 --- /dev/null +++ b/bundle/Resources/public/admin/js/auto-translation.js @@ -0,0 +1,92 @@ +import SelectedLocationsComponent from './components/selected.locations.component.js'; +const { ibexa } = window; + +(function (global, doc) { + const udwContainer = doc.querySelector('#react-udw'); + const formSearch = doc.querySelector('form[name="auto_translation_actions_search"]'); + const sortableColumns = doc.querySelectorAll('.ibexa-table__sort-column'); + const sortedActiveField = doc.querySelector('#auto_translation_actions_search_sort_field').value; + const sortedActiveDirection = doc.querySelector('#auto_translation_actions_search_sort_direction').value; + const sortField = doc.querySelector('#auto_translation_actions_search_sort_field'); + const sortDirection = doc.querySelector('#auto_translation_actions_search_sort_direction'); + const CLASS_SORTED_ASC = 'ibexa-table__sort-column--asc'; + const CLASS_SORTED_DESC = 'ibexa-table__sort-column--desc'; + + const closeUDW = () => ReactDOM.unmountComponentAtNode(udwContainer); + const selectLocationBut = doc.querySelector('.js-auto_translation-select-location-id'); + function notify(message, type = 'info') { + if (!message) return; + const eventInfo = new CustomEvent('ibexa-notify', { + detail: { + label: type, + message: message + } + }); + document.body.dispatchEvent(eventInfo); + } + if (selectLocationBut) { + let udwRoot = null; + + selectLocationBut.addEventListener('click', function (e) { + e.preventDefault(); + const clickedButton = e.target; + const config = JSON.parse(e.currentTarget.dataset.udwConfig); + const selectedLocationList = doc.querySelector(clickedButton.dataset.selectedLocationListSelector); + ReactDOM.render(React.createElement(ibexa.modules.UniversalDiscovery, { + onConfirm: (data) => { + ReactDOM.render(React.createElement(SelectedLocationsComponent, { + items: data, + onDelete: (locations) => { + doc.querySelector(clickedButton.dataset.locationInputSelector).value = locations.map(location => location.id).join(); + } + }), selectedLocationList); + + doc.querySelector(clickedButton.dataset.locationInputSelector).value = data.map(location => location.id).join(); + closeUDW(); + const formError = clickedButton.closest('.auto-translation-field--ezobjectrelationlist') + .querySelector('.ibexa-form-error'); + if(formError) { + formError.innerHTML = ''; + } + }, + onCancel: () => { + closeUDW(); + }, + ...config + }), udwContainer); + }); + } + const sortItems = (event) => { + const { target } = event; + const { field, direction } = target.dataset; + + sortField.value = field; + target.dataset.direction = direction === 'ASC' ? 'DESC' : 'ASC'; + sortDirection.setAttribute('value', direction === 'DESC' ? 1 : 0); + formSearch.submit(); + }; + + const setSortedClass = () => { + doc.querySelectorAll('.ibexa-table__sort-column').forEach((node) => { + node.classList.remove(CLASS_SORTED_ASC, CLASS_SORTED_DESC); + }); + + if (sortedActiveField) { + const sortedFieldNode = doc.querySelector(`.ibexa-table__sort-column--${sortedActiveField}`); + + if (!sortedFieldNode) { + return; + } + + if (parseInt(sortedActiveDirection, 10) === 1) { + sortedFieldNode.classList.add(CLASS_SORTED_ASC); + } else { + sortedFieldNode.classList.add(CLASS_SORTED_DESC); + } + } + }; + + setSortedClass(); + sortableColumns.forEach((column) => column.addEventListener('click', sortItems, false)); + +})(window, document); diff --git a/bundle/Resources/public/admin/js/components/selected.location.component.js b/bundle/Resources/public/admin/js/components/selected.location.component.js new file mode 100644 index 0000000..7ce13c2 --- /dev/null +++ b/bundle/Resources/public/admin/js/components/selected.location.component.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '@ibexa-admin-ui/src/bundle/ui-dev/src/modules/common/icon/icon'; + +const SelectedLocationComponent = (props) => { + const { location, onDelete } = props; + const handleClick = function (event) { + onDelete(event.currentTarget.dataset.locationId); + }; + return ( +
  • +
    + + + + {location.ContentInfo.Content.Name} + + + +
    +
  • + ); +}; + +SelectedLocationComponent.propTypes = { + location: PropTypes.object, + onDelete: PropTypes.func, +}; + +SelectedLocationComponent.defaultProps = { + location: null +}; + +export default SelectedLocationComponent; diff --git a/bundle/Resources/public/admin/js/components/selected.locations.component.js b/bundle/Resources/public/admin/js/components/selected.locations.component.js new file mode 100644 index 0000000..d0867af --- /dev/null +++ b/bundle/Resources/public/admin/js/components/selected.locations.component.js @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import SelectedLocationComponent from "./selected.location.component"; + +const SelectedLocationsComponent = (props) => { + const { items, onDelete } = props; + const [locations, setLocations] = useState([]); + const handleClick = function (locationId) { + setLocations(() => { + const results = locations.filter(location => parseInt(location.id) !== parseInt(locationId)); + onDelete(results); + return results; + }); + + }; + useEffect(() => { + setLocations((prevState) => [...prevState, ...items]); + }, [items]); + + return ( + + ); +}; + +SelectedLocationsComponent.propTypes = { + items: PropTypes.array, + onDelete: PropTypes.func, +}; + +SelectedLocationsComponent.defaultProps = { + items: [], +}; + +export default SelectedLocationsComponent; diff --git a/bundle/Resources/public/admin/js/show.history.js b/bundle/Resources/public/admin/js/show.history.js new file mode 100644 index 0000000..b9350da --- /dev/null +++ b/bundle/Resources/public/admin/js/show.history.js @@ -0,0 +1,14 @@ +(function (global, doc, ibexa, bootstrap) { + const containers = doc.querySelectorAll('.ibexa-auto-translation-actions-table'); + const showPopup = ({ currentTarget: btn }) => { + const selector = `[data-action-logs-popup="${btn.dataset.uiComponent}-${btn.dataset.actionId}"]`; + const modal = doc.querySelector(selector); + bootstrap.Modal.getOrCreateInstance(modal).show(); + }; + + containers.forEach((container) => { + container.querySelectorAll('.ibexa-btn--translation-actions-chart').forEach((btn) => { + btn.addEventListener('click', showPopup, false); + }); + }); +})(window, window.document, window.ibexa, window.bootstrap); diff --git a/bundle/Resources/public/admin/js/validator/auto-translation-ezobjectrelationlist.js b/bundle/Resources/public/admin/js/validator/auto-translation-ezobjectrelationlist.js new file mode 100644 index 0000000..e050bb2 --- /dev/null +++ b/bundle/Resources/public/admin/js/validator/auto-translation-ezobjectrelationlist.js @@ -0,0 +1,40 @@ +(function (global, doc, ibexa) { + const SELECTOR_FIELD = '.auto-translation-field--ezobjectrelationlist'; + class AutoTranslationObjectRelationListValidator extends ibexa.BaseFieldValidator { + /** + * Validates the textarea field value + * + * @method validateInput + * @param {Event} event + * @returns {Object} + * @memberof EzTextValidator + */ + validateInput(event) { + const isError = event.target.required && !event.target.value.trim(); + const label = event.target.closest(SELECTOR_FIELD).querySelector('.ibexa-label').innerHTML; + const errorMessage = ibexa.errors.emptyField.replace('{fieldName}', label); + + return { + isError, + errorMessage, + }; + } + } + + const validator = new AutoTranslationObjectRelationListValidator({ + classInvalid: 'is-invalid', + fieldSelector: SELECTOR_FIELD, + eventsMap: [ + { + selector: `${SELECTOR_FIELD} input`, + eventName: 'blur', + callback: 'validateInput', + errorNodeSelectors: ['.ibexa-form-error'], + }, + ], + }); + + validator.init(); + + ibexa.addConfig('fieldTypeValidators', [validator], true); +})(window, window.document, window.ibexa); diff --git a/bundle/Resources/public/admin/js/validator/auto-translation-ezselection.js b/bundle/Resources/public/admin/js/validator/auto-translation-ezselection.js new file mode 100644 index 0000000..c6b0102 --- /dev/null +++ b/bundle/Resources/public/admin/js/validator/auto-translation-ezselection.js @@ -0,0 +1,49 @@ +(function (global, doc, ibexa) { + const SELECTOR_FIELD = '.auto-translation-field--ezselection'; + const SELECTOR_SELECTED = '.ibexa-dropdown__selection-info'; + const SELECTOR_ERROR_NODE = '.ibexa-form-error'; + const EVENT_VALUE_CHANGED = 'change'; + + class AutoTranslationSelectionValidator extends ibexa.BaseFieldValidator { + /** + * Validates the textarea field value + * + * @method validateInput + * @param {Event} event + * @returns {Object} + * @memberof EzSelectionValidator + */ + validateInput(event) { + const fieldContainer = event.currentTarget.closest(SELECTOR_FIELD); + const selection = fieldContainer.querySelector('.ibexa-data-source__input'); + const hasSelectedOptions = !!selection.value; + const isRequired = selection && selection.required; + const isError = isRequired && !hasSelectedOptions; + const label = fieldContainer.querySelector('.ibexa-label').innerHTML; + const errorMessage = ibexa.errors.emptyField.replace('{fieldName}', label); + + return { + isError, + errorMessage, + }; + } + } + + const validator = new AutoTranslationSelectionValidator({ + classInvalid: 'is-invalid', + fieldSelector: SELECTOR_FIELD, + eventsMap: [ + { + selector: `${SELECTOR_FIELD} .ibexa-data-source__input--selection`, + eventName: EVENT_VALUE_CHANGED, + callback: 'validateInput', + errorNodeSelectors: [SELECTOR_ERROR_NODE], + invalidStateSelectors: [SELECTOR_SELECTED], + }, + ], + }); + + validator.init(); + + ibexa.addConfig('fieldTypeValidators', [validator], true); +})(window, window.document, window.ibexa); diff --git a/bundle/Resources/public/admin/scss/auto-translation.scss b/bundle/Resources/public/admin/scss/auto-translation.scss new file mode 100755 index 0000000..5ac71f1 --- /dev/null +++ b/bundle/Resources/public/admin/scss/auto-translation.scss @@ -0,0 +1,55 @@ +.path-location { + padding: 5px 10px; + background-color: #fff; + height: 58px; + list-style-type: none; + i { + margin-top: 0.1rem; + width: 1.5rem; + height: 1.5rem; + font-size: 30px; + color: #fff; + } +} + +.location-tree { + padding: 0; + margin: 0; + div { + line-height: 50px; + font-weight: 700; + padding: 0 10px; + } +} + + +.pull-right { + display: flex; + flex-direction: row-reverse; +} + +.ibexa-popup--auto-translation .modal-body { + overflow-y: scroll; + height: auto; + max-height: 600px; +} + +.ibexa-auto-translation-actions-table { + .ibexa-badge--secondary { + color: #FFF; + } + .status-pending { + background-color: #f15a10; + } + .status-in_progress { + background-color: #5a10f1; + } + .status-failed { + background-color: #db0032; + } + .status-finished { + background-color: #00621a; + } +} + + diff --git a/bundle/Resources/translations/forms.en.xliff b/bundle/Resources/translations/forms.en.xliff new file mode 100644 index 0000000..c78305b --- /dev/null +++ b/bundle/Resources/translations/forms.en.xliff @@ -0,0 +1,36 @@ + + + +
    + + The source node in most cases contains the sample message as written by the developer. If it looks like a dot-delimitted string such as "form.label.firstname", then the developer has not provided a default message. +
    + + + Automated Translation + Automated Translation + key: role.policy.auto_translation + + + Automated Translation/ All functions + Automated Translation/ All functions + key: role.policy.auto_translation.all_functions + + + Automated Translation/ View + Automated Translation/ View + key: role.policy.auto_translation.view + + + Automated Translation/ Create + Automated Translation/ Create + key: role.policy.auto_translation.create + + + Automated Translation/ Delete + Automated Translation/ Delete + key: role.policy.auto_translation.delete + + +
    +
    diff --git a/bundle/Resources/translations/forms.en.yml b/bundle/Resources/translations/forms.en.yml deleted file mode 100644 index e956eae..0000000 --- a/bundle/Resources/translations/forms.en.yml +++ /dev/null @@ -1 +0,0 @@ -no-service: "Select a translation service" diff --git a/bundle/Resources/translations/menu.en.yaml b/bundle/Resources/translations/menu.en.yaml new file mode 100644 index 0000000..f8da04e --- /dev/null +++ b/bundle/Resources/translations/menu.en.yaml @@ -0,0 +1 @@ +content_translation: Contents Translation diff --git a/bundle/Resources/translations/menu.fr.yaml b/bundle/Resources/translations/menu.fr.yaml new file mode 100644 index 0000000..4789a62 --- /dev/null +++ b/bundle/Resources/translations/menu.fr.yaml @@ -0,0 +1 @@ +content_translation: Traductions des Contenus diff --git a/bundle/Resources/translations/messages.en.yaml b/bundle/Resources/translations/messages.en.yaml new file mode 100644 index 0000000..e5f43a7 --- /dev/null +++ b/bundle/Resources/translations/messages.en.yaml @@ -0,0 +1,26 @@ +tab.translations.remote.translation.service: 'Use automatic translation' +tab.translations.remote.translation.service.with: 'Use automatic translation (%alias%)' +auto_translation.title: 'Tree translation module' +auto_translation.permission.failed: 'You don''t have the permission to acces to the Content Translation interface' +auto_translation.form.subtree_id.label: 'Tree to translate' +auto_translation.form.location.label: 'Select content' +auto_translation.form.location.success: 'Tree "%location%" selected' +auto_translation.form.submit: 'Add' +auto_translation.form.target_language.label: 'Target language' +auto_translation.form.overwrite.label: 'Overwrite existing translations' +auto_translation.form.target_language.default_language: 'Select target language' +auto_translation.list.title: 'History of translations' +auto_translation.list.user_name: 'Requestor' +auto_translation.list.content_name: 'Tree' +auto_translation.list.target_language: 'Target Language' +auto_translation.list.overwrite: 'Overwrite' +auto_translation.list.created_at: 'Date of request' +auto_translation.list.status: 'Status' +auto_translation.viewing: 'Viewing %viewing% out of %total% items' +auto_translation.list.overwrite.value: '{0} No|{1} Yes|]1' +auto_translation.list.status.pending: 'Pending' +auto_translation.list.status.in_progress: 'In Progress' +auto_translation.list.status.failed: 'Failed' +auto_translation.list.status.finished: 'Done' +auto_translation.add.success: 'The translation request of "%subtree_name%" has been registered.' +auto_translation.add.failed: 'Another translation request for "%subtree_name%" already pending in the list.' \ No newline at end of file diff --git a/bundle/Resources/translations/messages.en.yml b/bundle/Resources/translations/messages.en.yml deleted file mode 100644 index 8d12082..0000000 --- a/bundle/Resources/translations/messages.en.yml +++ /dev/null @@ -1,2 +0,0 @@ -tab.translations.remote.translation.service: "Use automatic translation" -tab.translations.remote.translation.service.with: "Use automatic translation (%alias%)" diff --git a/bundle/Resources/translations/messages.fr.yaml b/bundle/Resources/translations/messages.fr.yaml new file mode 100644 index 0000000..87de37a --- /dev/null +++ b/bundle/Resources/translations/messages.fr.yaml @@ -0,0 +1,26 @@ +tab.translations.remote.translation.service: 'Utiliser la traduction automatique' +tab.translations.remote.translation.service.with: 'Utiliser la traduction automatique (%alias%)' +auto_translation.title: Module de traduction d'une Arborescence +auto_translation.permission.failed: 'You don''t have the permission to acces to the Content Translation interface' +auto_translation.form.subtree_id.label: 'Arborescence à traduire' +auto_translation.form.location.label: 'Sélectionner un contenu' +auto_translation.form.location.success: 'Arborescence "%location%" séléctionné' +auto_translation.form.submit: 'Ajouter' +auto_translation.form.target_language.label: 'Langue Cible' +auto_translation.form.overwrite.label: 'Écraser les traductions existantes' +auto_translation.form.target_language.default_language: 'Sélectionner la langue Cible' +auto_translation.list.title: 'Historique des traductions' +auto_translation.list.user_name: 'Demandeur' +auto_translation.list.content_name: 'Arborescence' +auto_translation.list.target_language: 'Langue Cible' +auto_translation.list.overwrite: 'Écraser' +auto_translation.list.created_at: 'Date de la demande' +auto_translation.viewing: '%viewing% sur %total% éléments' +auto_translation.list.status: 'Statut' +auto_translation.list.overwrite.value: '{0} Non|{1} Oui|]1' +auto_translation.list.status.pending: 'En attente' +auto_translation.list.status.in_progress: 'En cours' +auto_translation.list.status.failed: 'Échoué' +auto_translation.list.status.finished: 'Fait' +auto_translation.add.success: 'La demande de traduction de "%subtree_name%" a été enregistré.' +auto_translation.add.failed: 'Un autre demande de traduction de "%subtree_name%" déjà en attente dans la liste.' \ No newline at end of file diff --git a/bundle/Resources/views/ezadminui/javascripts.html.twig b/bundle/Resources/views/ezadminui/javascripts.html.twig deleted file mode 100644 index 5f6a5cf..0000000 --- a/bundle/Resources/views/ezadminui/javascripts.html.twig +++ /dev/null @@ -1 +0,0 @@ -{{ encore_entry_script_tags('ezplatform-automated-translation-js', null, 'ibexa') }} diff --git a/bundle/Resources/views/themes/admin/Form/auto_translation/form.html.twig b/bundle/Resources/views/themes/admin/Form/auto_translation/form.html.twig new file mode 100644 index 0000000..bd22761 --- /dev/null +++ b/bundle/Resources/views/themes/admin/Form/auto_translation/form.html.twig @@ -0,0 +1,6 @@ +{% form_theme form '@ibexadesign/Form/auto_translation/form_fields.html.twig' %} + +{{ form_start(form, { attr: {class: 'ibexa-form ibexa-form-validate'}}) }} + {{ form_errors(form) }} + {{ form_widget(form) }} +{{ form_end(form) }} \ No newline at end of file diff --git a/bundle/Resources/views/themes/admin/Form/auto_translation/form_fields.html.twig b/bundle/Resources/views/themes/admin/Form/auto_translation/form_fields.html.twig new file mode 100644 index 0000000..8335439 --- /dev/null +++ b/bundle/Resources/views/themes/admin/Form/auto_translation/form_fields.html.twig @@ -0,0 +1,53 @@ +{% extends '@IbexaAdminUi/themes/admin/ui/form_fields.html.twig' %} + +{% block form_errors -%} +
    {{ parent() }}
    +{%- endblock form_errors %} + +{% block submit_widget %} +
    + +{% endblock %} + +{% block subtree_location_widget %} +
    + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} + + +{% block _auto_translation_actions_overwrite_row -%} + {% use '@ibexadesign/ui/form_fields/toggle_widget.html.twig' %} +
    + {{ block('form_label') }} + {{ block('toggle_widget') }} +
    +{%- endblock %} diff --git a/bundle/Resources/views/themes/admin/Form/auto_translation/form_search.html.twig b/bundle/Resources/views/themes/admin/Form/auto_translation/form_search.html.twig new file mode 100644 index 0000000..5437ed4 --- /dev/null +++ b/bundle/Resources/views/themes/admin/Form/auto_translation/form_search.html.twig @@ -0,0 +1,6 @@ +{% form_theme form '@ibexadesign/Form/auto_translation/search_form_fields.html.twig' %} + +{{ form_start(form_search, { attr: {class: 'auto-translation-search-form'}}) }} + {{ form_errors(form_search) }} + {{ form_widget(form_search) }} +{{ form_end(form_search) }} \ No newline at end of file diff --git a/bundle/Resources/views/themes/admin/Form/auto_translation/search_form_fields.html.twig b/bundle/Resources/views/themes/admin/Form/auto_translation/search_form_fields.html.twig new file mode 100644 index 0000000..8281c39 --- /dev/null +++ b/bundle/Resources/views/themes/admin/Form/auto_translation/search_form_fields.html.twig @@ -0,0 +1,5 @@ +{% extends '@IbexaAdminUi/themes/admin/ui/form_fields.html.twig' %} + +{% block form_errors -%} +
    {{ parent() }}
    +{%- endblock form_errors %} \ No newline at end of file diff --git a/bundle/Resources/views/themes/admin/auto_translation/view.html.twig b/bundle/Resources/views/themes/admin/auto_translation/view.html.twig new file mode 100644 index 0000000..da6cdca --- /dev/null +++ b/bundle/Resources/views/themes/admin/auto_translation/view.html.twig @@ -0,0 +1,183 @@ +{% extends '@ibexadesign/ui/layout.html.twig' %} + +{% block javascripts %} + {{ parent() }} + {{ encore_entry_script_tags('ezplatform-automated-translation-js', null, 'ibexa') }} +{% endblock %} + +{% block stylesheets %} + {{ parent() }} + {{ encore_entry_link_tags('ezplatform-automated-translation-css', null, 'ibexa') }} +{% endblock %} + +{% block content %} + {% if form_search.vars.data.sort is defined %} + {% set current_field = form_search.vars.data.sort['field'] %} + {% set current_direction = form_search.vars.data.sort['direction'] %} + {% else %} + {% set current_field = 'created_at' %} + {% set current_direction = 0 %} + {% endif %} + + {% set sort_directions = { + 'user_name' : current_field == 'user_name' and current_direction == 0 ? 'DESC' : 'ASC', + 'content_name' : current_field == 'content_name' and current_direction == 0 ? 'DESC' : 'ASC', + 'target_language' : current_field == 'target_language' and current_direction == 0 ? 'DESC' : 'ASC', + 'created_at' : current_field == 'created_at' and current_direction == 0 ? 'DESC' : 'ASC', + 'overwrite' : current_field == 'overwrite' and current_direction == 0 ? 'DESC' : 'ASC', + 'status' : current_field == 'status' and current_direction == 0 ? 'DESC' : 'ASC' + } %} +
    +
    +
    +

    {{ 'auto_translation.title'|trans() }}

    +
    +
    + {% if canCreate is defined and canCreate %} + {% if form.vars.attr['__template'] is defined %} + {% include form.vars.attr['__template'] %} + {% else %} + {{ form_start(form, { attr: {class: 'ibexa-form'}}) }} + {{ form_errors(form) }} + {{ form_widget(form) }} + {{ form_end(form) }} + {% endif %} + {% endif %} + + {% if form_search.vars.attr['__template'] is defined %} + {% include form_search.vars.attr['__template'] %} + {% else %} + {{ form_start(form_search, { attr: {class: 'ibexa-adaptive-filters ibexa-adaptive-filters--inside-container auto-translation-search-form'}}) }} + {{ form_errors(form_search) }} + {{ form_widget(form_search) }} + {{ form_end(form_search) }} + {% endif %} + + +
    +
    {{ 'auto_translation.list.title'|trans() }}
    +
    +
    + + + + + + + + + + + + + + {% for item in pager.currentPageResults %} + + + + + + + + + + + {% endfor %} + +
    + + {{ 'Num' }} + + + + {{ 'auto_translation.list.user_name'|trans }} + + + + {{ 'auto_translation.list.content_name'|trans }} + + + + {{ 'auto_translation.list.target_language'|trans }} + + + + {{ 'auto_translation.list.overwrite'|trans }} + + + + {{ 'auto_translation.list.created_at'|trans }} + + + + {{ 'auto_translation.list.status'|trans }} + + + + {{ 'Log'|trans }} + +
    + {{ item.id }} + + {{ item.user_name }} + + {{ item.content_name }} + {{ ibexa_admin_ui_config.languages.mappings[item.target_language].name }}{{ 'auto_translation.list.overwrite.value'|trans({'%count%': item.overwrite})|raw }}{{ item.created_at|date("d/m/Y H:i") }} + + {{ ('auto_translation.list.status.' ~ item.status)|trans }} + + {% if item.finished_at and item.status == 'finished' or item.status == 'failed' %} +
    ({{ item.finished_at|date("d/m/Y H:i") }}) + {% endif %} +
    + {% if item.finished_at and item.status == 'finished' or item.status == 'failed' %} + + + {% endif %} +
    + {% if pager.haveToPaginate %} +
    + + {{ 'auto_translation.viewing'|trans({ + '%viewing%': pager.currentPageResults|length, + '%total%': pager.nbResults})|desc('Viewing %viewing% out of %total% items')|raw }} + + +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/bundle/Resources/views/themes/admin/content/remote_translate_form_fields.html.twig b/bundle/Resources/views/themes/admin/content/remote_translate_form_fields.html.twig index bf13cc9..3e4a081 100644 --- a/bundle/Resources/views/themes/admin/content/remote_translate_form_fields.html.twig +++ b/bundle/Resources/views/themes/admin/content/remote_translate_form_fields.html.twig @@ -1,3 +1,4 @@ + {% if form.vars.autotranslated_data is defined %}
    {% if form.translatorAlias.vars.choices is defined %} diff --git a/composer.json b/composer.json index ce3da85..a1cf939 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,9 @@ "symfony/http-foundation": "^5.0", "symfony/event-dispatcher": "^5.0", "guzzlehttp/guzzle": "^6.3.0", - "ext-json": "*" + "ext-json": "*", + "deeplcom/deepl-php": "^1.12", + "http-interop/http-factory-guzzle": "^1.2" }, "require-dev": { "squizlabs/php_codesniffer": "^3.2", @@ -61,7 +63,8 @@ "config": { "allow-plugins": { "symfony/flex": true, - "ibexa/post-install": true + "ibexa/post-install": true, + "php-http/discovery": true } } } diff --git a/lib/Client/Deepl.php b/lib/Client/Deepl.php index 27f5470..fbb47d6 100644 --- a/lib/Client/Deepl.php +++ b/lib/Client/Deepl.php @@ -8,17 +8,21 @@ namespace EzSystems\EzPlatformAutomatedTranslation\Client; +use DeepL\DeepLClient; +use DeepL\DeepLException; +use DeepL\TranslateTextOptions; use EzSystems\EzPlatformAutomatedTranslation\Exception\ClientNotConfiguredException; -use EzSystems\EzPlatformAutomatedTranslation\Exception\InvalidLanguageCodeException; -use GuzzleHttp\Client; class Deepl implements ClientInterface { /** @var string */ - private $authKey; + private string $authKey; - /** @var string */ - private $baseUri; + /** @var array */ + private array $nonSplittingTags; + + /** @var array */ + private array $supportedLanguagesMapping = []; public function getServiceAlias(): string { @@ -36,35 +40,40 @@ public function setConfiguration(array $configuration): void throw new ClientNotConfiguredException('authKey is required'); } $this->authKey = $configuration['authKey']; - $this->baseUri = isset($configuration['baseUri']) ? $configuration['baseUri'] : 'https://api.deepl.com'; + + if (isset($configuration['nonSplittingTags'])) { + $this->nonSplittingTags = array_filter(explode(',', $configuration['nonSplittingTags'])); + } + if (isset($configuration['supported_languages_mapping'])) { + $this->supportedLanguagesMapping = array_unique((array) $configuration['supported_languages_mapping']); + } + } + /** + * @throws DeepLException + */ public function translate(string $payload, ?string $from, string $to): string { - $parameters = [ - 'auth_key' => $this->authKey, - 'target_lang' => $this->normalized($to), - 'tag_handling' => 'xml', - 'text' => $payload, + $sourceLang = null; + $targetLang = $this->normalized($to); + $options = [ + TranslateTextOptions::TAG_HANDLING => 'xml', ]; + if (!empty($this->nonSplittingTags)) { + $options[TranslateTextOptions::NON_SPLITTING_TAGS] = $this->nonSplittingTags; + } + if (null !== $from) { - $parameters += [ - 'source_lang' => $this->normalized($from), - ]; + $sourceLang = $this->normalized($from); } - $http = new Client( - [ - 'base_uri' => $this->baseUri, - 'timeout' => 5.0, - ] - ); - $response = $http->post('/v2/translate', ['form_params' => $parameters]); - // May use the native json method from guzzle - $json = json_decode($response->getBody()->getContents()); - - return $json->translations[0]->text; + $deeplClient = new DeepLClient($this->authKey); + + $result = $deeplClient->translateText($payload, $sourceLang, $targetLang, $options); + + return $result->text; } public function supportsLanguage(string $languageCode): bool @@ -77,6 +86,9 @@ private function normalized(string $languageCode): string|null if (\in_array($languageCode, self::LANGUAGE_CODES)) { return $languageCode; } + if (isset($this->supportedLanguagesMapping[$languageCode])) { + return $this->supportedLanguagesMapping[$languageCode]; + } $code = strtoupper(substr($languageCode, 0, 2)); if (\in_array($code, self::LANGUAGE_CODES)) { @@ -87,7 +99,10 @@ private function normalized(string $languageCode): string|null } /** - * List of available code https://www.deepl.com/api.html. + * List of available code https://www.deepl.com/docs-api/translate-text */ - private const LANGUAGE_CODES = ['EN', 'DE', 'FR', 'ES', 'IT', 'NL', 'PL', 'JA']; + private const LANGUAGE_CODES = ['AR','BG', 'CS','DA', 'DE', 'EL', 'EN','EN-GB','EN-US', 'ES', 'ET', + 'FI','FR', 'HU', 'ID', 'IT', 'JA', 'KO', 'LT', 'LV', 'NB', 'NL', 'PL', 'PT', 'PT-BR', 'PT-PT', 'RO', + 'RU', 'SK', 'SL', 'SV', 'TR', 'UK', 'ZH' + ]; } diff --git a/lib/Encoder.php b/lib/Encoder.php index 0d6667d..c61089e 100644 --- a/lib/Encoder.php +++ b/lib/Encoder.php @@ -21,6 +21,7 @@ use InvalidArgumentException; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Ibexa\FieldTypePage\FieldType\LandingPage\Value as LandingPageValue; /** * Class Encoder. @@ -76,10 +77,10 @@ class Encoder /** @var ContentTypeService */ private $contentTypeService; - /** @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface */ + /** @var EventDispatcherInterface */ private $eventDispatcher; - /** @var \EzSystems\EzPlatformAutomatedTranslation\Encoder\Field\FieldEncoderManager */ + /** @var FieldEncoderManager */ private $fieldEncoderManager; public function __construct( @@ -90,9 +91,10 @@ public function __construct( $this->contentTypeService = $contentTypeService; $this->eventDispatcher = $eventDispatcher; $this->fieldEncoderManager = $fieldEncoderManager; + } - public function encode(Content $content): string + public function encode(Content $content, ?string $from, ?string $to): string { $results = []; $contentType = $this->contentTypeService->loadContentType($content->contentInfo->contentTypeId); @@ -104,7 +106,11 @@ public function encode(Content $content): string } $type = \get_class($field->value); - if (null === ($value = $this->encodeField($field))) { + if ($field->value instanceof LandingPageValue){ + $type = LandingPageValue::class; + } + + if (null === ($value = $this->encodeField($field, $from, $to))) { continue; } @@ -162,10 +168,10 @@ public function decode(string $xml, Content $sourceContent): array return $results; } - private function encodeField(Field $field): ?string + private function encodeField(Field $field, ?string $from, ?string $to): ?string { try { - $value = $this->fieldEncoderManager->encode($field); + $value = $this->fieldEncoderManager->encode($field, $from, $to); } catch (InvalidArgumentException $e) { return null; } diff --git a/lib/Encoder/BlockAttribute/NestedAttributeEncoder.php b/lib/Encoder/BlockAttribute/NestedAttributeEncoder.php new file mode 100644 index 0000000..296ffa3 --- /dev/null +++ b/lib/Encoder/BlockAttribute/NestedAttributeEncoder.php @@ -0,0 +1,87 @@ + []]; + try { + $value = json_decode($value); + foreach ($value->attributes as $index => $attributes) { + foreach ($attributes as $key => $attribute){ + if($key === 'url'){ + $encodeValue['attributes']['key_'.$index][$key] = (string) $attribute->value; + } else { + $encodeValue['attributes']['key_'.$index][$key] = htmlentities((string) $attribute->value); + } + } + } + } catch (\Exception $e){ + $encodeValue = ['attributes' => []]; + } + + $encoder = new XmlEncoder(); + $payload = $encoder->encode($encodeValue, XmlEncoder::FORMAT); + $payload = str_replace('' . "\n", '', $payload); + $payload = str_replace( + [''], + ['<' . self::CDATA_FAKER_TAG . '>', ''], + $payload + ); + + return (string) $payload; + } + + public function decode(string $value): string + { + $data = str_replace( + ['<' . self::CDATA_FAKER_TAG . '>', ''], + [''], + $value + ); + + $encoder = new XmlEncoder(); + $decodeArray = $encoder->decode($data, XmlEncoder::FORMAT); + $decodeValues = ['attributes' => []]; + + if (isset($decodeArray['attributes']) && !empty($decodeArray['attributes'])) { + foreach ($decodeArray['attributes'] as $index => $attributes){ + $index = str_replace('key_', '',$index); + foreach ($attributes as $key => $attributeValue){ + + if($key === 'url'){ + $decodeValues['attributes'][$index][$key]['value'] = $attributeValue; + } else { + $decodeValues['attributes'][$index][$key]['value'] = html_entity_decode( + htmlspecialchars_decode(trim($attributeValue)) + ); + } + } + } + } + + return json_encode($decodeValues); + } +} diff --git a/lib/Encoder/BlockAttribute/TextBlockAttributeEncoder.php b/lib/Encoder/BlockAttribute/TextBlockAttributeEncoder.php index 682bb56..c7f89dc 100644 --- a/lib/Encoder/BlockAttribute/TextBlockAttributeEncoder.php +++ b/lib/Encoder/BlockAttribute/TextBlockAttributeEncoder.php @@ -37,6 +37,6 @@ public function decode(string $value): string $value ); - return htmlspecialchars_decode(trim($value)); + return html_entity_decode(htmlspecialchars_decode(trim($value))); } } diff --git a/lib/Encoder/BlockAttribute/TextLineAttributeEncoder.php b/lib/Encoder/BlockAttribute/TextLineAttributeEncoder.php index bc7fb85..3a60ccd 100644 --- a/lib/Encoder/BlockAttribute/TextLineAttributeEncoder.php +++ b/lib/Encoder/BlockAttribute/TextLineAttributeEncoder.php @@ -37,6 +37,6 @@ public function decode(string $value): string $value ); - return htmlspecialchars_decode(trim($value)); + return html_entity_decode(htmlspecialchars_decode(trim($value))); } } diff --git a/lib/Encoder/Field/FieldEncoderInterface.php b/lib/Encoder/Field/FieldEncoderInterface.php index 8024ff0..625c63c 100644 --- a/lib/Encoder/Field/FieldEncoderInterface.php +++ b/lib/Encoder/Field/FieldEncoderInterface.php @@ -8,8 +8,8 @@ namespace EzSystems\EzPlatformAutomatedTranslation\Encoder\Field; -use eZ\Publish\API\Repository\Values\Content\Field; -use eZ\Publish\Core\FieldType\Value; +use Ibexa\Contracts\Core\Repository\Values\Content\Field; +use Ibexa\Core\FieldType\Value; interface FieldEncoderInterface { @@ -17,7 +17,7 @@ public function canEncode(Field $field): bool; public function canDecode(string $type): bool; - public function encode(Field $field): string; + public function encode(Field $field, ?string $from, ?string $to): string; /** * @param mixed $previousFieldValue diff --git a/lib/Encoder/Field/FieldEncoderManager.php b/lib/Encoder/Field/FieldEncoderManager.php index 6af2797..0992d1d 100644 --- a/lib/Encoder/Field/FieldEncoderManager.php +++ b/lib/Encoder/Field/FieldEncoderManager.php @@ -14,6 +14,7 @@ final class FieldEncoderManager { + public const CHINESE_LANGUAGES_CODS = ['chi-CN', 'chi-HK','chi-TW']; /** @var FieldEncoderInterface[]|iterable */ private $fieldEncoders; @@ -25,11 +26,11 @@ public function __construct(iterable $fieldEncoders = []) $this->fieldEncoders = $fieldEncoders; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { foreach ($this->fieldEncoders as $fieldEncoder) { if ($fieldEncoder->canEncode($field)) { - return $fieldEncoder->encode($field); + return $fieldEncoder->encode($field, $from, $to); } } diff --git a/lib/Encoder/Field/FormBuilderFieldEncoder.php b/lib/Encoder/Field/FormBuilderFieldEncoder.php new file mode 100644 index 0000000..b0af6e5 --- /dev/null +++ b/lib/Encoder/Field/FormBuilderFieldEncoder.php @@ -0,0 +1,151 @@ +formBuilderFieldAttributeEncoderManager = $formBuilderFieldAttributeEncoderManager; + $this->fieldDefinitionFactory = $fieldDefinitionFactory; + } + public function canEncode(Field $field): bool + { + + return $field->value instanceof FormValue; + } + + public function canDecode(string $type): bool + { + return FormValue::class === $type; + } + + public function encode(Field $field, ?string $from, ?string $to): string + { + /** @var FormValue $value */ + $value = $field->value; + $formFields = []; + $form = $value->getForm(); + $fieldDefinitionAttributesType = []; + /** @var \Ibexa\Contracts\FormBuilder\FieldType\Model\Field $formField */ + foreach ($value->getFormValue()?->getFields() as $formField) { + $attrs = []; + $fieldDefinition = $this->fieldDefinitionFactory->getFieldDefinition($formField->getIdentifier()); + $fieldDefinitionAttributesType[$formField->getIdentifier()] = []; + foreach ($fieldDefinition->getAttributes() as $attribute) { + $fieldDefinitionAttributesType[$formField->getIdentifier()][$attribute->getIdentifier()] = $attribute->getType(); + } + + foreach ($formField->getAttributes() as $attribute) { + $attributeType = $fieldDefinitionAttributesType[$formField->getIdentifier()][$attribute->getIdentifier()]; + if (null === ($attributeValue = $this->encodeAttribute($attributeType, $attribute->getValue()))) { + continue; + } + + $attrs[$attribute->getIdentifier()] = [ + '@type' => $attributeType, + '#' => $attributeValue, + ]; + } + + $formFields[$formField->getId()] = [ + 'name' => $this->encodeAttribute('string', $formField->getName()), + 'attributes' => $attrs, + ]; + } + + + $encoder = new XmlEncoder(); + $payload = $encoder->encode($formFields, XmlEncoder::FORMAT); + + $payload = str_replace('' . "\n", '', $payload); + + $payload = str_replace( + [''], + ['<' . self::CDATA_FAKER_TAG . '>', ''], + $payload + ); + + + return (string) $payload; + } + + public function decode(string $value, $previousFieldValue): FormValue + { + $encoder = new XmlEncoder(); + $data = str_replace( + ['<' . self::CDATA_FAKER_TAG . '>', ''], + [''], + $value + ); + + /** @var FormValue $formValue */ + $formValue = clone $previousFieldValue; + $decodeArray = $encoder->decode($data, XmlEncoder::FORMAT); + + if ($decodeArray) { + foreach ($decodeArray as $fieldId => $fieldValue) { + $field = $formValue->getFormValue()->getFieldById((string)$fieldId); + $field->setName($this->decodeAttribute('string', $fieldValue['name'])); + + if (is_array($fieldValue['attributes'])) { + foreach ($fieldValue['attributes'] as $attributeName => $attribute) { + if (null === ($attributeValue = $this->decodeAttribute($attribute['@type'], $attribute['#']))) { + continue; + } + + $field->getAttribute($attributeName)->setValue($attributeValue); + } + } + } + } + + return $formValue; + } + + /** + * @param mixed $value + */ + private function encodeAttribute(string $type, $value): ?string + { + try { + $value = $this->formBuilderFieldAttributeEncoderManager->encode($type, $value); + } catch (InvalidArgumentException $e) { + return null; + } + + return $value; + } + + private function decodeAttribute(string $type, string $value): ?string + { + try { + $value = $this->formBuilderFieldAttributeEncoderManager->decode($type, $value); + } catch (InvalidArgumentException | EmptyTranslatedAttributeException $e) { + return null; + } + + return $value; + } +} \ No newline at end of file diff --git a/lib/Encoder/Field/ImageFieldEncoder.php b/lib/Encoder/Field/ImageFieldEncoder.php new file mode 100644 index 0000000..2a6a624 --- /dev/null +++ b/lib/Encoder/Field/ImageFieldEncoder.php @@ -0,0 +1,50 @@ +value instanceof ImageValue; + } + + public function canDecode(string $type): bool + { + return ImageValue::class === $type; + } + + public function encode(Field $field, ?string $from, ?string $to): string + { + return htmlentities((string) $field->value->alternativeText); + } + + public function decode(string $value, $previousFieldValue): Value + { + $value = str_replace( + Encoder::XML_MARKUP, + '', + $value + ); + $value = html_entity_decode(htmlspecialchars_decode(trim($value))); + if (strlen($value) === 0) { + throw new EmptyTranslatedFieldException(); + } + $imageValue = clone $previousFieldValue; + $imageValue->alternativeText = $value; + + return $imageValue; + } +} diff --git a/lib/Encoder/Field/MetasFieldEncoder.php b/lib/Encoder/Field/MetasFieldEncoder.php index 8fa56be..2dbb07f 100644 --- a/lib/Encoder/Field/MetasFieldEncoder.php +++ b/lib/Encoder/Field/MetasFieldEncoder.php @@ -2,8 +2,8 @@ namespace EzSystems\EzPlatformAutomatedTranslation\Encoder\Field; -use eZ\Publish\API\Repository\Values\Content\Field; -use eZ\Publish\Core\FieldType\Value; +use Ibexa\Contracts\Core\Repository\Values\Content\Field; +use Ibexa\Core\FieldType\Value; use Novactive\Bundle\eZSEOBundle\Core\FieldType\Metas\Value as MetasValue; use Symfony\Component\Serializer\Encoder\XmlEncoder; @@ -21,7 +21,7 @@ public function canDecode(string $type): bool return MetasValue::class === $type; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { /** @var MetasValue $value */ $value = $field->value; diff --git a/lib/Encoder/Field/PageBuilderFieldEncoder.php b/lib/Encoder/Field/PageBuilderFieldEncoder.php index 6af6eb7..6dba4e0 100644 --- a/lib/Encoder/Field/PageBuilderFieldEncoder.php +++ b/lib/Encoder/Field/PageBuilderFieldEncoder.php @@ -47,7 +47,7 @@ public function canDecode(string $type): bool return class_exists(Value::class) && Value::class === $type; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { /** @var Value $value */ $value = $field->value; diff --git a/lib/Encoder/Field/RichTextFieldEncoder.php b/lib/Encoder/Field/RichTextFieldEncoder.php index e589457..68ee151 100644 --- a/lib/Encoder/Field/RichTextFieldEncoder.php +++ b/lib/Encoder/Field/RichTextFieldEncoder.php @@ -34,7 +34,7 @@ public function canDecode(string $type): bool return RichTextValue::class === $type; } - public function encode($field): string + public function encode($field, ?string $from, ?string $to): string { return $this->richTextEncoder->encode((string) $field->value); } diff --git a/lib/Encoder/Field/TextBlockFieldEncoder.php b/lib/Encoder/Field/TextBlockFieldEncoder.php index c87cb7d..ee40c3f 100644 --- a/lib/Encoder/Field/TextBlockFieldEncoder.php +++ b/lib/Encoder/Field/TextBlockFieldEncoder.php @@ -26,9 +26,14 @@ public function canDecode(string $type): bool return TextBlockValue::class === $type; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { - return (string) $field->value; + $value = (string) $field->value; + if(FieldEncoderManager::CHINESE_LANGUAGES_CODS){ + $value= strtolower($value); + } + + return htmlentities($value); } public function decode(string $value, $previousFieldValue): Value @@ -38,7 +43,7 @@ public function decode(string $value, $previousFieldValue): Value '', $value ); - $value = htmlspecialchars_decode(trim($value)); + $value = html_entity_decode(htmlspecialchars_decode(trim($value))); if (strlen($value) === 0) { throw new EmptyTranslatedFieldException(); diff --git a/lib/Encoder/Field/TextLineFieldEncoder.php b/lib/Encoder/Field/TextLineFieldEncoder.php index da10851..e9914d0 100644 --- a/lib/Encoder/Field/TextLineFieldEncoder.php +++ b/lib/Encoder/Field/TextLineFieldEncoder.php @@ -26,9 +26,14 @@ public function canDecode(string $type): bool return TextLineValue::class === $type; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { - return htmlentities((string) $field->value); + $value = (string) $field->value; + if(FieldEncoderManager::CHINESE_LANGUAGES_CODS){ + $value= strtolower($value); + } + + return htmlentities($value); } public function decode(string $value, $previousFieldValue): Value @@ -38,7 +43,7 @@ public function decode(string $value, $previousFieldValue): Value '', $value ); - $value = htmlspecialchars_decode(trim($value)); + $value = html_entity_decode(htmlspecialchars_decode(trim($value))); if (strlen($value) === 0) { throw new EmptyTranslatedFieldException(); diff --git a/lib/Encoder/Field/UrlFieldEncoder.php b/lib/Encoder/Field/UrlFieldEncoder.php index 7e46357..12e71ee 100644 --- a/lib/Encoder/Field/UrlFieldEncoder.php +++ b/lib/Encoder/Field/UrlFieldEncoder.php @@ -25,7 +25,7 @@ public function canDecode(string $type): bool return UrlValue::class === $type; } - public function encode(Field $field): string + public function encode(Field $field, ?string $from, ?string $to): string { return (string) $field->value->text; } diff --git a/lib/Encoder/FormBuilderFieldAttribute/FormBuilderFieldAttributeEncoderInterface.php b/lib/Encoder/FormBuilderFieldAttribute/FormBuilderFieldAttributeEncoderInterface.php new file mode 100644 index 0000000..3407252 --- /dev/null +++ b/lib/Encoder/FormBuilderFieldAttribute/FormBuilderFieldAttributeEncoderInterface.php @@ -0,0 +1,23 @@ +formBuilderFieldAttributeEncoders = $formBuilderFieldAttributeEncoders; + } + + /** + * @param mixed $value + */ + public function encode(string $type, $value): string + { + foreach ($this->formBuilderFieldAttributeEncoders as $formBuilderFieldAttributeEncoder) { + if ($formBuilderFieldAttributeEncoder->canEncode($type)) { + return $formBuilderFieldAttributeEncoder->encode($value); + } + } + + throw new InvalidArgumentException( + sprintf( + 'Unable to encode form builder field attribute %s. Make sure form builder field attribute encoder service for it is properly registered.', + $type + ) + ); + } + + /** + * @throws EmptyTranslatedAttributeException + */ + public function decode(string $type, string $value): string + { + foreach ($this->formBuilderFieldAttributeEncoders as $formBuilderFieldAttributeEncoder) { + if ($formBuilderFieldAttributeEncoder->canDecode($type)) { + return $formBuilderFieldAttributeEncoder->decode($value); + } + } + + throw new InvalidArgumentException( + sprintf( + 'Unable to decode form builder field attribute %s. Make sure form builder field attribute encoder service for it is properly registered.', + $type + ) + ); + } +} diff --git a/lib/Encoder/FormBuilderFieldAttribute/TextBlockFieldEncoder.php b/lib/Encoder/FormBuilderFieldAttribute/TextBlockFieldEncoder.php new file mode 100644 index 0000000..3f4e30e --- /dev/null +++ b/lib/Encoder/FormBuilderFieldAttribute/TextBlockFieldEncoder.php @@ -0,0 +1,42 @@ +eventDispatcher = $eventDispatcher; $tags = $configResolver->getParameter( 'non_translatable_tags', 'ez_platform_automated_translation' @@ -86,6 +95,10 @@ public function __construct( public function encode(string $xmlString): string { + $event = new RichTextEncodeEvent($xmlString); + $this->eventDispatcher->dispatch($event, Events::PRE_RICHTEXT_ENCODE); + $xmlString = $event->getValue(); + if (strpos($xmlString, self::XML_MARKUP . "\n") !== false) { $xmlString = substr($xmlString, strlen(self::XML_MARKUP . "\n")); } @@ -132,17 +145,20 @@ function ($matches) use ($tag) { $xmlString = str_replace("", "", $xmlString); } - $xmlString = str_replace( + return str_replace( [''], ['<' . self::CDATA_FAKER_TAG . '>', ''], $xmlString ); - return $xmlString; } public function decode(string $value): string { + $event = new RichTextDecodeEvent($value); + $this->eventDispatcher->dispatch($event, Events::PRE_RICHTEXT_DECODE); + $value = $event->getValue(); + $value = str_replace( ['<' . self::CDATA_FAKER_TAG . '>', ''], [''], diff --git a/lib/Translator.php b/lib/Translator.php index fdab510..5322e8c 100644 --- a/lib/Translator.php +++ b/lib/Translator.php @@ -13,6 +13,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Content; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition; use Ibexa\Core\MVC\Symfony\Locale\LocaleConverterInterface; +use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; class Translator { @@ -28,19 +29,21 @@ class Translator /** @var Encoder */ private $encoder; - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ + /** @var ContentService */ private $contentService; - /** @var \Ibexa\Contracts\Core\Repository\ContentTypeService */ + /** @var ContentTypeService */ private $contentTypeService; - + /** @var ConfigResolverInterface */ + private $configResolver; public function __construct( TranslatorGuard $guard, LocaleConverterInterface $localeConverter, ClientProvider $clientProvider, Encoder $encoder, ContentService $contentService, - ContentTypeService $contentTypeService + ContentTypeService $contentTypeService, + ConfigResolverInterface $configResolver ) { $this->guard = $guard; $this->localeConverter = $localeConverter; @@ -48,6 +51,7 @@ public function __construct( $this->encoder = $encoder; $this->contentService = $contentService; $this->contentTypeService = $contentTypeService; + $this->configResolver = $configResolver; } public function getTranslatedFields(?string $from, ?string $to, string $remoteServiceKey, Content $content): array @@ -60,7 +64,7 @@ public function getTranslatedFields(?string $from, ?string $to, string $remoteSe $this->guard->enforceTargetLanguageExist($to); $sourceContent = $this->guard->fetchContent($content, $from); - $payload = $this->encoder->encode($sourceContent); + $payload = $this->encoder->encode($sourceContent, $from, $to); $posixTo = $this->localeConverter->convertToPOSIX($to); $remoteService = $this->clientProvider->get($remoteServiceKey); $translatedPayload = $remoteService->translate($payload, $posixFrom, $posixTo); @@ -73,21 +77,31 @@ public function getTranslatedContent(string $from, string $to, string $remoteSer $translatedFields = $this->getTranslatedFields($from, $to, $remoteServiceKey, $content); $contentDraft = $this->contentService->createContentDraft($content->contentInfo); + $contentUpdateStruct = $this->contentService->newContentUpdateStruct(); $contentUpdateStruct->initialLanguageCode = $to; $contentType = $this->contentTypeService->loadContentType( $content->contentInfo->contentTypeId ); + $excludeAttributes = (array) $this->configResolver + ->getParameter('overwrite_exclude_attributes', 'ez_platform_automated_translation'); foreach ($contentType->getFieldDefinitions() as $field) { if (!$field->isTranslatable) { continue; } - /** @var FieldDefinition $field */ $fieldName = $field->identifier; - $newValue = $translatedFields[$fieldName] ?? $content->getFieldValue($fieldName); + //Exclude fields from overwrite translation + $excludeAttribute = $contentType->identifier.'/'.$field->identifier; + if (in_array($excludeAttribute, $excludeAttributes, true) && + null !== $contentDraft->getFieldValue($fieldName, $to) + ) { + $newValue = $contentDraft->getFieldValue($fieldName, $to); + } else { + $newValue = $translatedFields[$fieldName] ?? $content->getFieldValue($fieldName); + } $contentUpdateStruct->setField($fieldName, $newValue, $to); }