<?php
/**
* Pimcore
*
* This source file is available under following license:
* - Pimcore Commercial License (PCL)
*
* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org)
* @license http://www.pimcore.org/license PCL
*/
namespace Pimcore\TranslationsProviderInterfaceBundle\TranslationsProvider;
use Pimcore\Log\ApplicationLogger;
use Pimcore\Logger;
use Pimcore\Model\DataObject\Concrete;
use Pimcore\Model\Notification\Service\NotificationService;
use Pimcore\Translation\ExportDataExtractorService\ExportDataExtractorServiceInterface;
use Pimcore\Translation\ExportService\Exporter\ExporterInterface;
use Pimcore\Translation\ImporterService\ImporterServiceInterface;
use Pimcore\Translation\Translator;
use Pimcore\TranslationsProviderInterfaceBundle\Client\TranslationsCom\ProjectDirectorClient;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\JobState;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\LogContext;
use Pimcore\TranslationsProviderInterfaceBundle\Entity\Job;
use Pimcore\TranslationsProviderInterfaceBundle\Event\JobEvents;
use Pimcore\TranslationsProviderInterfaceBundle\PimcoreTranslationsProviderInterfaceBundle;
use Pimcore\TranslationsProviderInterfaceBundle\Service\ConfigurationService;
use Pimcore\TranslationsProviderInterfaceBundle\Translation\ImportDataExtractor\TranslationsComDataExtractor;
use Pimcore\TranslationsProviderInterfaceBundle\Workflow\Enum\JobWorkflow;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Workflow\StateMachine;
class TranslationsCom extends AbstractTranslationsProvider implements SupportsDataCompareInterface
{
const MAX_COMPLETED_TARGETS_LIMIT = 100000;
/**
* @var string
*/
private $projectShortCode = '';
/**
* @var ProjectDirectorClient
*/
private $projectDirectorClient;
/**
* @var ExportDataExtractorServiceInterface
*/
private $translationDataExtractorService;
/**
* @var TranslationsComDataExtractor
*/
private $importDataExtractor;
/**
* @var ImporterServiceInterface
*/
private $importerService;
/**
* @var ExporterInterface
*/
private $translationExporter;
/**
* @var StateMachine
*/
private $workflow;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var NotificationService
*/
protected $notificationService;
/**
* @var Translator
*/
protected $translator;
/** @var array */
protected $notificationRecipients;
public function __construct(string $shortcut, EventDispatcherInterface $eventDispatcher, NotificationService $notificationService, Translator $translator, ConfigurationService $configurationService, ApplicationLogger $logger, string $workflow = 'translation_job_default')
{
parent::__construct($shortcut, $notificationService, $translator, $configurationService, $logger);
if (\Pimcore::getContainer()->hasParameter('translations_com.project-shortcode')) {
trigger_deprecation('pimcore/translations-provider-interfaces', '2.4', 'Using parameter `translations_com.project-shortcode` is deprecated, use config instead. Will be removed in version 3.0');
$this->projectShortCode = \Pimcore::getContainer()->getParameter('translations_com.project-shortcode');
} else {
$this->projectShortCode = $configurationService->getProjectShortcode();
}
$this->workflow = \Pimcore::getContainer()->get('state_machine.' . $workflow);
$this->eventDispatcher = $eventDispatcher;
}
/**
* @inheritdoc
*/
public function submitJob(Job $job): bool
{
try {
$this->resetError($this->translationsJobService, $job);
if (\Pimcore::inDebugMode()) {
if ($simulatedError = PimcoreTranslationsProviderInterfaceBundle::getSimulatedError()) {
throw new \Exception($simulatedError . ' (submit)');
}
}
$ignoredItems = [];
$projectDirectorClient = $this->getProjectDirectorClient();
if (!$job->getSubmissionName()) {
// set default name
$job->setSubmissionName($this->getSubmissionName($job));
}
$this->applicationLogger->debug('Submit Job ' . $job->getSubmissionName(), ['component' => LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM]);
$projectDirectorClient->initSubmission(
$this->projectShortCode,
$this->getSubmissionName($job),
'',
$this->getDueDate($job),
false,
$job->getCustomAttributes()
);
$exportData = [];
$items = $this->translationsJobService->getTranslationItems($job)->getItems();
foreach ($items as $counter => $translationItem) {
try {
$result = $this->getTranslationDataExtractorService()->extract($translationItem, $job->getSourceLanguage(), $job->getTargetLanguages());
} catch (\Exception $e) {
$ignoredItems[$translationItem->getType()][$translationItem->getId()] = $job->getTargetLanguages();
continue;
}
if ($result->isEmpty()) {
$ignoredItems[$translationItem->getType()][$translationItem->getId()] = $job->getTargetLanguages();
continue;
}
$id = uniqid();
$this->getTranslationExporter()->export($result, $id);
$exportFile = $this->getTranslationExporter()->getExportFilePath($id);
$exportFile = file_get_contents($exportFile);
$ticketId = $projectDirectorClient->uploadTranslateableFile(
$this->getSubmissionFilename($job, $translationItem->getType() . '_' . $translationItem->getId()),
$exportFile,
$this->convertLanguage($job->getSourceLanguage()),
$this->convertLanguage($job->getTargetLanguages())
);
$exportData[] = [
'ticketId' => $ticketId,
'xml' => $exportFile,
'ctype' => $translationItem->getType(),
'cid' => $translationItem->getId()
];
}
if ($exportData || $ignoredItems) {
if ($exportData) {
$job->setExportData(json_encode($exportData));
}
if ($ignoredItems) {
$job->setIgnoredTranslationItems($ignoredItems);
}
$this->translationsJobService->persist($job);
if ($ticketId = $projectDirectorClient->startSubmission()) {
$this->workflow->apply($job, JobWorkflow::TRANSITION_SUBMIT);
$job->setRemoteId($ticketId);
$this->translationsJobService->persist($job);
return true;
}
return false;
} else {
Logger::debug('there is nothing to export');
$this->translationsJobService->delete($job);
return false;
}
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM);
throw $e;
}
}
/**
* @inheritdoc
*/
public function receiveJob(Job $job, $isRedeliveryCheck = false): bool
{
try {
$this->resetError($this->translationsJobService, $job);
if (\Pimcore::inDebugMode()) {
if ($simulatedError = PimcoreTranslationsProviderInterfaceBundle::getSimulatedError()) {
throw new \Exception($simulatedError . ' (receive)');
}
}
$this->applicationLogger->debug('Receive Job ' . $job->getSubmissionName(), ['component' => LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM]);
// here we receive not only the completed but also the cancelled and stuff
$completedTargets = @$this->projectDirectorClient->getGLExchange()->getCompletedTargetsBySubmission($job->getRemoteId(), self::MAX_COMPLETED_TARGETS_LIMIT);
$cancelledTargets = @$this->projectDirectorClient->getGLExchange()->getCancelledTargetsBySubmissions($job->getRemoteId(), self::MAX_COMPLETED_TARGETS_LIMIT);
if (!sizeof($completedTargets) && !sizeof($cancelledTargets)) {
// nothing to do
return false;
}
$exportData = json_decode($job->getExportData(), true);
$totalCompletedCount = 0;
$totalCancelledCount = 0;
$totalNumberOfExpectedTargetDocuments = sizeof($exportData) * sizeof($job->getTargetLanguages());
$resultData = $job->getResultDataAsArray();
if ($completedTargets) {
/** @var \PDTarget $target */
foreach ($completedTargets as $target) {
if (!$translatedXml = $this->projectDirectorClient->getGLExchange()->downloadTarget($target->ticket)) {
continue;
}
// send download confirmation
$this->projectDirectorClient->getGLExchange()->sendDownloadConfirmation($target->ticket);
$resultData[$target->targetLocale] = $resultData[$target->targetLocale] ?? [];
$resultData[$target->targetLocale][$target->ticket] = [
'xml' => $translatedXml,
'state' => JobWorkflow::STATE_RECEIVED, // job item received, meaning that it can be processed now.
'documentTicket' => $target->documentTicket
];
}
}
if ($cancelledTargets) {
/** @var \PDTarget $target */
foreach ($cancelledTargets as $target) {
$resultData[$target->targetLocale][$target->ticket] = [
'xml' => null,
'state' => JobWorkflow::STATE_CANCELED,
'ticket' => $target->ticket,
'documentName' => $target->documentName,
'documentTicket' => $target->documentTicket
// job item cancelled
];
}
}
$job->setResultData(json_encode($resultData));
// count canceled and completed based on aggregated result information
// not only based on last receive request, since completed are not received anymore
foreach ($resultData as $language => $items) {
foreach ($items as $item) {
if ($item['state'] == JobWorkflow::STATE_CANCELED) {
$totalCancelledCount++;
}
if ($item['state'] == JobWorkflow::STATE_RECEIVED) {
$totalCompletedCount++;
}
}
}
if ($totalCancelledCount === $totalNumberOfExpectedTargetDocuments) {
// everything has been cancelled, so switch to cancelled state
$this->workflow->apply($job, JobWorkflow::TRANSITION_CANCEL);
$this->translationsJobService->persist($job);
return true;
}
if ($isRedeliveryCheck) {
if ($job->getState() == JobState::PROCESSED) {
$this->workflow->apply($job, JobWorkflow::TRANSITION_RECEIVE);
}
$job->setProcessedTranslationItems([]);
$this->translationsJobService->persist($job);
return true;
}
// if cancelled + partially received equal to total count then we are done
if ($totalCompletedCount + $totalCancelledCount === $totalNumberOfExpectedTargetDocuments) {
// if everything is there then we are complete
$this->workflow->apply($job, JobWorkflow::TRANSITION_RECEIVE);
$this->translationsJobService->persist($job);
return true;
}
// otherwise there is still something missing
$this->workflow->apply($job, JobWorkflow::TRANSITION_RECEIVE_PARTIALLY);
// here the job is saved
$this->translationsJobService->persist($job);
return false;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM);
throw $e;
}
}
/**
*
* @throws \Exception
*/
public function processReceived(Job $job): bool
{
try {
$this->resetError($this->translationsJobService, $job);
if (!in_array($job->getState(), [JobWorkflow::STATE_RECEIVED, JobWorkflow::STATE_RECEIVED_PARTIALLY])) {
return false;
}
if (\Pimcore::inDebugMode()) {
if ($simulatedError = PimcoreTranslationsProviderInterfaceBundle::getSimulatedError()) {
throw new \Exception($simulatedError . ' (process)');
}
}
$this->applicationLogger->debug('Process Job ' . $job->getSubmissionName(), ['component' => LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM]);
$resultData = json_decode($job->getResultData(), true);
$processedItems = $job->getProcessedTranslationItems();
$changedTranslationItems = [];
foreach ($resultData as $targetLanguage => $items) {
foreach ($items as $item) {
if ($item['state'] === JobWorkflow::STATE_CANCELED) {
continue;
}
$importDataExtractor = $this->getImportDataExtractor();
$id = uniqid();
$importFile = $importDataExtractor->getImportFilePath($id);
file_put_contents($importFile, $item['xml']);
$importDataExtractor->setSourceLanguage($job->getSourceLanguage());
$importDataExtractor->setTargetLanguage($targetLanguage);
/**
* currently a separate file for each translation item will be created as this is the prefered way for translations.com
*/
$attributeSet = $importDataExtractor->extractElement($id, 0);
$type = $attributeSet->getTranslationItem()->getType();
$id = $attributeSet->getTranslationItem()->getId();
if (!$this->isAlreadyProcessed($type, $id, $targetLanguage, $processedItems)) {
$this->getImporterService()->import($attributeSet, false);
$changedTranslationItems[] = $attributeSet->getTranslationItem();
$this->markAsProcessed($type, $id, $targetLanguage, $processedItems);
$event = new GenericEvent($attributeSet->getTranslationItem()->getElement(), ['attributeSet' => $attributeSet]);
$this->eventDispatcher->dispatch($event, JobEvents::TRANSLATION_ITEM_PROCESSED);
}
}
}
$this->saveTranslationItems($changedTranslationItems);
if (json_encode($job->getProcessedTranslationItems()) != json_encode($processedItems)
|| $job->getState() === JobWorkflow::STATE_RECEIVED) {
$job->setProcessedTranslationItems($processedItems);
if ($job->getState() === JobWorkflow::STATE_RECEIVED) {
$this->workflow->apply($job, JobWorkflow::TRANSITION_PROCESS);
}
$this->translationsJobService->persist($job);
return true;
}
return false;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM);
throw $e;
}
}
/**
* @param array $processedItems
*
*/
private function isAlreadyProcessed(string $type, string $id, string $targetLanguage, $processedItems): bool
{
return isset($processedItems[$type]) && isset($processedItems[$type][$id]) && in_array($targetLanguage, $processedItems[$type][$id]);
}
/**
* @param array $processedItems
*
* @return void
*/
private function markAsProcessed(string $type, string $id, string $targetLanguage, &$processedItems)
{
$processedItems[$type] = $processedItems[$type] ?? [];
$processedItems[$type][$id] = $processedItems[$type][$id] ?? [];
$processedItems[$type][$id][] = $targetLanguage;
}
private function saveTranslationItems(array $translationItems)
{
$savedItems = [];
foreach ($translationItems as $item) {
$itemKey = $item->getType() . '_' . $item->getId();
if (!in_array($itemKey, $savedItems)) {
if ($item->getElement() instanceof Concrete) {
$item->getElement()->setOmitMandatoryCheck(true);
}
$item->getElement()->save();
$savedItems[] = $itemKey;
}
}
}
public function getExportDataResponse(string $exportData): Response
{
$response = new Response($exportData);
$response->headers->set('Content-Type', 'text/json');
return $response;
}
public function getResultDataResponse(string $resultData): Response
{
$response = new Response($resultData);
$response->headers->set('Content-Type', 'text/json');
return $response;
}
public function getProjectDirectorClient(): ProjectDirectorClient
{
return $this->projectDirectorClient;
}
/**
*
* @required
*/
public function setProjectDirectorClient(ProjectDirectorClient $projectDirectorClient): self
{
$this->projectDirectorClient = $projectDirectorClient;
return $this;
}
public function getTranslationDataExtractorService(): ExportDataExtractorServiceInterface
{
return $this->translationDataExtractorService;
}
/**
*
* @required
*/
public function setTranslationDataExtractorService(
ExportDataExtractorServiceInterface $translationDataExtractorService
): self {
$this->translationDataExtractorService = $translationDataExtractorService;
return $this;
}
public function getImportDataExtractor(): TranslationsComDataExtractor
{
return $this->importDataExtractor;
}
public function getTranslationExporter(): ExporterInterface
{
return $this->translationExporter;
}
/**
*
* @required
*/
public function setTranslationExporter(ExporterInterface $translationExporter): self
{
$this->translationExporter = $translationExporter;
return $this;
}
/**
*
* @required
*/
public function setImportDataExtractor(TranslationsComDataExtractor $importDataExtractor): void
{
$this->importDataExtractor = $importDataExtractor;
}
public function getImporterService(): ImporterServiceInterface
{
return $this->importerService;
}
/**
* @required
*/
public function setImporterService(ImporterServiceInterface $importerService): self
{
$this->importerService = $importerService;
return $this;
}
public function getProjectShortCode(): string
{
return $this->projectShortCode;
}
public function setProjectShortCode(string $projectShortCode): self
{
$this->projectShortCode = $projectShortCode;
return $this;
}
/**
* @param string|array $language
*
* @return mixed
*/
protected function convertLanguage($language)
{
if (is_array($language)) {
$result = [];
foreach ($language as $lang) {
$result[] = str_replace('_', '-', $lang);
}
return $result;
}
return str_replace('_', '-', $language);
}
/**
* @return int
*/
protected function getDueDate(Job $job)
{
return $job->getDueDate() ? $job->getDueDate() : -1;
}
/**
* @return string
*/
protected function getSubmissionName(Job $job)
{
if ($job->getSubmissionName()) {
$submissionName = $job->getSubmissionName();
} else {
$submissionName = 'Job ID ' . $job->getId();
}
return $submissionName;
}
/**
* @return string
*/
protected function getSubmissionFilename(Job $job, string $suffix)
{
return 'submission_' . $job->getId() . '_' . $suffix . '.xml';
}
/**
* @return bool whether the job was successfully cancelled.
*
* @throws \Exception
*/
public function cancelJob(Job $job): bool
{
try {
$this->resetError($this->translationsJobService, $job);
if (\Pimcore::inDebugMode()) {
if ($simulatedError = PimcoreTranslationsProviderInterfaceBundle::getSimulatedError()) {
throw new \Exception($simulatedError . ' (cancel)');
}
}
$this->applicationLogger->debug('Cancel Job ' . $job->getSubmissionName(), ['component' => LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM]);
if ($job->getRemoteId()) {
$result = $this->projectDirectorClient->getGLExchange()->cancelSubmission($job->getRemoteId());
} else {
$this->applicationLogger->info('Not remotely cancelling job ' . $job->getSubmissionName() . ' since there is no remote ID.', ['component' => LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM]);
}
$this->workflow->apply($job, JobWorkflow::TRANSITION_CANCEL);
$this->translationsJobService->persist($job);
return true;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_TRANSLATIONS_COM);
throw $e;
}
}
public function getCancelledLanguages(Job $job, string $elementType): array
{
$result = [];
$exportData = $job->getExportData() ? json_decode($job->getExportData(), true) : [];
$structuredExportData = $this->structureExportData($exportData);
$resultData = $job->getResultDataAsArray();
foreach ($resultData as $language => $items) {
foreach ($items as $item) {
if ($item['state'] == JobState::CANCELLED) {
$documentTicketId = $item['documentTicket'] ?? null;
$exportItem = $structuredExportData[$elementType][$documentTicketId];
if ($exportItem['cid']) {
$result[$exportItem['cid']][] = $language;
}
}
}
}
return $result;
}
public function getReceivedLanguages(Job $job, string $elementType): array
{
$result = [];
$exportData = $job->getExportData() ? json_decode($job->getExportData(), true) : [];
$structuredExportData = $this->structureExportData($exportData);
$resultData = $job->getResultDataAsArray();
foreach ($resultData as $language => $items) {
foreach ($items as $item) {
if ($item['state'] == JobState::RECEIVED) {
$documentTicketId = $item['documentTicket'] ?? null;
if (isset($structuredExportData[$elementType][$documentTicketId])) {
$exportItem = $structuredExportData[$elementType][$documentTicketId];
if ($exportItem['cid']) {
$result[$exportItem['cid']][] = $language;
}
}
}
}
}
return $result;
}
protected function structureExportData(array $exportData): array
{
$structuredExportData = [];
foreach ($exportData as $exportItem) {
$structuredExportData[$exportItem['ctype']][$exportItem['ticketId']] = $exportItem;
}
return $structuredExportData;
}
protected function parseAttributes(string $xml, array &$compareArray, string $language)
{
$p = xml_parser_create();
$values = [];
$index = [];
xml_parse_into_struct($p, $xml, $values, $index);
xml_parser_free($p);
$id = $values[$index['ITEM'][0]]['attributes']['PK'];
$type = $values[$index['ITEM'][0]]['attributes']['TYPE'];
foreach ($index['ATTRIBUTE'] as $attribute) {
$compareArray[$type][$id][$values[$attribute]['attributes']['VARIABLE']][$language] = $values[$attribute]['value'];
}
}
public function createCompareArray(?array $exportedData, ?array $importedData): array
{
$compareArray = [];
if ($exportedData) {
foreach ($exportedData as $exportItem) {
$this->parseAttributes($exportItem['xml'], $compareArray, 'source');
}
}
if ($importedData) {
foreach ($importedData as $language => $importedItems) {
foreach ($importedItems as $importedItem) {
$this->parseAttributes($importedItem['xml'], $compareArray, $language);
}
}
}
return $compareArray;
}
}