<?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\Model\DataObject\Concrete;
use Pimcore\Model\Document;
use Pimcore\Model\Notification\Service\NotificationService;
use Pimcore\Translation\ExportDataExtractorService\ExportDataExtractorServiceInterface;
use Pimcore\Translation\ImportDataExtractor\ImportDataExtractorInterface;
use Pimcore\Translation\ImporterService\ImporterServiceInterface;
use Pimcore\Translation\TranslationItemCollection\TranslationItem;
use Pimcore\Translation\Translator;
use Pimcore\TranslationsProviderInterfaceBundle\Authenticator\XPlanationAuthenticator;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\JobState;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\JobTransition;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\LogContext;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\Xml\NodeAttribute;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\XPlanation\JobStatusCode;
use Pimcore\TranslationsProviderInterfaceBundle\Constant\XPlanation\ResponseStatusCode;
use Pimcore\TranslationsProviderInterfaceBundle\Entity\Job;
use Pimcore\TranslationsProviderInterfaceBundle\Event\JobEvents;
use Pimcore\TranslationsProviderInterfaceBundle\PimcoreTranslationsProviderInterfaceBundle;
use Pimcore\TranslationsProviderInterfaceBundle\Service\ConfigurationService;
use Pimcore\TranslationsProviderInterfaceBundle\Service\Rest\XPlanationRestService;
use Pimcore\TranslationsProviderInterfaceBundle\Service\Xml\XmlParserService;
use Pimcore\TranslationsProviderInterfaceBundle\Service\Zip\ZipService;
use Pimcore\TranslationsProviderInterfaceBundle\Translation\ExportService\Exporter\XPlanationExporter;
use Pimcore\TranslationsProviderInterfaceBundle\Translation\ImportDataExtractor\XPlanationDataExtractor;
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 XPlanation extends AbstractTranslationsProvider
{
const MAX_COMPLETED_TARGETS_LIMIT = 100000;
/**
* @var string
*/
protected $projectShortCode = '';
/**
* @var ExportDataExtractorServiceInterface
*/
protected $translationDataExtractorService;
/**
* @var XPlanationDataExtractor
*/
protected $importDataExtractor;
/**
* @var ImporterServiceInterface
*/
protected $importerService;
/**
* @var StateMachine
*/
protected $workflow;
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/** @var XPlanationRestService $restService */
protected $restService;
/** @var XPlanationExporter $exporter */
protected $exporter;
/** @var XmlParserService $xmlParserService */
protected $xmlParserService;
/** @var ZipService $zipService */
protected $zipService;
/** @var XPlanationAuthenticator $authenticator */
protected $authenticator;
/** @var bool */
protected $authenticated;
public function __construct(string $shortcut, EventDispatcherInterface $eventDispatcher, XPlanationAuthenticator $authenticator, XPlanationRestService $restService, XPlanationExporter $exporter, XmlParserService $xmlParserService, NotificationService $notificationService, Translator $translator, ConfigurationService $configurationService, ApplicationLogger $logger)
{
parent::__construct($shortcut, $notificationService, $translator, $configurationService, $logger);
if (\Pimcore::getContainer()->hasParameter('XPlanation.project-shortcode')) {
trigger_deprecation('pimcore/translations-provider-interfaces', '2.4', 'Using parameter `XPlanation.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.' . 'translation_job_default');
$this->eventDispatcher = $eventDispatcher;
$this->restService = $restService;
$this->exporter = $exporter;
$this->xmlParserService = $xmlParserService;
$this->authenticator = $authenticator;
}
/**
* @throws \Unirest\Exception
*/
protected function authenticate()
{
if (!$this->authenticated) {
$this->authenticator->authenticate();
$this->authenticated = true;
}
}
/**
* @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)');
}
}
$this->authenticate();
$exportData = [];
$exportId = uniqid();
$translationItems = $this->translationsJobService->getTranslationItems($job)->getItems();
$result = null;
foreach ($translationItems as $counter => $translationItem) {
// For documents it is expected that the content master is saved, but the documents referring to the saved
// document as content master are set as elements in the job
// Thus, in order to find the proper source texts, we need to fetch the master content document
// If no master content document is given, we have to skip the document, otherwise the document will
// translate itself to the target language
$element = $translationItem->getElement();
$isDocument = $element instanceof Document\PageSnippet;
if ($isDocument) {
$contentMasterDocument = $this->getContentMaster($element);
if ($contentMasterDocument) {
$type = $translationItem->getType();
$id = $translationItem->getId();
$translationItem = new TranslationItem($type, $id, $contentMasterDocument);
} else { // Do not translate the document itself
$message = '[Skipping] The document did not have a master document set. Thus, the document is skipped, as otherwise the document would translate itself.';
$this->applicationLogger->error($message, ['component' => LogContext::TRANSLATION_PROVIDER_XPLANATION]);
continue;
}
}
$result = $this->getTranslationDataExtractorService()->extract($translationItem, $job->getSourceLanguage(), $job->getTargetLanguages());
if (!$result->isEmpty()) {
$customJobAttributes = $job->getCustomAttributes();
$pimcoreLanguage = $customJobAttributes[NodeAttribute::PIMCORE_LANGUAGE];
$this->exporter->setPimcoreLanguage($pimcoreLanguage);
$exportFilePath = $this->exporter->export($result, $exportId);
$exportFileContent = $this->exporter->getFileContentFromPath($exportFilePath);
$exportData = $this->computeExportData($exportId, $exportFileContent, $translationItem);
}
}
if (!$exportData) {
$this->eventDispatcher->dispatch(new GenericEvent($job, ['result' => $result]), JobEvents::NO_EXPORT_DATA);
return false;
}
$job->setExportDataFromObject($exportData);
$this->translationsJobService->persist($job);
return $this->sendJobToProvider($job, $exportData);
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_XPLANATION);
throw $e;
}
}
/**
*
* @return bool
*
* @throws \Unirest\Exception
*/
private function sendJobToProvider(Job $job, array $exportData, bool $restart = false)
{
$jobId = $job->getId();
$jobName = $job->getName();
$source = $job->getSourceLanguage();
$targets = $job->getTargetLanguages();
$fileContent = $exportData['xml'];
$fileContent = base64_encode($fileContent);
$fileName = $exportData['ticketId'] . XPlanationExporter::XLIFF_FILE_EXTENSION;
$dueDate = $job->getDueDate() ?: '';
$body = $this->restService->createJob([
$jobId,
$jobName,
$source,
$targets,
$fileContent,
$fileName,
$dueDate,
$restart
]);
$response = $this->restService->getLastResponse();
if ($wasSubmitted = $this->restService->isOkay($response)) {
$this->workflow->apply($job, JobWorkflow::TRANSITION_SUBMIT);
$remoteId = $body->jobid;
$fileId = $body->item->file_id;
$job->setRemoteId($remoteId);
$job->setFileId($fileId);
// If the source language matches the target language (e.g. de-de => de-de), the translation is already sent in the response.
// This is, because the data is already available and simply used to create the same format of the response
// However, the job will not be created, thus, there is no remote ID and the job cannot be fetched from XPlanation.
if (in_array($job->getSourceLanguage(), $job->getTargetLanguages())) {
$customJobAttributes = $job->getCustomAttributes() ?: [];
$customJobAttributes['xml'] = $body->item->final;
$job->setCustomAttributes($customJobAttributes);
}
$this->translationsJobService->persist($job);
}
$event = new GenericEvent($job, ['response' => $response, 'restarted' => $restart]);
$this->eventDispatcher->dispatch($event, $wasSubmitted ? JobEvents::JOB_SUBMITTED : JobEvents::JOB_NOT_SUBMITTED);
if ($wasSubmitted) {
$this->receiveJob($job);
} else {
// Chances are the underlying tool (CAT-Tool) of XPlanation has an error
// In this case, XPlanation recommends to re-send the job with the "restart" flag to continue creating jobs
if ($this->restService->getStatusCode($response) === ResponseStatusCode::NOT_ACCEPTABLE) {
if (!$restart) { // Avoid endless loop
$wasSubmitted = $this->sendJobToProvider($job, $exportData, true);
}
}
}
return $wasSubmitted;
}
/**
* @param null|string $ticketId the ID of the ticket that is created (used to identify the file, after a response was received)
* @param null|string $xml the XML's content
*
* @return array
*/
private function computeExportData(?string $ticketId, ?string $xml, TranslationItem $translationItem)
{
return [
'ticketId' => $ticketId,
'xml' => $xml,
'ctype' => $translationItem->getType(),
'cid' => $translationItem->getId()
];
}
/**
* @param int|null $length the file size in bytes
* @param null|string $checksum the checksum of the file
* @param null|string $xml the file content
*
* @return array
*/
private function computeResultData(?int $length, ?string $checksum, ?string $xml)
{
return [
'xml' => $xml,
'length' => $length,
'checksum' => $checksum
];
}
/**
* @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->authenticate();
$remoteId = $job->getRemoteId();
$jobFinished = false;
if ($remoteId) {
$body = $this->restService->getJobStatus($remoteId);
$response = $this->restService->getLastResponse();
if ($this->restService->isOkay($response)) {
$code = $body->code;
if ($code === JobStatusCode::END) {
$body = $this->restService->getJobTranslations($remoteId);
$response = $this->restService->getLastResponse();
if ($this->restService->isOkay($response)) {
$binaryZipData = base64_decode($body->data);
$xmlContent = $this->zipService->createAndReadTempZip($binaryZipData);
$targetLanguages = $job->getTargetLanguages();
$resultData = [];
$relevantXmlContent = [];
// Pre-filter files based on target language
foreach ($xmlContent as $availableFile => $content) {
$resultData['availableFiles'] = $resultData['availableFiles'] ?: [];
$resultData['availableFiles'][] = $availableFile;
foreach ($targetLanguages as $currentTargetLanguage) {
if (strpos(strtolower($availableFile), $currentTargetLanguage) === 0) {
$relevantXmlContent[$availableFile] = $content;
break;
}
}
}
foreach ($relevantXmlContent as $availableFile => $content) {
// Only file with the highest version is relevant
$version = $this->getVersionFromFileName($availableFile);
foreach ($relevantXmlContent as $currentFile => $currentContent) {
$currentVersion = $this->getVersionFromFileName($currentFile);
if ($version < $currentVersion) {
continue 2;
}
}
$xml = $this->xmlParserService->loadFromString($content);
$resultData = [];
foreach ($this->xmlParserService->getFileNodes($xml) as $fileNode) {
if ($fileNode) {
$targetLanguage = $this->xmlParserService->getTargetLanguage($fileNode);
$id = $this->xmlParserService->getOriginal($fileNode);
if ($this->xmlParserService->isValidId($id) === false) {
$message = "[Error] A part of the translation could not be updated, as the ID '$id' is invalid.";
$this->applicationLogger->error($message, ['component' => LogContext::TRANSLATION_PROVIDER_XPLANATION]);
}
$resultData = empty($resultData) ? $this->computeResultData($body->length, $body->checksum, $content) : $resultData;
$resultData[$id][$targetLanguage]['state'] = JobState::RECEIVED;
$resultData[$id][$targetLanguage]['importedFile'] = $availableFile;
} else {
$message = '[Error] No file node available.';
$this->applicationLogger->error($message, ['component' => LogContext::TRANSLATION_PROVIDER_XPLANATION]);
}
}
}
$job->setResultDataFromObject($resultData);
$jobFinished = true;
$event = new GenericEvent($job);
$this->eventDispatcher->dispatch($event, JobEvents::JOB_RECEIVED);
}
} else {
$event = new GenericEvent($job);
$this->eventDispatcher->dispatch($event, JobEvents::JOB_IN_PROGRESS);
}
}
if ($jobFinished) {
if ($this->workflow->can($job, JobTransition::RECEIVE)) {
$this->workflow->apply($job, JobTransition::RECEIVE);
}// Was finished before --> do nothing
} else {
$resultData = $this->restService->getBody($response, true);
$job->setResultData($resultData);
}
$this->translationsJobService->persist($job);
} else {
// Handle the case where source and target language equals (see submitJob method for details)
if (in_array($job->getSourceLanguage(), $job->getTargetLanguages())) {
$xmlString = ($job->getCustomAttributes()['xml']);
$xmlString = base64_decode($xmlString);
$xml = $this->xmlParserService->loadFromString($xmlString);
$resultData = [];
foreach ($this->xmlParserService->getFileNodes($xml) as $fileNode) {
if ($fileNode) {
$targetLanguage = $this->xmlParserService->getTargetLanguage($fileNode);
$id = $this->xmlParserService->getOriginal($fileNode);
if ($this->xmlParserService->isValidId($id) === false) {
$message = "[Error] A part of the translation could not be updated, as the ID '$id' is invalid.";
$this->applicationLogger->error($message, ['component' => LogContext::TRANSLATION_PROVIDER_XPLANATION]);
}
$resultData = empty($resultData) ? $this->computeResultData(strlen($xmlString), '-1', $xmlString) : $resultData;
$resultData[$id][$targetLanguage]['state'] = JobState::RECEIVED;
} else {
$message = '[Error] No file node available.';
$this->applicationLogger->error($message, ['component' => LogContext::TRANSLATION_PROVIDER_XPLANATION]);
}
}
$job->setResultDataFromObject($resultData);
$jobFinished = true;
$event = new GenericEvent($job);
$this->eventDispatcher->dispatch($event, JobEvents::JOB_RECEIVED);
if ($this->workflow->can($job, JobTransition::RECEIVE)) {
$this->workflow->apply($job, JobTransition::RECEIVE);
}// Was finished before --> do nothing
$this->translationsJobService->persist($job);
}
}
return $jobFinished;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_XPLANATION);
throw $e;
}
}
/**
*
* @return int the determined version, based on the file name.
*/
private function getVersionFromFileName(string $file)
{
$parts = explode('\\', $file);
$filename = end($parts);
$parts = explode('_', $filename);
$version = reset($parts);
$version = ltrim($version, '0');
$version = (int)$version;
return $version ?: 0;
}
/**
*
* @throws \Exception
*/
public function processReceived(Job $job): bool
{
try {
$this->resetError($this->translationsJobService, $job);
if ($this->hasReceivedState($job)) {
if (\Pimcore::inDebugMode()) {
if ($simulatedError = PimcoreTranslationsProviderInterfaceBundle::getSimulatedError()) {
throw new \Exception($simulatedError . ' (process)');
}
}
$resultData = $job->getResultDataAsArray();
$processedItems = $job->getProcessedTranslationItems();
$changedTranslationItems = [];
$importDataExtractor = $this->getImportDataExtractor();
$importId = uniqid();
// We need to create a temporary file, in order to be able to extract a single element
$xml = $resultData['xml'];
$importFile = $importDataExtractor->getImportFilePath($importId);
file_put_contents($importFile, $xml);
foreach ($resultData as $id => $entry) {
if (is_array($entry)) { // Only object specific information is available as array
foreach ($entry as $info) {
$state = $info['state'];
if ($state !== JobState::CANCELLED) {
$attributeSet = $importDataExtractor->extractElement($importId, $id);
if ($attributeSet) {
$translationItem = $attributeSet->getTranslationItem();
$type = $translationItem->getType();
$translationItemId = $translationItem->getId();
$targetLanguage = current($attributeSet->getTargetLanguages());
$event = new GenericEvent($attributeSet->getTranslationItem()->getElement(), ['attributeSet' => $attributeSet, 'job' => $job]);
if (!$this->isAlreadyProcessed($type, $translationItemId, $targetLanguage, $processedItems)) {
$this->getImporterService()->import($attributeSet, false);
$changedTranslationItems[] = $attributeSet->getTranslationItem();
$this->markAsProcessed($type, $translationItemId, $targetLanguage, $processedItems);
$this->eventDispatcher->dispatch($event, JobEvents::TRANSLATION_ITEM_PROCESSED);
} else {
$this->eventDispatcher->dispatch($event, JobEvents::TRANSLATION_ITEM_ALREADY_PROCESSED);
}
}
}
}
}
}
$this->saveTranslationItems($changedTranslationItems);
if (json_encode($job->getProcessedTranslationItems()) != json_encode($processedItems)) {
$job->setProcessedTranslationItems($processedItems);
if ($job->getState() === JobState::RECEIVED) {
$this->workflow->apply($job, JobTransition::PROCESS);
$event = new GenericEvent($job);
$this->eventDispatcher->dispatch($event, JobEvents::FINISHED_JOB);
}
$this->translationsJobService->persist($job);
return true;
}
}
return false;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_XPLANATION);
throw $e;
}
}
/**
* @param Job $job the job to check the status for
*
* @return bool whether the job has any of the received states.
*/
private function hasReceivedState(Job $job)
{
$status = $job->getState();
$requiredStates = [JobState::RECEIVED_PARTIALLY, JobState::RECEIVED];
return in_array($status, $requiredStates);
}
/**
* @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
*/
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 getTranslationDataExtractorService(): ExportDataExtractorServiceInterface
{
return $this->translationDataExtractorService;
}
/**
* @required
*/
public function setTranslationDataExtractorService(ExportDataExtractorServiceInterface $translationDataExtractorService): self
{
$this->translationDataExtractorService = $translationDataExtractorService;
return $this;
}
public function getImportDataExtractor(): XPlanationDataExtractor
{
return $this->importDataExtractor;
}
/**
* @required
*/
public function setImportDataExtractor(ImportDataExtractorInterface $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;
}
/**
* @return string
*/
protected function getSubmissionName(Job $job)
{
return 'Job ID ' . $job->getId();
}
/**
* @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->authenticate();
$remoteId = $job->getRemoteId();
$fileId = $job->getFileId();
$body = $remoteId ? $this->restService->cancelJob($remoteId, $fileId) : false;
if ($body == 1) {
$this->workflow->apply($job, JobTransition::CANCEL);
$this->translationsJobService->persist($job);
return true;
}
return false;
} catch (\Exception $e) {
$this->sendErrorNotification($job, $e);
$this->recordError($this->translationsJobService, $job, $e, LogContext::TRANSLATION_PROVIDER_XPLANATION);
throw $e;
}
}
/**
* @required
*/
public function setZipService(ZipService $zipService)
{
$this->zipService = $zipService;
}
}