workbench_moderation_migrate-1.0.0-alpha4/src/ModerationStateMigrate.php

src/ModerationStateMigrate.php
<?php

declare(strict_types = 1);

namespace Drupal\workbench_moderation_migrate;

use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Database\Connection;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
use Drupal\migrate\Row;
use Drupal\workflows\StateInterface;
use Drupal\workflows\TransitionInterface;
use Drupal\workflows\WorkflowInterface;

/**
 * Manages moderation states of nodes.
 */
class ModerationStateMigrate {

  /**
   * Migration tag added to moderated node migrations.
   *
   * @const string
   */
  const MODERATED_NODE_MIGRATION_TAG = 'Workbench Moderated Content';

  /**
   * State ID of published state in Drupal 7.
   *
   * @const string
   */
  const DRUPAL7_PUBLISHED_STATE_ID = 'published';

  /**
   * State ID of published state in Drupal 9.
   *
   * @const string
   */
  const DRUPAL9_MIGRATED_PUBLISHED_STATE_ID = 'published';

  /**
   * Threshold for timestamp after a published clone was saved after a draft op.
   *
   * @const int
   */
  const CLONE_SAVE_TIME_UNCERTAINTY_THRESHOLD = 1;

  /**
   * Data column name of the calculated history timestamp.
   *
   * @const string
   */
  const STAMP_CALCULATED = 'stamp_calc';

  /**
   * Local cache for the discovered applicable workflow's ID p source node type.
   *
   * @var string[]
   */
  private static $matchingModerationFlowIds = [];

  /**
   * Local cache for the discovered computed unpublished states.
   *
   * @var string[]
   */
  private static $applicableUnpublishedStates = [];

  /**
   * Adds the appropriate moderation state property value to a node migrate row.
   *
   * @param \Drupal\migrate\Row $row
   *   A migration row representing a node revision source item.
   * @param \Drupal\Core\Database\Connection $source_connection
   *   Database connection to the source Drupal 7 instance.
   *
   * @throws \Drupal\migrate\MigrateSkipRowException
   *   When a particular node revision shouldn't be migrated.
   */
  public static function prepareModeratedNodeRow(Row $row, Connection $source_connection) {
    if (
      !($nid = $row->getSourceProperty('nid')) ||
      !($vid = $row->getSourceProperty('vid'))
    ) {
      return;
    }

    $all_migratable_revision_all_trans = static::getAllTransitionOfMigratableRevisions(
      $source_connection,
      $nid,
      $row->getSourceProperty('type')
    );
    $actual_revisions_last_transition = static::getLastTransitionRelativeToRevision($all_migratable_revision_all_trans, $vid, 'current');
    $next_revisions_last_transition = static::getLastTransitionRelativeToRevision($all_migratable_revision_all_trans, $vid, 'next');
    $previous_published_transition_exists = static::publishedTransitionExists($all_migratable_revision_all_trans, $vid);

    // Skipping clones:
    // Ignore the migration of the current revision if:
    // - the current revision is published, AND
    // - the prev transitions "[to_]state" is not published, AND
    // - the prev prev transitions "[to_]state" is published.
    if (
      $actual_revisions_last_transition['state'] === static::DRUPAL7_PUBLISHED_STATE_ID &&
      $previous_published_transition_exists &&
      static::isPublishedClone($all_migratable_revision_all_trans, $actual_revisions_last_transition)
    ) {
      throw new MigrateSkipRowException(
        "Skipping the migration of this published revision: it is the copy of the last published revision. It was saved by Workbench Moderation as a workaround for the lack of a feature in Drupal 7, because it wasn't able to handle forward (non-default) revisions. In Drupal 9 this is not needed anymore.",
        TRUE
      );
    }

    // Ignore the migration of the current non-default (not published) revision
    // if:
    // 1. The next transition (whose node revision exists) "[to_]state" is
    //    "published"
    // 2. There isn't any previous node revision which was migrated as
    //    "published", but the next transition's from_state is published.
    if (
      $next_revisions_last_transition &&
      $actual_revisions_last_transition['state'] !== static::DRUPAL7_PUBLISHED_STATE_ID &&
      ($next_revisions_last_transition['from_state'] === static::DRUPAL7_PUBLISHED_STATE_ID && !$previous_published_transition_exists) &&
      $next_revisions_last_transition['state'] === static::DRUPAL7_PUBLISHED_STATE_ID
    ) {
      throw new MigrateSkipRowException(
        "Skipping the migration of this draft revision: it lacks its previous revision. It happens because with Drupal 7 Workbench Moderation it was possible to delete older revisions, but in Drupal 9 core it is impossible to restore the original data integrity. Hopefully it isn't a problem that a draft cannot be restored.",
        TRUE
      );
    }

    // If the last active ("is_current") moderation state of the entity isn't
    // published, then we shouldn't allow the entire entity being published: we
    // have to archive the entity.
    $moderation_state = $actual_revisions_last_transition['state'];
    if (
      $next_revisions_last_transition === NULL &&
      $moderation_state !== static::DRUPAL7_PUBLISHED_STATE_ID &&
      static::publishedTransitionExists($all_migratable_revision_all_trans)
    ) {
      $moderation_state = 'archive';
    }

    $row->setDestinationProperty('moderation_state', $moderation_state);

    // If the actual revision lacks its history, add note about that the state
    // is calculated.
    if (!$actual_revisions_last_transition['hid']) {
      $row->setSourceProperty(
        'log',
        implode(' ', array_filter([
          $row->getSourceProperty('log'),
          'Moderation state computed by Workbench Moderation Migrate',
        ]))
      );
    }

    // Node revisions are migrated in ascending order of revision IDs.
    // Before a node is saved as published, every draft is a default revision.
    // After that the node was saved as published, only those revisions are
    // default revisions whose state is configured as being a default revision.
    // With Workbench Moderation Migrate, these states are 'published' and
    // 'archive'.
    $is_default_revision = in_array($moderation_state, ['published', 'archive']) || !$previous_published_transition_exists;
    $row->setDestinationProperty('revision_default', $is_default_revision);
  }

