cms_content_sync-3.0.x-dev/src/PushIntent.php
src/PushIntent.php
<?php
namespace Drupal\cms_content_sync;
use Drupal\cms_content_sync\Controller\ContentSyncSettings;
use Drupal\cms_content_sync\Controller\Embed;
use Drupal\cms_content_sync\Controller\LoggerProxy;
use Drupal\cms_content_sync\Entity\EntityStatus;
use Drupal\cms_content_sync\Entity\Flow;
use Drupal\cms_content_sync\Entity\Pool;
use Drupal\cms_content_sync\Event\AfterEntityPush;
use Drupal\cms_content_sync\Exception\SyncException;
use Drupal\cms_content_sync\Plugin\Type\EntityHandlerPluginManager;
use Drupal\cms_content_sync\SyncCoreInterface\SyncCoreFactory;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Link;
use Drupal\crop\Entity\Crop;
use EdgeBox\SyncCore\Exception\SyncCoreException;
use EdgeBox\SyncCore\Interfaces\ISyncCore;
use EdgeBox\SyncCore\V2\SyncCore;
use function t;
/**
* Class PushIntent.
*/
class PushIntent extends SyncIntent {
/**
* @var string PUSH_DISABLED
* Disable pushing completely for this entity type, unless forced.
* - used as a configuration option
* - not used as $action
*/
public const PUSH_DISABLED = 'disabled';
/**
* @var string PUSH_AUTOMATICALLY
* Automatically push all entities of this entity type.
* - used as a configuration option
* - used as $action
*/
public const PUSH_AUTOMATICALLY = 'automatically';
/**
* @var string PUSH_MANUALLY
* Push only some of these entities, chosen manually.
* - used as a configuration option
* - used as $action
*/
public const PUSH_MANUALLY = 'manually';
/**
* @var string PUSH_AS_DEPENDENCY
* Push only some of these entities, pushed if other pushed entities
* use it.
* - used as a configuration option
* - used as $action
*/
public const PUSH_AS_DEPENDENCY = 'dependency';
/**
* @var string PUSH_FORCED
* Force the entity to be pushed (as long as a handler is also selected).
* Can be used programmatically for custom workflows.
* - not used as a configuration option
* - used as $action
* - Allows MANUAL and AUTOMATICALLY but will skip optimization (e.g. push entity even if it hasn't changed)
*/
public const PUSH_FORCED = 'forced';
/**
* @var string PUSH_ANY
* Only used as a filter to check if the Flow pushes this entity in any
* way.
* - not used as a configuration option
* - not used as $action
* - so only used to query against Flows that have *any* push setting for a given entity (type).
*/
public const PUSH_ANY = 'any';
/**
* @var int CACHE_EXPIRATION How long the push operation should be cached in seconds.
*/
public const CACHE_EXPIRATION = 60 * 60 * 24 * 30;
/**
* @var string PUSH_FAILED_REQUEST_FAILED
* The request to the Sync Core failed completely
*/
public const PUSH_FAILED_REQUEST_FAILED = 'export_failed_request_failed';
/**
* @var string PUSH_FAILED_REQUEST_INVALID_STATUS_CODE
* The Sync Core returned a non-2xx status code
*/
public const PUSH_FAILED_REQUEST_INVALID_STATUS_CODE = 'export_failed_invalid_status_code';
/**
* @var string PUSH_FAILED_DEPENDENCY_PUSH_FAILED
* The entity wasn't pushed because when pushing a dependency, an error was thrown
*/
public const PUSH_FAILED_DEPENDENCY_PUSH_FAILED = 'export_failed_dependency_export_failed';
/**
* @var string PUSH_FAILED_INTERNAL_ERROR
* The entity wasn't pushed because when serializing it, an error was thrown
*/
public const PUSH_FAILED_INTERNAL_ERROR = 'export_failed_internal_error';
/**
* @var string PUSH_FAILED_HANDLER_DENIED
* Soft fail: The push failed because the handler returned FALSE when executing the push
*/
public const PUSH_FAILED_HANDLER_DENIED = 'export_failed_handler_denied';
/**
* @var string PUSH_FAILED_UNCHANGED
* Soft fail: The entity wasn't pushed because it didn't change since the last push
*/
public const PUSH_FAILED_UNCHANGED = 'export_failed_unchanged';
/**
* @var string NO_PUSH_REASON__JUST_PULLED The entity has been pulled
* during this very request, so it can't be pushed again immediately
*/
public const NO_PUSH_REASON__JUST_PULLED = 'JUST_IMPORTED';
/**
* @var string NO_PUSH_REASON__NEVER_PUSHED The entity has never been
* pushed before, so pushing the deletion doesn't make sense (it will
* not even exist remotely yet)
*/
public const NO_PUSH_REASON__NEVER_PUSHED = 'NEVER_EXPORTED';
/**
* @var string NO_PUSH_REASON__UNCHANGED The entity hasn't changed, so the
* push would not do anything
*/
public const NO_PUSH_REASON__UNCHANGED = 'UNCHANGED';
/**
* @var string NO_PUSH_REASON__HANDLER_IGNORES The handler for the entity
* refused to push this entity. These are usually handler specific
* configurations like "Don't push unpublished content" for nodes.
*/
public const NO_PUSH_REASON__HANDLER_IGNORES = 'HANDLER_IGNORES';
/**
* @var string NO_PUSH_REASON__NO_POOL No pool was assigned, so there's no push to take place
*/
public const NO_PUSH_REASON__NO_POOL = 'NO_POOL';
/**
* @var string NO_PUSH_REASON__NOT_REGISTERD The site has not been registered yet so can't talk to the Sync Core
*/
public const NO_PUSH_REASON__NOT_REGISTERED = 'NOT_REGISTERED';
/**
* @var \EdgeBox\SyncCore\Interfaces\Syndication\IPushSingle
*/
protected $operation;
/**
* @var \EdgeBox\SyncCore\Interfaces\Syndication\IPushMultiple
*/
protected $asyncOperation;
protected $isQuickEdited = FALSE;
protected $entityVersionHash;
/**
* @var array
* A list of all pushed entities to make sure entities aren't pushed
* multiple times during the same request in the format
* [$action][$entity_type][$bundle][$uuid] => TRUE
*/
protected static $pushed = [];
/**
* @var array
* pushed. Can be queried via self::getNoPushReason($entity). Structure:
* [ entity_type_id:string ][ entity_uuid:string ] => string|Exception
*/
protected static $noPushReasons = [];
/**
* @var array
* Structure:
* [ entity_type_id:string ][ entity_uuid:string ] => message
*/
protected static $noPushMessages = [];
/**
* @var PushIntent[]
*/
protected $embeddedPushIntents = [];
protected $individualLanguage;
/**
* @var string[]
*/
protected $languages;
/**
* @var string[]
*/
protected $deletedLanguages;
/**
* PushIntent constructor.
*
* @param $reason
* @param $action
* @param null|mixed $individual_language
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Exception
*/
public function __construct(Flow $flow, array $pools, $reason, $action, EntityInterface $entity, $individual_language = NULL, ?array $languages = NULL, ?array $deleted_languages = NULL) {
parent::__construct($flow, $pools, $reason, $action, $entity->getEntityTypeId(), $entity->bundle(), $entity->uuid(), EntityHandlerPluginManager::getIdOrNull($entity), NULL, (bool) $individual_language);
$this->individualLanguage = $individual_language;
$this->languages = self::canHandleTranslationsIndependently() ? $languages : NULL;
$this->deletedLanguages = $deleted_languages;
if ($entity instanceof TranslatableInterface) {
if (!$individual_language || !$entity->hasTranslation($individual_language)) {
$entity = $entity->getUntranslated();
}
elseif ($entity->language()->getId() !== $individual_language) {
$entity = $entity->getTranslation($individual_language);
}
}
if (!$this->entity_status->getLastPush($individual_language)) {
if (!EntityStatus::getLastPullForEntity($entity) && !PullIntent::entityHasBeenPulledFromRemoteSite($entity->getEntityTypeId(), $entity->uuid())) {
$this->entity_status->isSourceEntity(TRUE);
}
}
$this->entity = $entity;
$moduleHandler = \Drupal::service('module_handler');
$quickedit_enabled = $moduleHandler->moduleExists('quickedit');
if ($quickedit_enabled && !empty(\Drupal::service('tempstore.private')->get('quickedit')->get($entity->uuid()))) {
$this->isQuickEdited = TRUE;
}
$type_config = $flow->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle());
if (empty($type_config['version'])) {
throw new \Exception('Please export your Flow ' . $flow->id() . ' before pushing an entity of type ' . $entity->getEntityTypeId() . '.' . $entity->bundle() . '.');
}
$this->operation = reset($this->pools)
->getClient()
->getSyndicationService()
->pushSingle(
$this->flow->id,
$entity->getEntityTypeId(),
$entity->bundle(),
$type_config['version'],
$entity->language()->getId(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity)
)
->asDependency(PushIntent::PUSH_AS_DEPENDENCY == $this->flow->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle())['export']);
foreach ($this->pools as $pool) {
$this->operation->toPool($pool->id);
}
$type_config = $this->flow->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle());
$this->entityVersionHash = $type_config['version'];
}
/**
*
*/
public static function setNoPushMessage(string $entity_type, string $shared_entity_id, $message) {
self::$noPushMessages[$entity_type][$shared_entity_id] = $message;
}
/**
*
*/
public static function getNoPushMessage(string $entity_type, string $shared_entity_id) {
return self::$noPushMessages[$entity_type][$shared_entity_id] ?? NULL;
}
/**
*
*/
public function setIgnoreMessage($message) {
self::setNoPushMessage($this->entityType, $this->getSharedId(), $message);
}
/**
* Get the correct synchronization for a specific action on a given entity.
*
* @param string|string[] $reason
* @param string $action
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*
* @return \Drupal\cms_content_sync\Entity\Flow[]
*/
public static function getFlowsForEntity(EntityInterface $entity, $reason, $action = SyncIntent::ACTION_CREATE) {
$flows = Flow::getAll();
$result = [];
foreach ($flows as $flow) {
if ($flow->getController()->canPushEntity($entity, $reason, $action)) {
$result[] = $flow;
}
}
return $result;
}
/**
* Serialize the given entity using the entity push and field push
* handlers.
*
* @throws \Drupal\cms_content_sync\Exception\SyncException
*
* @return bool
* Whether or not the serialized entity could be created
*/
public function serialize() {
return $this->getHandler()->push($this);
}
/**
* @return \EdgeBox\SyncCore\Interfaces\Syndication\IPushSingle
*/
public function getOperation() {
return $this->operation;
}
/**
* @param \EdgeBox\SyncCore\Interfaces\Syndication\IPushMultiple $async_operation
*
* @return void
*/
public function setAsyncOperation($async_operation) {
$this->asyncOperation = $async_operation;
}
/**
* The languages that were pushed or where the push has been triggered.
*
* @var string[]|null
*/
protected $pushed_languages = NULL;
/**
*
*/
public function executeAsync() {
$entity = $this->getEntity();
$handler = $this->getHandler();
if ($handler->ignorePush($this)) {
return FALSE;
}
$operation = $this->asyncOperation ?? reset($this->pools)
->getClient()
->getSyndicationService()
->pushMultiple($this->flow->id);
$default_language = $entity->language()->getId();
$item = $operation
->addEntity(
$entity->getEntityTypeId(),
$entity->bundle(),
$this->entityVersionHash,
$default_language,
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity)
)
->setName($entity->label())
->setSourceDeepLink($handler->getViewUrl($entity))
->isDeleted(FALSE)
->isSource($this->getEntityStatus()->isSourceEntity());
$skip_unchanged = SyncCoreFactory::featureEnabled(ISyncCore::FEATURE_SKIP_UNCHANGED_TRANSLATIONS);
$last_push = self::PUSH_FORCED != $this->getReason() ? $this->getEntityStatus()->getLastPush($this->individualLanguage) : 0;
if ($this->languages && $entity instanceof TranslatableInterface) {
if (!in_array($entity->language()->getId(), $this->languages)) {
$item->hasChanged(FALSE);
}
}
elseif ($skip_unchanged) {
$changed_time = $this->getEntityChangedTime($entity, TRUE);
if ($changed_time <= $last_push) {
$item->hasChanged(FALSE);
}
}
if ($entity instanceof TranslatableInterface && $entity->getEntityType()->getKey('langcode')) {
$languages = array_keys($entity->getTranslationLanguages(FALSE));
$allowed_languages = $this->flow->getController()->getAllowedLanguages();
// Avoid spoiling to the Sync Core that new translations exist if they
// shouldn't be pushed yet. Otherwise the Sync Core will complain that
// this language can't be cloned to the new revision because it doesn't
// exist at the Sync Core yet.
if ($this->languages) {
if (!$this->entity_status->getLastPush()) {
$languages = $this->languages;
}
}
$this->pushed_languages = [
$default_language,
];
foreach ($languages as $langcode) {
if ($langcode === $default_language) {
continue;
}
$translation_last_push = self::PUSH_FORCED != $this->getReason() ? $this->getEntityStatus()->getLastPush($langcode) : 0;
$changed = TRUE;
$translation = $entity->getTranslation($langcode);
// For automated pushes don't trigger a push/exclude unpublished
// translations if the Flow is set to "ignore unpublished" and the
// translation was never pushed before.
if ($this->getFlow()->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle())['export'] === self::PUSH_AUTOMATICALLY
&& $this->getFlow()->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle())['handler_settings']['ignore_unpublished']
&& !$translation->isPublished()
&& !$translation_last_push) {
continue;
}
if ($this->languages) {
$changed = in_array($translation->language()->getId(), $this->languages);
}
elseif ($skip_unchanged && $translation_last_push) {
$changed_time = $this->getEntityChangedTime($translation, TRUE);
if ($changed_time <= $translation_last_push) {
$changed = FALSE;
}
}
if ($allowed_languages && !in_array($langcode, $allowed_languages)) {
$changed = FALSE;
}
if ($changed) {
$this->pushed_languages[] = $langcode;
}
$item->addTranslation($langcode, $handler->getViewUrl($entity->getTranslation($langcode)), $changed);
}
if ($this->deletedLanguages) {
foreach ($this->deletedLanguages as $deleted_language) {
$item->deleteTranslation($deleted_language);
}
}
}
foreach ($this->pools as $pool) {
$item->addPool($pool->id);
}
if ($this->asyncOperation) {
return TRUE;
}
try {
$operation->execute();
}
catch (SyncCoreException $e) {
$this->handleFinishedAsyncOperation($e);
throw new SyncException(SyncException::CODE_PUSH_REQUEST_FAILED, $e);
}
$this->handleFinishedAsyncOperation(NULL);
return TRUE;
}
/**
*
*/
public function handleFinishedAsyncOperation(?SyncCoreException $e) {
if ($e) {
LoggerProxy::get()->error(
'Failed to ASYNC @action entity @entity_type-@entity_bundle @entity_uuid' . PHP_EOL . '@message' . PHP_EOL . 'Got status code @status_code @reason_phrase with body:' . PHP_EOL . '@body<br>Flow: @flow_id | Pool: @pool_id',
[
'@action' => $this->action,
'@entity_type' => $this->entityType,
'@entity_bundle' => $this->bundle,
'@entity_uuid' => $this->uuid,
'@message' => $e->getMessage(),
'@status_code' => $e->getStatusCode(),
'@reason_phrase' => $e->getReasonPhrase(),
'@body' => $e->getResponseBody() . '',
'@flow_id' => $this->getFlow()->id(),
'@pool_id' => implode(',', $this->getPoolIds()),
'@ids' => (!empty($this->entity) ? ($this->entity->getEntityType()->isRevisionable() ? "entity_id:{$this->entity->id()} revision_id:{$this->entity->getRevisionId()}" : "entity_id:{$this->entity->id()}") : ('')),
]
);
$this->saveFailedPush(PushIntent::PUSH_FAILED_REQUEST_FAILED, $e->getMessage());
}
else {
$entity = $this->getEntity();
LoggerProxy::get()->info('PUSH ASYNC @action @entity_type:@bundle @uuid @ids @reason: @message<br>Flow: @flow_id | Pools: @pool_id', [
'@reason' => $this->reason,
'@action' => $this->action,
'@entity_type' => $this->entityType,
'@bundle' => $this->bundle,
'@uuid' => $this->uuid,
'@message' => t('The entity has been pushed asynchronously.'),
'@flow_id' => $this->getFlow()->id(),
'@pool_id' => implode(',', $this->getPoolIds()),
'@ids' => (!empty($entity) ? ($entity->getEntityType()->isRevisionable() ? "entity_id:{$entity->id()} revision_id:{$entity->getRevisionId()}" : "entity_id:{$entity->id()})") : ('')),
]);
$this->afterPushTriggered($this->action, $entity);
}
}
protected array $cacheTags = [];
/**
*
*/
public function addCacheTags(array $tags) {
$this->cacheTags = array_merge($this->cacheTags, $tags);
}
/**
*
*/
public function getCacheTags() {
return $this->cacheTags;
}
/**
* Push the given entity.
*
* @param bool $return_only
*
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
*
* @return bool|PushIntent TRUE|FALSE if the entity is pushed via REST.
* NULL|PushIntent if $return_only is set to TRUE.
*/
public function execute($return_only = FALSE) {
$action = $this->getAction();
$reason = $this->getReason();
$entity = $this->getEntity();
// If this very request was sent to delete/create this entity, ignore the
// push as the result of this request will already tell Sync Core it has
// been deleted. Otherwise Sync Core will return a reasonable 404 for
// deletions.
if (PullIntent::entityHasBeenPulledFromRemoteSite($entity->getEntityTypeId(), $entity->uuid())) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__JUST_PULLED;
return FALSE;
}
$entity_type = $entity->getEntityTypeId();
$entity_bundle = $entity->bundle();
$entity_uuid = $entity->uuid();
$entity_id = $entity->id();
$pushed = $this->entity_status->getLastPush($this->individualLanguage);
if ($pushed) {
if (SyncIntent::ACTION_CREATE == $action) {
$action = SyncIntent::ACTION_UPDATE;
}
}
else {
if (SyncIntent::ACTION_UPDATE == $action) {
$action = SyncIntent::ACTION_CREATE;
}
// If the entity was deleted but has never been pushed before,
// pushing the deletion action doesn't make sense as it doesn't even
// exist remotely.
elseif (SyncIntent::ACTION_DELETE == $action) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__NEVER_PUSHED;
return FALSE;
}
}
$cms_content_sync_disable_optimization = boolval(\Drupal::config('cms_content_sync.debug')
->get('cms_content_sync_disable_optimization'));
foreach ($this->pools as $pool) {
if (isset(self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id]) && (!$return_only || self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id] instanceof PushIntent)) {
return $return_only ? self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id] : (bool) self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id];
}
$alt_action = SyncIntent::ACTION_CREATE == $action ? SyncIntent::ACTION_UPDATE : (SyncIntent::ACTION_UPDATE == $action ? SyncIntent::ACTION_CREATE : NULL);
if ($alt_action) {
if (isset(self::$pushed[$alt_action][$entity_type][$entity_bundle][$entity_uuid][$pool->id]) && (!$return_only || self::$pushed[$alt_action][$entity_type][$entity_bundle][$entity_uuid][$pool->id] instanceof PushIntent)) {
return $return_only ? self::$pushed[$alt_action][$entity_type][$entity_bundle][$entity_uuid][$pool->id] : (bool) self::$pushed[$alt_action][$entity_type][$entity_bundle][$entity_uuid][$pool->id];
}
}
// No need to retry from this point onward.
self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id] = $return_only ? $this : TRUE;
}
if (!$return_only && SyncIntent::ACTION_DELETE !== $action && SyncCoreFactory::featureEnabled(ISyncCore::FEATURE_PUSH_ASYNC)) {
return $this->executeAsync();
}
$proceed = TRUE;
$operation = $this->operation;
$from_cache = FALSE;
if (SyncIntent::ACTION_DELETE === $action) {
$operation->setName($entity->label() ?? '(deleted)');
$operation->delete(TRUE);
}
else {
$cache_key_parts = [
'cms_content_sync',
'push',
$entity_type,
$entity_uuid,
$this->flow->id(),
join("|", array_column($this->pools, 'id')),
$reason === PushIntent::PUSH_AS_DEPENDENCY ? '1' : '0',
$this->individualLanguage ?? "",
];
$cache_key = join(':', $cache_key_parts);
$cache = \Drupal::cache();
$cache_item = $cache->get($cache_key);
$cache_item_data = $cache_item ? $cache_item->data : NULL;
if (!$this->isQuickEdited && $reason === PushIntent::PUSH_AS_DEPENDENCY && $cache_item_data && isset($cache_item_data->operation) && isset($cache_item_data->proceed) && isset($cache_item_data->entityVersionHash) && $cache_item_data->entityVersionHash === $this->entityVersionHash) {
$from_cache = TRUE;
$proceed = $cache_item_data->proceed;
$operation = $cache_item_data->operation;
$this->operation = $operation;
$this->addCacheTags($cache_item->tags);
}
else {
try {
$proceed = $this->serialize();
if (!$cache_item_data) {
$cache_item_data = new \stdClass();
}
$cache_item_data->operation = $operation;
$cache_item_data->proceed = $proceed;
$cache_item_data->entityVersionHash = $this->entityVersionHash;
$this->addCacheTags($entity->getCacheTags());
$cache->set($cache_key, $cache_item_data, time() + self::CACHE_EXPIRATION, array_unique(array_merge([SyncCoreFactory::CACHE_TAG_SYNC_CORE], $this->getCacheTags())));
}
catch (\Exception $e) {
$this->saveFailedPush(PushIntent::PUSH_FAILED_INTERNAL_ERROR, $e->getMessage());
throw new SyncException(SyncException::CODE_ENTITY_API_FAILURE, $e);
}
}
}
// If the entity didn't change, it doesn't have to be pushed again.
// Note that we still serialize the entity above. This is required for the hash
// of all referenced entities to be created (see PushSingle implementation).
if (!$cms_content_sync_disable_optimization && !$this->entityChanged() && self::PUSH_FORCED != $reason
&& SyncIntent::ACTION_DELETE != $action
&& !$return_only) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__UNCHANGED;
return FALSE;
}
LoggerProxy::get()->info('@not @embed PUSH @duration @action @entity_type:@bundle @uuid (hash: @hash) @ids @reason: @message<br>Flow: @flow_id | Pool: @pool_id | Cached: @cached | Timers: @timers', [
'@reason' => $reason,
'@action' => $action,
'@cached' => $from_cache ? 'YES' : 'NO',
'@duration' => $this->formatDuration(),
'@timers' => $this->formatTimers(),
'@entity_type' => $entity_type,
'@bundle' => $entity_bundle,
'@uuid' => $entity_uuid,
'@not' => $proceed ? '' : 'NO',
'@embed' => $return_only ? 'EMBEDDING' : '',
'@hash' => $this->operation->getEntityHash(),
'@message' => $proceed ? t('The entity has been pushed.') : (self::getNoPushMessage($entity_type, $this->getSharedId()) ?? t('The entity handler denied to push this entity.')),
'@flow_id' => $this->getFlow()->id(),
'@pool_id' => implode(',', $this->getPoolIds()),
'@ids' => (!empty($this->entity) ? ($this->entity->getEntityType()->isRevisionable() ? "entity_id:{$this->entity->id()} revision_id:{$this->entity->getRevisionId()}" : "entity_id:{$this->entity->id()}") : ('')),
]);
// Handler chose to deliberately ignore this entity,
// e.g. a node that wasn't published yet and is not pushed unpublished.
if (!$proceed) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__HANDLER_IGNORES;
$this->saveFailedPush(PushIntent::PUSH_FAILED_HANDLER_DENIED);
foreach ($this->pools as $pool) {
unset(self::$pushed[$action][$entity_type][$entity_bundle][$entity_uuid][$pool->id]);
}
$this->extendedEntityExportLogMessage($entity);
return $return_only ? NULL : FALSE;
}
// We need to update the revision timestamp as otherwise the change won't be propagated by the Sync Core.
if ($this->isQuickEdited) {
$revision_timestamp = $operation->getProperty('revision_timestamp');
if (!empty($revision_timestamp[0]['value']) && $revision_timestamp[0]['value'] < $this->getRequestTime()) {
$revision_timestamp[0]['value'] = $this->getRequestTime();
$this->operation->setProperty('revision_timestamp', $revision_timestamp);
}
}
// If the version changed, UPDATE becomes CREATE instead and DELETE requests must be performed against the old
// version, as otherwise they would result in a 404 Not Found response.
if ($this->entityVersionHash != $this->entity_status->getEntityTypeVersion()) {
if (SyncIntent::ACTION_UPDATE == $action) {
$action = SyncIntent::ACTION_CREATE;
}
elseif (SyncIntent::ACTION_DELETE == $action) {
$this->entityVersionHash = $this->entity_status->getEntityTypeVersion();
}
}
if ($return_only) {
$this->extendedEntityExportLogMessage($entity);
return $this;
}
try {
$operation->execute();
}
catch (SyncCoreException $e) {
LoggerProxy::get()->error(
'Failed to @action entity @entity_type-@entity_bundle @entity_uuid @ids' . PHP_EOL . '@message' . PHP_EOL . 'Got status code @status_code @reason_phrase with body:' . PHP_EOL . '@body<br>Flow: @flow_id | Pool: @pool_id',
[
'@action' => $action,
'@entity_type' => $entity_type,
'@entity_bundle' => $entity_bundle,
'@entity_uuid' => $entity_uuid,
'@message' => $e->getMessage(),
'@status_code' => $e->getStatusCode(),
'@reason_phrase' => $e->getReasonPhrase(),
'@body' => $e->getResponseBody() . '',
'@flow_id' => $this->getFlow()->id(),
'@pool_id' => implode(',', $this->getPoolIds()),
'@ids' => (!empty($this->entity) ? ($this->entity->getEntityType()->isRevisionable() ? "entity_id:{$this->entity->id()} revision_id:{$this->entity->getRevisionId()}" : "entity_id:{$this->entity->id()}") : ('')),
]
);
$this->saveFailedPush(PushIntent::PUSH_FAILED_REQUEST_FAILED, $e->getMessage());
throw new SyncException(SyncException::CODE_PUSH_REQUEST_FAILED, $e);
}
$this->afterPushTriggered($action, $entity);
$this->afterPushExecuted($action, $entity);
return TRUE;
}
/**
* @deprecated v3.0.7
*/
public function afterPush($action, $entity) {
$this->afterPushTriggered($action, $entity);
$this->afterPushExecuted($action, $entity);
}
/**
*
*/
public function afterPushTriggered($action, $entity) {
if ($this->pushed_languages) {
foreach ($this->pushed_languages as $language) {
$this->entity_status->setLastPushTrigger(\Drupal::time()->getRequestTime(), $language);
}
}
else {
$this->entity_status->setLastPushTrigger(\Drupal::time()->getRequestTime());
}
if (!empty($this->deletedLanguages)) {
// Deletions are marked as being successful immediately as they don't
// require another request to the site.
$deleted_at = time();
foreach ($this->deletedLanguages as $language) {
$this->entity_status->setLastPush($deleted_at, $language);
}
}
// Dispatch entity push event to give other modules the possibility to react on it.
\Drupal::service('event_dispatcher')->dispatch(new AfterEntityPush($entity, $this->pools, $this->flow, $this->reason, $this->action), AfterEntityPush::EVENT_NAME);
$this->extendedEntityExportLogMessage($entity);
$this->entity_status->save();
}
/**
*
*/
public function afterPushExecuted($action, $entity) {
$this->updateEntityStatusAfterSuccessfulPush($action);
}
/**
* Handle Extended Entity Export logging.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The exported entity.
*/
public function extendedEntityExportLogMessage(EntityInterface $entity) {
$settings = ContentSyncSettings::getInstance();
if ($settings->getExtendedEntityExportLogging()) {
$data = json_encode($this->operation->getData());
\Drupal::logger('cms_content_sync_entity_export_log')->debug('%entity_type - %uuid <br>Data: <br><pre><code>%data</code></pre>', [
'%entity_type' => $entity->getEntityTypeId(),
'%uuid' => $entity->uuid(),
'%data' => $data,
]);
}
}
/**
* @param string $action
* @param null $parent_type
* @param null $parent_uuid
*/
public function updateEntityStatusAfterSuccessfulPush($action = SyncIntent::ACTION_CREATE, $parent_type = NULL, $parent_uuid = NULL) {
static $saved = [];
$entity = $this->getEntity();
if (!empty($saved[$entity->getEntityTypeId()][$entity->uuid()])) {
return;
}
$saved[$entity->getEntityTypeId()][$entity->uuid()] = TRUE;
$this->entity_status->setEntityPushHash($this->operation->getEntityHash());
if (!$this->entity_status->getLastPush($this->individualLanguage) && !$this->entity_status->getLastPull() && !empty($this->operation->getProperty('url'))) {
$this->entity_status->setSourceUrl($this->operation->getProperty('url'));
}
$individual_translation = $this->isIndividualTranslation();
if ($individual_translation && $entity instanceof TranslatableInterface) {
if ($entity->language()->getId() !== $this->individualLanguage && $entity->hasTranslation($this->individualLanguage)) {
$entity = $entity->getTranslation($this->individualLanguage);
}
$push = $this->getEntityChangedTime($entity, TRUE);
$this->entity_status->setLastPush($push, $this->individualLanguage);
}
else {
$push = $this->getEntityChangedTime($this->entity);
$this->entity_status->setLastPush($push);
}
if (SyncIntent::ACTION_DELETE == $action) {
$this->entity_status->isDeleted(TRUE);
reset($this->pools)->markDeleted($entity->getEntityTypeId(), $entity->uuid(), $this->pools);
}
if ($this->entityVersionHash != $this->entity_status->getEntityTypeVersion()) {
$this->entity_status->setEntityTypeVersion($this->entityVersionHash);
}
if ($parent_type && $parent_uuid) {
$this->entity_status->wasPushedEmbedded(TRUE);
$this->entity_status->setParentEntity($parent_type, $parent_uuid);
}
else {
$this->entity_status->wasPushedEmbedded(FALSE);
}
$this->entity_status->save();
foreach ($this->embeddedPushIntents as $intent) {
$intent->updateEntityStatusAfterSuccessfulPush(SyncIntent::ACTION_CREATE, $this->entityType, $this->uuid);
}
}
/**
* Check whether the given entity is currently being pushed. Useful to check
* against hierarchical references as for nodes and menu items for example.
*
* @param string $entity_type
* The entity type to check for.
* @param string $uuid
* The UUID of the entity in question.
* @param string $pool
* The pool to push to.
* @param null|string $action
* See ::ACTION_*.
*
* @return bool
*/
public static function isPushing(string $entity_type, string $uuid, ?string $pool = NULL, ?string $action = NULL) {
foreach (self::$pushed as $do => $types) {
if ($action ? $do != $action : SyncIntent::ACTION_DELETE == $do) {
continue;
}
if (!isset($types[$entity_type])) {
continue;
}
foreach ($types[$entity_type] as $bundle => $entities) {
if (empty($pool)) {
if (!empty($entities[$uuid])) {
return TRUE;
}
}
else {
if (!empty($entities[$uuid][$pool])) {
return TRUE;
}
}
}
}
return FALSE;
}
/**
* Get the pools to push to, filtered to not include content that was overridden
* locally.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* @param string $reason
* @param string $action
* @param \Drupal\cms_content_sync\Entity\Flow $flow
*
* @return \Drupal\cms_content_sync\Entity\Pool[]
*/
public static function getPoolsToPushTo(EntityInterface $entity, string $reason, string $action, Flow $flow) {
$all_pools = $flow->getController()->getPoolsToPushTo($entity, $reason, $action, TRUE);
$pools = [];
foreach ($all_pools as $pool) {
$infos = EntityStatus::getInfosForEntity($entity->getEntityTypeId(), $entity->uuid(), ['pool' => $pool->id()]);
$cancel = FALSE;
foreach ($infos as $info) {
if (!$info->getFlow()) {
continue;
}
if (!$info->getLastPull()) {
continue;
}
if ($info->isOverriddenLocally()) {
$cancel = TRUE;
break;
}
}
if ($cancel) {
continue;
}
$pools[] = $pool;
}
return $pools;
}
/**
* Helper function to push an entity and throw errors if anything fails.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to push.
* @param string $reason
* {@see Flow::PUSH_*}.
* @param string $action
* {@see ::ACTION_*}.
* @param \Drupal\cms_content_sync\Entity\Flow $flow
* The flow to be used. If none is given, all flows that may push this
* entity will be asked to do so for all relevant all_pools$all_pools.
* @param \Drupal\cms_content_sync\Entity\Pool[] $all_pools
* The pool to be used. If not set, all relevant all_pools$all_pools for the flow will be
* used one after another.
* @param bool $return_intent
* Return the PushIntent operation instead of
* executing it. Used to embed entities.
* @param bool $individual_translation
* Whether to serialize only the given translation or use the root translation and provide
* all available translations of it.
* @param null|mixed $pools
* @param string|null $individual_language
* @param string[]|null $languages
*
* @throws \Drupal\cms_content_sync\Exception\SyncException
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @return bool|PushIntent Whether the entity is configured to be pushed or not.
* if $return_only is given, this will return the serialized entity to embed
* or NULL.
*/
public static function pushEntity(EntityInterface $entity, $reason, $action, ?Flow $flow = NULL, $pools = NULL, $return_intent = FALSE, $individual_language = NULL, $languages = NULL) {
if (!$flow) {
$flows = self::getFlowsForEntity($entity, $reason, $action);
if (!count($flows)) {
return FALSE;
}
$result = FALSE;
foreach ($flows as $flow) {
if ($return_intent) {
$result = self::pushEntity($entity, $reason, $action, $flow, NULL, TRUE, $individual_language, $languages);
if ($result) {
return $result;
}
}
else {
$result |= self::pushEntity($entity, $reason, $action, $flow, NULL, FALSE, $individual_language, $languages);
}
}
return $result;
}
if (!$pools) {
$pools = self::getPoolsToPushTo($entity, $reason, $action, $flow);
if (!count($pools)) {
return FALSE;
}
return self::pushEntity($entity, $reason, $action, $flow, $pools, $return_intent, $individual_language, $languages);
}
if (!$flow->getController()->canPushEntity($entity, $reason, $action)) {
return FALSE;
}
$any_pool = reset($pools);
// Ignore any push attempts if the site wasn't registered yet. This can
// happen if the site imported Flow and Pool configuration but didn't
// register.
try {
$client = $any_pool->getClient();
if (!$client->isSiteRegistered()) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__NOT_REGISTERED;
return FALSE;
}
}
catch (\Exception $e) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__NOT_REGISTERED;
return FALSE;
}
if (SyncCoreFactory::featureEnabled(ISyncCore::FEATURE_PUSH_TO_MULTIPLE_POOLS)) {
$intent = new PushIntent($flow, $pools, $reason, $action, $entity, $individual_language, $languages);
return $intent->execute($return_intent);
}
$result = FALSE;
foreach ($pools as $pool) {
$intent = new PushIntent($flow, [$pool], $reason, $action, $entity, $individual_language);
if ($return_intent) {
$result = $intent->execute(TRUE);
if ($result) {
return $result;
}
}
else {
$result |= $intent->execute();
}
}
return $result;
}
/**
* Get the reason why a push has not happened.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* @param bool $as_message
*
* @return null|Exception|string see self::$noPushReasons
*/
public static function getNoPushReason($entity, $as_message = FALSE) {
// If push wasn't even tried, no pool has been assigned.
if (empty(self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()])) {
$issue = self::NO_PUSH_REASON__NO_POOL;
}
else {
$issue = self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()];
}
if ($as_message) {
$shared_id = EntityHandlerPluginManager::getSharedId($entity);
if ($message = self::getNoPushMessage($entity->getEntityTypeId(), $shared_id)) {
return $message;
}
return self::displayNoPushReason(
$issue
);
}
return $issue;
}
/**
* Get a user message on why the push failed.
*
* @param Exception|string $reason
* The reason from self::getNoPushReason()
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup|string
*/
public static function displayNoPushReason($reason) {
if ($reason instanceof \Exception) {
return $reason->getMessage();
}
switch ($reason) {
case self::NO_PUSH_REASON__HANDLER_IGNORES:
return t('The configuration forbids the push.');
case self::NO_PUSH_REASON__JUST_PULLED:
return t('The entity has just been pulled and cannot be pushed immediately with the same request.');
case self::NO_PUSH_REASON__NEVER_PUSHED:
return t('The entity has not been pushed before, so pushing the deletion doesn\'t have any effect.');
case self::NO_PUSH_REASON__UNCHANGED:
return t('The entity has not changed since it\'s last push.');
case self::NO_PUSH_REASON__NOT_REGISTERED:
return t('The site wasn\'t registered yet.');
default:
return t('The entity doesn\'t have any Pool assigned.');
}
}
/**
* Helper function to push an entity and display the user the results. If
* you want to make changes programmatically, use ::pushEntity() instead.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to push.
* @param string $reason
* {@see Flow::PUSH_*}.
* @param string $action
* {@see ::ACTION_*}.
* @param \Drupal\cms_content_sync\Entity\Flow $flow
* The flow to be used. If none is given, all flows that may push this.
* @param \Drupal\cms_content_sync\Entity\Pool[] $pools
* The pool to be used. If not set, all relevant all pools for the flow will be used.
* @param string[] $languages
* The languages to push; defaults to all.
*
* @return bool whether the entity is configured to be pushed or not
*/
public static function pushEntityFromUi(EntityInterface $entity, $reason, $action, ?Flow $flow = NULL, ?array $pools = NULL, ?array $languages = NULL) {
$messenger = \Drupal::messenger();
try {
$status = self::pushEntity($entity, $reason, $action, $flow, $pools, FALSE, NULL, $languages);
if ($status) {
$link = 'node' === $entity->getEntityTypeId() && SyncIntent::ACTION_DELETE != $action
? Link::createFromRoute('View progress', 'cms_content_sync.content_sync_status', ['node' => $entity->id()])->toString()
: '';
if (SyncIntent::ACTION_DELETE == $action) {
$message = t('%label has been pushed to your @repository.', ['@repository' => _cms_content_sync_get_repository_name(), '%label' => $entity->getEntityTypeId()]);
}
else {
$message = t('%label has been pushed to your @repository. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'@view_progress' => $link,
]);
}
$update_progress = NULL;
$is_cli = defined('STDIN') || 'cli' === php_sapi_name();
if (!$is_cli) {
static $embed = NULL;
if (!$embed) {
$embed = Embed::create(\Drupal::getContainer());
}
$update_progress = $embed->updateStatusBox($entity, TRUE);
}
if (empty($update_progress)) {
$messenger->addMessage($message);
}
else {
$messenger->addMessage([
'message' => ['#markup' => $message],
'update_progress' => $update_progress,
]);
}
return TRUE;
}
return FALSE;
}
catch (SyncException $e) {
$root_exception = $e->getRootException();
$message = $root_exception ? $root_exception->getMessage() : (
$e->errorCode == $e->getMessage() ? '' : $e->getMessage()
);
if ($message) {
$messenger->addWarning(t('Failed to push %label to your @repository (%code). Message: %message', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
'%message' => $message,
]));
LoggerProxy::get()->error('Failed to push %label to your @repository (%code). Message: %message<br>Error stack: %error_stack', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
'%message' => $message,
'%error_stack' => $root_exception ? $root_exception->getTraceAsString() : '',
]);
}
else {
$messenger->addWarning(t('Failed to push %label to your @repository (%code).', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
]));
LoggerProxy::get()->error('Failed to push %label to your @repository (%code).', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
]);
}
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = $e;
return TRUE;
}
}
/**
* Helper function to push multiple entities and display the user the results.
* If you want to make changes programmatically, use ::pushEntity() instead
* per entity.
*
* @param \Drupal\Core\Entity\EntityInterface[][] $entity
* Every translation that should be pushed, grouped by entity.
* The entity to push.
* @param string $reason
* {@see Flow::PUSH_*}.
* @param string $action
* {@see ::ACTION_*}.
* @param \Drupal\cms_content_sync\Entity\Flow $flow
* The flow to be used.
* @param bool $run_in_order
* Whether the updates should be applied one after another. This also
* means that if an update fails, all updates after it will also fail.
* @param int $priority
* The publishing priority, if not the site's default.
* @param array $delete_translation_groups
* Translations to be deleted remotely, if any.
*
* @return bool whether the entity is configured to be pushed or not.
*/
public static function pushEntitiesFromUi(array $translation_groups, $reason, $action, Flow $flow, bool $run_in_order = FALSE, ?int $priority = NULL, ?array $delete_translation_groups = NULL) {
$messenger = \Drupal::messenger();
$operation = NULL;
$intents = [];
$success_messages = [];
$is_cli = defined('STDIN') || 'cli' === php_sapi_name();
if (!$is_cli) {
static $embed = NULL;
if (!$embed) {
$embed = Embed::create(\Drupal::getContainer());
}
}
$translation_group_summary = [];
foreach ($translation_groups as $update_translations) {
$root = $update_translations[0];
if ($root instanceof TranslatableInterface) {
$languages = [];
$root = $root->getUntranslated();
foreach ($update_translations as $translation) {
$languages[] = $translation->language()->getId();
}
}
else {
$languages = NULL;
}
$translation_group_summary[] = [
'root' => $root,
'update' => $languages,
'delete' => NULL,
];
}
if (!empty($delete_translation_groups)) {
foreach ($delete_translation_groups as $delete_translations) {
$root = $delete_translations[0];
$delete = array_slice($delete_translations, 1);
$found = FALSE;
foreach ($translation_group_summary as &$existing) {
if ($existing['root']->getEntityTypeId() === $root->getEntityTypeId() && $existing['root']->id() === $root->id()) {
$existing['delete'] = $delete;
$found = TRUE;
break;
}
}
if (!$found) {
$translation_group_summary[] = [
'root' => $root instanceof TranslatableInterface ? $root->getUntranslated() : $root,
'update' => [],
'delete' => $delete,
];
}
}
}
foreach ($translation_group_summary as $actions) {
$entity = $actions['root'];
$update = $actions['update'];
$delete = $actions['delete'];
try {
$pools = self::getPoolsToPushTo($entity, $reason, $action, $flow);
if (!count($pools)) {
continue;
}
if (!$operation) {
$any_pool = reset($pools);
// Ignore any push attempts if the site wasn't registered yet. This can
// happen if the site imported Flow and Pool configuration but didn't
// register.
try {
$client = $any_pool->getClient();
if (!$client->isSiteRegistered()) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__NOT_REGISTERED;
continue;
}
}
catch (\Exception $e) {
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = self::NO_PUSH_REASON__NOT_REGISTERED;
continue;
}
$operation =
reset($pools)
->getClient()
->getSyndicationService()
->pushMultiple($flow->id);
if ($run_in_order) {
$operation->runInOrder(TRUE);
}
if ($priority !== NULL) {
$operation->setPriority($priority);
}
}
$intent = new PushIntent($flow, $pools, $reason, $action, $entity, FALSE, $update, $delete);
$intent->setAsyncOperation($operation);
$status = $intent->execute(FALSE);
$intents[] = $intent;
if ($status) {
$link = 'node' === $entity->getEntityTypeId() && SyncIntent::ACTION_DELETE != $action
? Link::createFromRoute('View progress', 'cms_content_sync.content_sync_status', ['node' => $entity->id()])->toString()
: '';
if (SyncIntent::ACTION_DELETE == $action) {
$message = t('%label has been pushed to your @repository.', ['@repository' => _cms_content_sync_get_repository_name(), '%label' => $entity->getEntityTypeId()]);
}
else {
if ($update === NULL) {
if (!empty($delete)) {
$message = t('%label has been pushed to your @repository and the translation %deleted has been deleted. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%deleted' => implode(', ', $delete),
'@view_progress' => $link,
]);
}
else {
$message = t('%label has been pushed to your @repository. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'@view_progress' => $link,
]);
}
}
elseif (count($update) === 0) {
$message = t('The deletion of the %deleted translation of %label has been pushed to your @repository. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%deleted' => implode(', ', $delete),
'@view_progress' => $link,
]);
}
else {
if (!empty($delete)) {
$message = t('%label has been pushed to your @repository with @updated updated and @deleted deleted translations. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'@updated' => count($update),
'@deleted' => count($delete),
'@view_progress' => $link,
]);
}
else {
if (count($update) === 1) {
$message = t('%label has been pushed to your @repository. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity instanceof TranslatableInterface ? $entity->getTranslation($update[0])->label() : $entity->label(),
'@count' => count($update),
'@view_progress' => $link,
]);
}
elseif (count($update) > 1) {
$message = t('%label has been pushed to your @repository with @count translations total. @view_progress', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->getTranslation($update[0])->label(),
'@count' => count($update),
'@view_progress' => $link,
]);
}
}
}
}
if ($is_cli) {
$update_progress = NULL;
}
else {
$update_progress = $embed->updateStatusBox($entity, TRUE);
}
if (empty($update_progress)) {
$success_messages[] = $message;
}
else {
$success_messages[] = [
'message' => ['#markup' => $message],
'update_progress' => $update_progress,
];
}
}
}
catch (SyncException $e) {
$root_exception = $e->getRootException();
$message = $root_exception ? $root_exception->getMessage() : (
$e->errorCode == $e->getMessage() ? '' : $e->getMessage()
);
if ($message) {
$messenger->addWarning(t('Failed to push %label to your @repository (%code). Message: %message', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
'%message' => $message,
]));
LoggerProxy::get()->error('Failed to push %label to your @repository (%code). Message: %message<br>Error stack: %error_stack', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
'%message' => $message,
'%error_stack' => $root_exception ? $root_exception->getTraceAsString() : '',
]);
}
else {
$messenger->addWarning(t('Failed to push %label to your @repository (%code).', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
]));
LoggerProxy::get()->error('Failed to push %label to your @repository (%code).', [
'@repository' => _cms_content_sync_get_repository_name(),
'%label' => $entity->label(),
'%code' => $e->errorCode,
]);
}
self::$noPushReasons[$entity->getEntityTypeId()][$entity->uuid()] = $e;
}
}
if ($operation) {
try {
$operation->execute();
}
catch (SyncCoreException $e) {
foreach ($intents as $intent) {
$intent->handleFinishedAsyncOperation($e);
}
return FALSE;
}
foreach ($intents as $intent) {
$intent->handleFinishedAsyncOperation(NULL);
}
foreach ($success_messages as $message) {
$messenger->addMessage($message);
}
}
return TRUE;
}
/**
* Push the provided entity along with the processed entity by embedding it
* right into the current entity. This means the embedded entity can't be used
* outside of it's parent entity in any way. This is used for field
* collections right now.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The referenced entity to push as well.
* @param array $details
* {@see SyncIntent::getEmbedEntityDefinition}.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @return array|object the definition you can store via {@see SyncIntent::setField} and on the other end receive via {@see SyncIntent::getField}
*/
public function embed($entity, $details = NULL) {
return $this->embedForFlowAndPools($entity, $details, $this->flow, $this->pools);
}
/**
* Push the provided entity as a dependency meaning the referenced entity
* is available before this entity so it can be referenced on the remote site
* immediately like bricks or paragraphs.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The referenced entity to push as well.
* @param array $details
* {@see SyncIntent::getEmbedEntityDefinition}.
* @param bool $push_to_same_pool
* @param string|null $view_url
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @return array|object the definition you can store via {@see SyncIntent::setField} and on the other end receive via {@see SyncIntent::getField}
*/
public function addDependency($entity, $details = NULL, $push_to_same_pool = TRUE, ?string $view_url = NULL) {
$embed = reset($this->pools)->getClient() instanceof SyncCore || in_array($entity->getEntityTypeId(), ContentSyncSettings::getInstance()->getEmbedEntities());
if ($embed) {
$result = $this->pushReference($entity, $details, TRUE, $push_to_same_pool, TRUE);
if ($result) {
return $result;
}
$all_pools = NULL;
}
else {
$all_pools = $this->pushReference($entity, $details, TRUE, $push_to_same_pool);
}
// Not pushed? Just using our current pool then to de-reference it at the remote site if the entity exists.
if (empty($all_pools)) {
return $this->operation->addReference(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
$this->getPoolIds(),
$entity->language()->getId(),
$entity->label(),
$details,
$view_url
);
}
$result = NULL;
foreach ($all_pools as $pool) {
$result = $this->operation->addDependency(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
[$pool->id()],
$entity->language()->getId(),
$entity->label(),
$details
);
}
return $result;
}
/**
* Push the provided entity as a simple reference. There is no guarantee the
* referenced entity will be available on the remote site as well, but if it
* is, it will be de-referenced. If you need the referenced entity to be available,
* use {@see PushIntent::addDependency} instead.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The referenced entity to push as well.
* @param array $details
* {@see SyncIntent::getEmbedEntityDefinition}.
* @param string|null $view_url
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @return array|object the definition you can store via {@see SyncIntent::setField} and on the other end receive via {@see SyncIntent::getField}
*/
public function addReference($entity, $details = NULL, ?string $view_url = NULL) {
// Check if the Pool has been selected manually. In this case, we need to embed the entity despite the AUTO PUSH not being set.
$statuses = EntityStatus::getInfosForEntity($entity->getEntityTypeId(), $entity->uuid(), ['flow' => $this->flow->id()]);
if (in_array($entity->getEntityTypeId(), ContentSyncSettings::getInstance()->getEmbedEntities())) {
$result = NULL;
$pools = [];
foreach ($statuses as $status) {
if ($status->isManualPushEnabled()) {
$pools[] = $status->getPool();
}
}
if (count($pools)) {
$result = $this->embedForFlowAndPools($entity, $details, $status->getFlow(), $pools);
}
if ($result) {
return $result;
}
// Not pushed? Just using our current pool then to de-reference it at the remote site if the entity exists.
return $this->operation->addReference(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
$this->getPoolIds(),
$entity->language()->getId(),
$entity->label(),
$details,
$view_url
);
}
foreach ($statuses as $status) {
if ($status->isManualPushEnabled()) {
// This is only relevant for dependencies.
// If the mode is "all" or "manual" we must not add them as a dependency as otherwise this can result in pushing referenced entities endlessly.
if ($this->flow->getController()->canPushEntity($entity, PushIntent::PUSH_AS_DEPENDENCY)) {
return $this->addDependency($entity, $details, FALSE, $view_url);
}
}
}
$all_pools = $this->pushReference($entity, $details, FALSE);
// Not pushed? Just using our current pool then to de-reference it at the remote site if the entity exists.
if (empty($all_pools)) {
return $this->operation->addReference(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
$this->getPoolIds(),
$entity->language()->getId(),
$entity->label(),
$details,
$view_url
);
}
$result = NULL;
foreach ($all_pools as $pool) {
$result = $this->operation->addReference(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
[$pool->id()],
$entity->language()->getId(),
$entity->label(),
$details,
$view_url
);
}
return $result;
}
/**
* Set the value of the given field. By default every field handler
* will have a field available for storage when pulling / pushing that
* accepts all non-associative array-values. Within this array you can
* use the following types: array, associative array, string, integer, float,
* boolean, NULL. These values will be JSON encoded when pushing and JSON
* decoded when pulling. They will be saved in a structured database by
* Sync Core in between, so you can't pass any non-array value by default.
*
* @param string $name
* The name of the field in question.
* @param mixed $value
* The value to store.
* @param bool $requried
* Whether empty values can be skipped.
*/
public function setProperty($name, $value, $required = FALSE) {
// Don't need to store empty values.
if (NULL === $value) {
return;
}
if (!$required) {
if ('' === $value || (is_array($value) && (0 === count($value) || (1 === count($value) && isset($value[0]) && is_array($value[0]) && 0 === count($value[0]))))) {
return;
}
}
$this->operation->setProperty($name, $value, $this->activeLanguage);
}
/**
* Get a property value as it was set before.
*
* @return mixed
*/
public function getProperty(string $name) {
return $this->operation->getProperty($name);
}
/**
*
*/
protected function getHandler() {
$config = $this->flow->getController()->getEntityTypeConfig($this->entityType, $this->bundle);
return $this->flow->getController()->getEntityTypeHandler($this->entityType, $this->bundle, $config);
}
/**
* Save that the pull for the given entity failed.
*
* @param string $failure_reason
* See PushIntent::PUSH_FAILURE_*.
* @param null|string $message
* An optional message accompanying this error.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function saveFailedPush($failure_reason, $message = NULL) {
$soft_fails = [
PushIntent::PUSH_FAILED_HANDLER_DENIED,
PushIntent::PUSH_FAILED_UNCHANGED,
];
$soft = in_array($failure_reason, $soft_fails);
$this->entity_status->didPushFail(TRUE, $soft, [
'error' => $failure_reason,
'action' => $this->getAction(),
'reason' => $this->getReason(),
'message' => $message,
]);
$this->entity_status->save();
}
/**
* @return int
*/
protected function getRequestTime() {
return (int) $_SERVER['REQUEST_TIME'];
}
/**
* Get the changed date of the entity. Not all entities provide the required attribute and even those aren't
* consistently saving it so this method takes care of these exceptions.
*
* @todo Check if we should remove this as we're no longer using this changed
* date for deciding whether to push an entity or not (using hashes now).
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* @param bool $specific_translation
*
* @return int
*/
protected function getEntityChangedTime($entity, $specific_translation = FALSE) {
$request_time = $this->getRequestTime();
$push = $request_time;
if ($entity instanceof EntityChangedInterface) {
$push = $specific_translation ? (int) $entity->getChangedTime() : (int) $entity->getChangedTimeAcrossTranslations();
// Check if any bricks were updated during this request that this specific entity is referencing
// Quick edit doesn't update the changed date of the node...... so we have to go and see manually if anything
// changed by caching it....
if ($push < $request_time && $this->isQuickEdited) {
return $request_time;
}
}
if (EntityHandlerPluginManager::isEntityTypeFieldable($entity->getEntityTypeId())) {
/**
* @var \Drupal\Core\Entity\EntityFieldManager $entity_field_manager
*/
$entity_field_manager = \Drupal::service('entity_field.manager');
/**
* @var \Drupal\Core\Field\FieldDefinitionInterface[] $fields
*/
$fields = $entity_field_manager->getFieldDefinitions($entity->getEntityTypeId(), $entity->bundle());
// Elements that are inline edited in other forms like media elements edited within node forms don't get their
// timestamp updated even though the file attributes may change like the focal point. So we are checking for file
// reference fields and check if they were updated in the meantime.
foreach ($fields as $key => $field) {
if ('image' === $field->getType()) {
$data = $entity->get($key)->getValue();
foreach ($data as $delta => $value) {
if (empty($value['target_id'])) {
continue;
}
$entityTypeManager = \Drupal::entityTypeManager();
$storage = $entityTypeManager->getStorage('file');
$target_id = $value['target_id'];
$reference = $storage->load($target_id);
if (!$reference) {
continue;
}
$sub = $this->getEntityChangedTime($reference, $specific_translation);
if ($sub > $push) {
$push = $sub;
}
}
}
}
}
// File entities timestamp doesn't change when focal point is updated and crop entity doesn't provide a changed date.
if ('file' === $entity->getEntityTypeId()) {
/**
* @var \Drupal\file\FileInterface $entity
*/
// Handle crop entities.
$moduleHandler = \Drupal::service('module_handler');
if ($moduleHandler->moduleExists('crop') && $moduleHandler->moduleExists('focal_point')) {
if (Crop::cropExists($entity->getFileUri(), 'focal_point')) {
$crop = Crop::findCrop($entity->getFileUri(), 'focal_point');
if ($crop) {
$info = EntityStatus::getInfoForEntity('file', $entity->uuid(), $this->flow->id(), reset($this->pools)->id());
if ($info) {
$position = $crop->position();
$last = $info->getData('crop');
if (empty($last) || $position['x'] !== $last['x'] || $position['y'] !== $last['y']) {
$push = $this->getRequestTime();
}
}
}
}
}
}
return $push;
}
/**
* Check whether the entity changed at all since the last push.
*
* @return bool
*/
protected function entityChanged() {
$last_hash = $this->entity_status->getEntityPushHash();
$new_hash = $this->operation->getEntityHash();
return $last_hash !== $new_hash;
}
/**
* Push the provided entity along with the processed entity by embedding it
* right into the current entity. This means the embedded entity can't be used
* outside of it's parent entity in any way. This is used for field
* collections right now.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The referenced entity to push as well.
* @param null|array $details
* {@see SyncIntent::getEmbedEntityDefinition}.
* @param \Drupal\cms_content_sync\Entity\Flow $flow
* @param \Drupal\cms_content_sync\Entity\Pool[] $pools
* @param mixed $optional
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @return array|object the definition you can store via {@see SyncIntent::setField} and on the other end receive via {@see SyncIntent::getField}
*/
protected function embedForFlowAndPools($entity, $details, $flow, $pools, $optional = FALSE) {
$start = microtime(TRUE);
$individual_translation = $this->isIndividualTranslation();
if ($individual_translation) {
if ($entity instanceof TranslatableInterface) {
if ($entity->hasTranslation($this->individualLanguage)) {
$entity = $entity->getTranslation($this->individualLanguage);
}
}
}
/**
* @var PushIntent $embed_entity
*/
$embed_entity = PushIntent::pushEntity(
$entity,
PushIntent::PUSH_AS_DEPENDENCY,
SyncIntent::ACTION_CREATE,
$flow,
$pools,
TRUE,
$this->individualLanguage
);
$this->childTime += microtime(TRUE) - $start;
if (!$embed_entity) {
if ($optional) {
return NULL;
}
throw new SyncException(SyncException::CODE_UNEXPECTED_EXCEPTION, NULL, 'Failed to embed entity ' . $entity->getEntityTypeId() . ' ' . $entity->uuid() . ': ' . PushIntent::getNoPushReason($entity, TRUE));
}
$this->addCacheTags($embed_entity->getCacheTags());
$result = $this->operation->embed(
$entity->getEntityTypeId(),
$entity->bundle(),
$entity->uuid(),
EntityHandlerPluginManager::getIdOrNull($entity),
Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle()),
$embed_entity->getOperation(),
$details
);
$this->embeddedPushIntents[] = $embed_entity;
return $result;
}
/**
* @param \Drupal\Core\Entity\EntityInterface $entity
* @param array $details
* @param bool $dependency
* @param bool $push_to_same_pool
* @param bool $embed
*
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\cms_content_sync\Exception\SyncException
*
* @return \Drupal\cms_content_sync\Entity\Pool[] The Pools that were used
*/
protected function pushReference($entity, $details, $dependency, $push_to_same_pool = FALSE, $embed = FALSE) {
$embed_result = NULL;
try {
$all_pools = Pool::getAll();
$all_pools = $this->flow->getController()->getPoolsToPushTo($entity, PushIntent::PUSH_AS_DEPENDENCY, SyncIntent::ACTION_CREATE, TRUE);
$preferred_pools = [];
foreach ($this->pools as $pool) {
if (isset($all_pools[$pool->id]) || $push_to_same_pool) {
$preferred_pools[$pool->id] = $pool;
}
}
$all_pools = $preferred_pools + $all_pools;
$used_pools = [];
$version = Flow::getEntityTypeVersion($entity->getEntityTypeId(), $entity->bundle());
$flow = $this->flow;
if (!$flow->getController()->canAddEntityAsDependency($entity)) {
return $embed ? $embed_result : $used_pools;
}
$export_pools = $flow->getController()->getEntityTypeConfig($entity->getEntityTypeId(), $entity->bundle())['export_pools'];
// Make sure the first pool we try is the pool of the current parent
// push operation.
$preferred_pools = [];
foreach ($this->pools as $pool) {
if (isset($export_pools[$pool->id])) {
$preferred_pools[$pool->id] = $export_pools[$pool->id];
}
}
$export_pools = $preferred_pools + $export_pools;
foreach ($export_pools as $pool_id => $behavior) {
if (empty($all_pools[$pool_id]) || in_array($all_pools[$pool_id], $used_pools)) {
continue;
}
if (Pool::POOL_USAGE_FORBID == $behavior) {
continue;
}
// If this entity was newly created, it won't have any groups to push to
// selected, unless they're FORCED. In this case we add default sync
// groups based on the parent entity, as you would expect.
if ($dependency) {
if (!isset($all_pools[$pool_id])) {
continue;
}
$pool = $all_pools[$pool_id];
$info = EntityStatus::getInfoForEntity($entity->getEntityTypeId(), $entity->uuid(), $flow, $pool);
if (!$info) {
if (!$push_to_same_pool) {
continue;
}
$info = EntityStatus::create([
'flow' => $flow->id,
'pool' => $pool->id,
'entity_type' => $entity->getEntityTypeId(),
'entity_uuid' => $entity->uuid(),
'entity_type_version' => $version,
'flags' => 0,
]);
}
$info->isPushEnabled(NULL, TRUE);
if ($embed) {
$info->setLastPush($this->getEntityChangedTime($entity));
}
$info->save();
}
else {
$pool = $all_pools[$pool_id];
if (Pool::POOL_USAGE_ALLOW == $behavior) {
$info = EntityStatus::getInfoForEntity($entity->getEntityTypeId(), $entity->uuid(), $flow, $pool);
if (!$info || !$info->isPushEnabled()) {
continue;
}
}
}
$info = EntityStatus::getInfoForEntity($entity->getEntityTypeId(), $entity->uuid(), $flow, $pool);
// In case the handler denied pushing the entity, we simply ignore the attempt.
if (!$info || !$info->getLastPush($this->individualLanguage)) {
// Unless we are referencing our parent entity that is also being pushed right now
// e.g. a menu item will reference it's parent node but the parent will trigger
// the menu item push so the status entity isn't there yet.
if (self::isPushing($entity->getEntityTypeId(), $entity->uuid())) {
continue;
}
}
$used_pools[] = $all_pools[$pool_id];
}
if ($dependency) {
if ($embed) {
$new_embed_result = $this->embedForFlowAndPools($entity, $details, $flow, $used_pools, FALSE);
if ($new_embed_result && !$embed_result) {
$embed_result = $new_embed_result;
}
}
else {
$individual_translation = $this->isIndividualTranslation();
if ($individual_translation) {
if ($entity instanceof TranslatableInterface) {
if ($entity->hasTranslation($this->individualLanguage)) {
$entity = $entity->getTranslation($this->individualLanguage);
}
}
}
PushIntent::pushEntity($entity, PushIntent::PUSH_AS_DEPENDENCY, SyncIntent::ACTION_CREATE, $flow, $used_pools, FALSE, $individual_translation);
}
}
}
catch (\Exception $e) {
$this->saveFailedPush(PushIntent::PUSH_FAILED_DEPENDENCY_PUSH_FAILED, $e->getMessage());
throw new SyncException(SyncException::CODE_UNEXPECTED_EXCEPTION, $e);
}
return $embed ? $embed_result : $used_pools;
}
/**
* Whether or not the user is allowed to select what languages to push.
* Depends on the feature flags set for this space at the Sync Core.
*
* @return bool
*/
public static function canHandleTranslationsIndependently() {
static $result = NULL;
if ($result !== NULL) {
return $result;
}
$client = SyncCoreFactory::getSyncCoreV2();
return $result = $client->featureEnabled(ISyncCore::FEATURE_REQUEST_PER_TRANSLATION) && $client->featureEnabled(ISyncCore::FEATURE_SKIP_UNCHANGED_TRANSLATIONS);
}
}
