You are here

drafty.module in Drafty 7

Hook implementations and API functions for the Drafty module.

File

drafty.module
View source
<?php

/**
 * @file
 * Hook implementations and API functions for the Drafty module.
 */

/**
 * Implements hook_menu().
 */
function drafty_menu() {
  $items = array();
  $items['admin/config/system/drafty'] = array(
    'title' => 'Drafty',
    'description' => 'Manage Drafty settings.',
    'type' => MENU_NORMAL_ITEM,
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'drafty_form',
    ),
    'access arguments' => array(
      'administer drafty',
    ),
    'file' => 'includes/drafty.admin.inc',
  );
  return $items;
}

/**
 * Implements hook_permission().
 */
function drafty_permission() {
  return array(
    'administer drafty' => array(
      'title' => t('Administer Drafty'),
    ),
  );
}

/**
 * Implements hook_module_implements_alter().
 */
function drafty_module_implements_alter(&$implementations) {
  if (isset($implementations['drafty'])) {
    $group = $implementations['drafty'];
    unset($implementations['drafty']);
    $implementations['drafty'] = $group;
  }
}

/**
 * Implements hook_entity_presave().
 */
function drafty_entity_presave($entity, $type) {
  $recursion_level =& drupal_static('drafty_recursion_level', 0);
  if (!$recursion_level && !drafty_entity_is_new($entity, $type) && !empty($entity->is_draft_revision)) {

    // Since this is a draft revision, after saving we want the current,
    // published revision to remain in place in the base entity table and
    // field_data_*() tables. Set the revision to publish once the draft entity
    // has been written to the database.
    list($id) = entity_extract_ids($type, $entity);
    $vid = drafty()
      ->getPublishedRevisionId($type, $id);
    drafty()
      ->setRevisionToBePublished($type, $id, $vid);
  }
  $recursion_level++;
}

/**
 * Implements hook_entity_update().
 */
function drafty_entity_update($entity, $type) {
  $recursion_level =& drupal_static('drafty_recursion_level', 0);
  if ($recursion_level == 1) {

    // Doing this in hook_entity_update() so that the entire process is
    // completed within entity saving. However this results in two entity saves
    // within entity insert. The other option is hook_exit(), which is not
    // better since for example that would happen outside the transaction.
    drafty()
      ->restorePublishedRevisions();
  }
  $recursion_level--;
}

/**
 * Implements hook_entity_insert().
 */
function drafty_entity_insert($entity, $type) {
  $recursion_level =& drupal_static('drafty_recursion_level', 0);
  $recursion_level--;
}

/**
 * Implements hook_field_attach_load().
 */
function drafty_field_attach_load($entity_type, $entities, $age, $options) {

  // Entity API provides the function entity_revision_is_default() to determine
  // whether an entity was loaded with the default revision or not. However this
  // is not sufficient for two reasons.
  //  - It relies on entity_load() for core entities, which makes it unsafe to
  //    call within hook_entity_load() implementations. This can be useful when
  //    allowing drafts to be previewed in context such as listings.
  //  - The entity API implementation only tells you whether the entity was
  //    loaded with that revision or not, but not whether it was requested
  //    with the ID or with the revision explicitly specified.
  // Note that hook_field_attach_load() is the only hook in core where it is
  // possible to determine whether calling code requested a revision or not,
  // this information is not available to hook_entity_load(). Also note that
  // hook_field_attach_load() is cached when entities are loaded only be ID, but
  // since revision loads don't use the field cache it works fine for our
  // purposes.
  foreach ($entities as $entity) {
    $entity->_drafty_revision_requested = $age;
  }
}

/**
 * Callback function to delete a single temporary-draft revision.
 *
 * Could be called either from a batch job or from a cron-queue.
 */
function drafty_queue_delete_revision($drafty_queue_item, &$context = NULL) {
  try {

    // Trigger hook_drafty_predelete_revision(). Allow other modules to
    // preserve information on the old revision before it's deleted.
    module_invoke_all('drafty_predelete_revision', $drafty_queue_item['entity_type'], $drafty_queue_item['entity_id'], $drafty_queue_item['revision_id'], $drafty_queue_item['replaced_by']);

    // Delete the old revision.
    entity_revision_delete($drafty_queue_item['entity_type'], $drafty_queue_item['revision_id']);

    // Document the successful batch operation for the batch-finished function
    // to use.
    if (!empty($context) && isset($context['results'])) {
      $context['results'][] = $drafty_queue_item;
    }
  } catch (Exception $ex) {

    // Log the problem.
    $message = 'An error occurred while deleting temporary revision: @args. Exception details: @error';
    $args = array(
      '@args' => print_r($drafty_queue_item, TRUE),
      '@error' => print_r($ex, TRUE),
    );
    watchdog('drafty', $message, $args, WATCHDOG_ERROR);
    throw $ex;

    // Batch API will pass $success = FALSE.
  }
}

/**
 * Implements hook_cron_queue_info().
 */
function drafty_cron_queue_info() {
  $queues['drafty_revision_delete'] = array(
    'worker callback' => 'drafty_queue_delete_revision',
    'time' => 60,
  );
  return $queues;
}

/**
 * Batch completion callback for when draft-deletes happen in a batch job.
 * $results contains info about successfully deleted revisions, and
 * exception objects for revision-delete options that caused problems.
 * $operations contains the operations that remained unprocessed.
 */
function drafty_delete_batch_finished($success, $results, $operations) {

  // TODO: Display a message about temporary-revision-cleanup here?
  // Note that logging of failed deletes is handled by the operation callback.
}

/**
 * Factory function for the DraftyTracker class.
 */
function drafty() {
  $tracker =& drupal_static(__FUNCTION__);
  if (!isset($tracker)) {
    $tracker = new Drafty();
  }
  return $tracker;
}

/**
 * Helper function to determine if an entity is new or not.
 *
 * The $is_new property cannot be relied on, as not all entities use this. E.g.
 * files.
 *
 * @param mixed $entity
 *   The entity to check.
 * @param string $entity_type
 *   The entity type being passed in.
 *
 * @return bool
 */
function drafty_entity_is_new($entity, $entity_type) {

  // Use is_new if it's available, since it's not impossible for new entities to
  // have an ID set.
  if (!empty($entity->is_new)) {
    return TRUE;
  }
  list($id) = entity_extract_ids($entity_type, $entity);
  return empty($id);
}

/**
 * Handles tracking, selecting and publishing revisions.
 */
class Drafty {

  /**
   * A list of entity types, ids and version IDs to be published.
   */
  protected $revisionsToPublish = array();
  public function wasRevisionRequested($entity) {
    return isset($entity->_drafty_revision_requested) && $entity->_drafty_revision_requested === FIELD_LOAD_REVISION;
  }
  public function isDraftRevision($entity) {
    return !empty($entity->is_draft_revision);
  }

  /**
   * Get the current published revision for an entity.
   *
   * @param $type
   *   The entity type.
   * @param $id
   *   The entity ID.
   *
   * @return A version ID.
   */
  public function getPublishedRevisionId($type, $id) {
    $info = entity_get_info();

    // Get the version ID of the published revision directly from the database.
    // It is not possible to rely on $entity->original here since that does not
    // guarantee being the published revision. Also avoid loading the entity
    // because we may be in the process of saving it.
    $query = db_select($info[$type]['base table'], 'b');
    $query
      ->addField('b', $info[$type]['entity keys']['revision']);
    $query
      ->condition($info[$type]['entity keys']['id'], $id);
    $vid = $query
      ->execute()
      ->fetchField();
    return $vid;
  }

  /**
   * Add a revision to be published to the tracker.
   *
   * @param $type
   *   The entity type.
   * @param $id
   *   The entity ID.
   * @param $vid
   *   The entity version ID.
   *
   * @return $this
   */
  public function setRevisionToBePublished($type, $id, $vid) {

    // Only one revision can be published during a request, so just overwrite
    // and for now last one wins.
    $this->revisionsToPublish[$type][$id] = $vid;
    return $this;
  }

  /**
   * Publish a revision.
   *
   * @param $type
   *   The entity type.
   * @param $vid
   *   The entity version ID.
   *
   * @return The newly published revision.
   */
  function publishRevision($type, $id, $vid) {

    // Title module assumes that the current content language is used when
    // saving an entity. This is OK for the new draft revision, but it does not
    // work when publishing a revision. Therefore, ensure that
    // title_active_language() reflects the original language of the entity.
    // Without this, title may overwrite {$title}_field in the original language
    // with the contents of the legacy field.
    // @todo: this might not be necessary after one or both of these patches
    // lands:
    // https://www.drupal.org/node/2267251
    // https://www.drupal.org/node/2098097
    if (module_exists('title')) {
      $entity = entity_load_single($type, $id);
      $langcode = entity_language($type, $entity);
      title_active_language($langcode);
    }
    $revision = entity_revision_load($type, $vid);

    // Publishing a revision sometimes happens within hook_entity_update(). When
    // we do that, set $entity->original to the entity we're in the process of
    // saving. i.e. the draft we're in the process of creating and need to
    // replace with the published version again.
    $revision->is_draft_revision = FALSE;
    return $this
      ->saveRevisionAsNew($type, $revision);
  }

  /**
   * Save a revision as new.
   *
   * @param $type
   *   The entity type.
   * @param $revision
   *   An entity object.
   *
   * @return The newly saved revision.
   */
  public function saveRevisionAsNew($type, $revision) {
    list($id) = entity_extract_ids($type, $revision);
    entity_get_controller($type)
      ->resetCache();
    $original = entity_load_single($type, $id);
    $revision->original = $original;

    // @todo: entity API function?
    $revision->revision = TRUE;
    $revision->is_new_revision = TRUE;
    $revision->default_revision = TRUE;
    entity_save($type, $revision);
    return $revision;
  }

  /**
   * Publish revisions previously set with setRevisionToBePublished().
   */
  public function restorePublishedRevisions() {
    $delete_old_revisions = variable_get('drafty_delete_old_revisions', FALSE);
    $delete_with_cron = variable_get('drafty_delete_with_cron', TRUE);
    $queue = NULL;
    $operations = array();
    foreach ($this->revisionsToPublish as $type => $value) {
      foreach ($value as $id => $vid) {
        unset($this->revisionsToPublish[$type][$id]);
        $published_revision = $this
          ->publishRevision($type, $id, $vid);

        // Now that the revision is deleted, there are two identical copies of
        // the revision in the system. The original 'draft' revision and the
        // newly saved published revision.
        if ($delete_old_revisions) {
          list(, $replaced_by) = entity_extract_ids($type, $published_revision);

          // Deletion must be done on a different request thread, or any other
          // hooks called for the revision-to-be-deleted will fail.
          $drafty_queue_item = array(
            'entity_type' => $type,
            'entity_id' => $id,
            'revision_id' => $vid,
            'replaced_by' => $replaced_by,
          );
          if ($delete_with_cron) {
            if (empty($queue)) {
              $queue = DrupalQueue::get('drafty_revision_delete');
            }
            $queue
              ->createItem($drafty_queue_item);
          }
          else {
            $operations[] = array(
              'drafty_queue_delete_revision',
              array(
                $drafty_queue_item,
              ),
            );
          }
        }

        // @todo: when restoring a published revision, should the revision
        // timestamp be set to the old value?
      }
    }
    if (!empty($operations)) {
      $batch = array(
        'operations' => $operations,
        'finished' => 'drafty_delete_batch_finished',
      );
      batch_set($batch);
    }
  }

}

Functions

Namesort descending Description
drafty Factory function for the DraftyTracker class.
drafty_cron_queue_info Implements hook_cron_queue_info().
drafty_delete_batch_finished Batch completion callback for when draft-deletes happen in a batch job. $results contains info about successfully deleted revisions, and exception objects for revision-delete options that caused problems. $operations contains the operations that…
drafty_entity_insert Implements hook_entity_insert().
drafty_entity_is_new Helper function to determine if an entity is new or not.
drafty_entity_presave Implements hook_entity_presave().
drafty_entity_update Implements hook_entity_update().
drafty_field_attach_load Implements hook_field_attach_load().
drafty_menu Implements hook_menu().
drafty_module_implements_alter Implements hook_module_implements_alter().
drafty_permission Implements hook_permission().
drafty_queue_delete_revision Callback function to delete a single temporary-draft revision.

Classes

Namesort descending Description
Drafty Handles tracking, selecting and publishing revisions.