  /**
   * Returns the revision history of the given node.
   *
   * If some revisions lack their history then most column values will be NULL
   * except nid, vid (which is the node version ID – aka revision ID) or
   * timestamp.
   *
   * The returned history is sorted by a calculated timestamp, history ID and
   * state, and contains discoverable (but sometimes inaccurate) [to_]state
   * metadata.
   *
   * @param \Drupal\Core\Database\Connection $source_db
   *   Database connection to the source Drupal 7 instance.
   * @param string|int $nid
   *   The ID of the node which history should be returned.
   * @param string $source_node_type
   *   The type ID of the actual node revision on the source Drupal 7 instance.
   *
   * @return array[]
   *   The revision history of the given node, ordered by the moderation
   *   action's timestamp (so: in the order of the moderation actions, and NOT
   *   in the node revisions' migration!).
   */
  private static function getAllTransitionOfMigratableRevisions(Connection $source_db, $nid, $source_node_type): array {
    $all_history_query = $source_db->select('node_revision', 'nr');
    $all_history_query->leftJoin('workbench_moderation_node_history', 'history', 'history.vid = nr.vid');
    $all_history_query
      ->fields('history')
      ->fields('nr', ['nid', 'vid', 'status', 'timestamp'])
      ->condition('nr.nid', $nid);
    $all_history_query->addExpression('COALESCE(history.stamp, nr.timestamp)', self::STAMP_CALCULATED);

    $all_history = $all_history_query->execute()->fetchAll(\PDO::FETCH_ASSOC);

    $non_published_state = static::getNonPublishedStateAllowingTransitionToPublished($source_node_type);

    $all_history = array_reduce(
      $all_history,
      function (array $carry, array $last_transition) use ($non_published_state) {
        if ($last_transition['state'] === NULL) {
          // Unfortunately, revision status based state is only accurate for the
          // first unpublished revisions. If the node has an already published
          // revisions, then all forward revision's status is 1.
          $last_transition['state'] = $last_transition['status']
            ? self::DRUPAL7_PUBLISHED_STATE_ID
            : $non_published_state;
        }
        $carry[] = $last_transition;
        return $carry;
      },
      []
    );

    // Order:
    // 1. by stamp/timestamp ASC
    // 2. by hid if the above are equal and hids are available, OR by state
    //    (published comes last) OR version ID (ASC).
    usort(
      $all_history,
      function ($transition_a, $transition_b) {
        $time_a = $transition_a[self::STAMP_CALCULATED];
        $time_b = $transition_b[self::STAMP_CALCULATED];

        if ($time_a === $time_b) {
          if (isset($transition_a['hid']) && isset($transition_b['hid'])) {
            return ($transition_a['hid'] < $transition_b['hid']) ? -1 : 1;
          }

          if (
            $transition_a['state'] !== self::DRUPAL7_PUBLISHED_STATE_ID &&
            $transition_b['state'] == self::DRUPAL7_PUBLISHED_STATE_ID
          ) {
            return -1;
          }

          if (
            $transition_a['state'] === self::DRUPAL7_PUBLISHED_STATE_ID &&
            $transition_b['state'] !== self::DRUPAL7_PUBLISHED_STATE_ID
          ) {
            return 1;
          }

          return ($transition_a['vid'] < $transition_b['vid']) ? -1 : 1;
        }
        return ($time_a < $time_b) ? -1 : 1;
      }
    );

    // After we have an ordered history, we can get more info:
    // If two transition have the same (or same-ish) timestamp, then the one
    // with lower revision ID was always saved for a non-published edit.
    foreach ($all_history as $delta => $history_item) {
      if ($history_item['hid']) {
        continue;
      }
      $next_history_item = $all_history[$delta + 1] ?? NULL;

      if (
        $next_history_item  &&
        $next_history_item['state'] === self::DRUPAL7_PUBLISHED_STATE_ID &&
        $next_history_item['vid'] > $history_item['vid'] &&
        ($next_history_item[self::STAMP_CALCULATED] - $history_item[self::STAMP_CALCULATED]) >= 0 &&
        ($next_history_item[self::STAMP_CALCULATED] - $history_item[self::STAMP_CALCULATED]) <= self::CLONE_SAVE_TIME_UNCERTAINTY_THRESHOLD
      ) {
        $all_history[$delta]['state'] = $non_published_state;
      }
    }

    return $all_history;
  }

  /**
   * Returns the last transitions of the migratable node revisions.
   *
   * @param array $all_migratable_transitions
   *   The revision history of a node (returned by
   *   ::getAllTransitionOfMigratableRevisions).
   *
   * @return array[]
   *   The last transitions of the migratable revisions of the given node,
   *   ordered by the node revision (so in the order of the migration).
   */
  private static function getLastTransitions(array $all_migratable_transitions): array {
    return array_reduce(
      $all_migratable_transitions,
      function (array $carry, array $item) {
        if (($carry[$item['vid']][self::STAMP_CALCULATED] ?? 0) < $item[self::STAMP_CALCULATED]) {
          $carry[$item['vid']] = $item;
        }
        return $carry;
      },
      []
    );
  }

  /**
   * Checks whether a published migratable transition exists.
   *
   * @param array $all_migratable_transitions
   *   The revision history of a node (returned by
   *   ::getAllTransitionOfMigratableRevisions).
   * @param string|int|null $vid
   *   A node revision ID. If specified, then only the previous revisions are
   *   checked. If $vid is NULL, then every migratable revisions are checked.
   *
   * @return bool
   *   TRUE if a published migratable transition exists, FALSE if not.
   */
  private static function publishedTransitionExists(array $all_migratable_transitions, $vid = NULL): bool {
    $last_transitions = self::getLastTransitions($all_migratable_transitions);

    $published_found = FALSE;
    $vid = $vid ?? end($last_transitions)['vid'] + 1;
    reset($last_transitions);
    while (((int) (key($last_transitions) < (int) $vid)) && !$published_found) {
      $published_found = current($last_transitions)['state'] === self::DRUPAL7_PUBLISHED_STATE_ID;
      if (!next($last_transitions)) {
        break;
      }
    }

    return $published_found;
  }

  /**
   * Returns the last transition relative to the given revision ID.
   *
   * @param array $all_migratable_transitions
   *   The revision history of a node (returned by
   *   ::getAllTransitionOfMigratableRevisions).
   * @param string|int $vid
   *   The node revision ID whose previous, actual or next transition we need.
   * @param string $which
   *   Which transition should be returned. Might be "prev", "current" or
   *   "next".
   *
   * @return array|null
   *   The last transition relative to the given revision ID. If there is no
   *   matching transition, NULL is returned.
   */
  private static function getLastTransitionRelativeToRevision(array $all_migratable_transitions, $vid, string $which): ?array {
    $last_transitions = self::getLastTransitions($all_migratable_transitions);

    if (!isset($last_transitions[$vid])) {
      return NULL;
    }

    switch ($which) {
      case 'current':
      case 'next':
      case 'prev':
        break;

      default:
        throw new \LogicException("Third argument must be one of 'current', 'next' or 'prev'.");
    }

    reset($last_transitions);
    while (key($last_transitions) < $vid) {
      if (!next($last_transitions)) {
        return NULL;
      }
    }

    $transition = $which($last_transitions);
    if (!$transition) {
      return NULL;
    }

    return $transition;
  }

  /**
   * Checks whether the given transition belongs to a published version's clone.
   *
   * @param array $all_revision_history
   *   The revision history of the node.
   * @param array $transition
   *   The transition to check.
   *
   * @return bool
   *   Whether the given transition belongs to a clone of a published node's
   *   revision.
   */
  private static function isPublishedClone(array $all_revision_history, array $transition): bool {
    reset($all_revision_history);
    while (current($all_revision_history) !== $transition) {
      if (!next($all_revision_history)) {
        break;
      }
    }
    $previous_transition = prev($all_revision_history);

    if (!$previous_transition) {
      return FALSE;
    }

    // If the previous transition's last history entry is the current revision,
    // this was a clone.
    if (
      ($transition[self::STAMP_CALCULATED] - $previous_transition[self::STAMP_CALCULATED]) >= 0 &&
      ($transition[self::STAMP_CALCULATED] - $previous_transition[self::STAMP_CALCULATED]) <= self::CLONE_SAVE_TIME_UNCERTAINTY_THRESHOLD
    ) {
      return TRUE;
    }

    $previous_previous_transition = $previous_transition ? prev($all_revision_history) : FALSE;

    if (!$previous_previous_transition) {
      return FALSE;
    }

    return $previous_transition['state'] !== self::DRUPAL7_PUBLISHED_STATE_ID && $previous_previous_transition['state'] === self::DRUPAL7_PUBLISHED_STATE_ID;
  }

  /**
   * Returns the moderation flow for the corresponding entity.
   *
   * @param string $source_node_type
   *   The node type ID in the source Drupal 7 instance.
   *
   * @return \Drupal\workflows\WorkflowInterface
   *   The moderation flow of the given node type.
   */
  private static function getMatchingModerationFlow(string $source_node_type) {
    if (empty(static::$matchingModerationFlowIds[$source_node_type])) {
      $migration_manager = \Drupal::service('plugin.manager.migration');
      assert($migration_manager instanceof MigrationPluginManagerInterface);
      $flow_migrations = $migration_manager->createInstances(['workbench_moderation_flow']);
      $corresponding_flow_migration = array_reduce(
        $flow_migrations,
        function ($carry, MigrationInterface $flow_migration) use ($source_node_type) {
          $node_types_aggregated = $flow_migration->getSourceConfiguration()['node_types_aggregated'] ?? '';
          if (in_array($source_node_type, explode(',', $node_types_aggregated), TRUE)) {
            return $flow_migration;
          }
          return $carry;
        }
      );
      // Every single flow is migrated by a dedicated migration, so we can
      // easily identify the destination config entity's ID.
      $destination_ids = $corresponding_flow_migration->getIdMap()->lookupDestinationIds([
        $corresponding_flow_migration->getSourceConfiguration()['default_state_serialized'],
      ]);
      if (empty($destination_ids)) {
        throw new \LogicException(
          sprintf(
            "Cannot find the corresponding moderation flow applicable for node type %s",
            $source_node_type
          )
        );
      }
      $destination_id = implode('.', reset($destination_ids));
      static::$matchingModerationFlowIds[$source_node_type] = $destination_id;
    }

    $storage = \Drupal::entityTypeManager()->getStorage('workflow');
    assert($storage instanceof ConfigEntityStorageInterface);
    $corresponding_flow = $storage->load(static::$matchingModerationFlowIds[$source_node_type]);
    if ($corresponding_flow) {
      assert($corresponding_flow instanceof WorkflowInterface);
      assert($corresponding_flow->status());
    }

    return $corresponding_flow;
  }

  /**
   * Returns the ID of an unpublished state which can transition to published.
   *
   * @param string $source_node_type
   *   The node type ID in the source Drupal 7 instance.
   *
   * @return string
   *   The ID of an unpublished state which can transition to the given state.
   */
  private static function getNonPublishedStateAllowingTransitionToPublished($source_node_type): string {
    if (empty(static::$applicableUnpublishedStates[$source_node_type])) {
      $moderation_flow = self::getMatchingModerationFlow($source_node_type);
      $transitions_to_published = array_filter(
        $moderation_flow->getTypePlugin()->getTransitions(),
        function (TransitionInterface $transition) {
          // Return TRUE if:
          // - transition's to state equals to $to_state_id.
          // - one of the from state is unpublished.
          $to_state_is_published = $transition->to()->id() === static::DRUPAL9_MIGRATED_PUBLISHED_STATE_ID;
          $one_from_state_is_unpublished = array_reduce(
            $transition->from(),
            function (bool $carry, StateInterface $from_state) {
              if (!$from_state->isPublishedState()) {
                return TRUE;
              }
              return $carry;
            },
            FALSE
          );
          return $to_state_is_published && $one_from_state_is_unpublished;
        }
      );

      $non_default_state = array_reduce(
        reset($transitions_to_published)->from(),
        function ($carry, StateInterface $from_state) {
          if (!$carry && !$from_state->isPublishedState()) {
            return $from_state->id();
          }
          return $carry;
        },
        NULL
      );
      static::$applicableUnpublishedStates[$source_node_type] = $non_default_state;
    }

    return static::$applicableUnpublishedStates[$source_node_type];
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc