You are here

revisioning.module in Revisioning 7

Allows content to be updated and reviewed before submitting it for publication, while the current live revision remains unchanged and publicly visible until the changes have been reviewed and found fit for publication by a moderator.

File

revisioning.module
View source
<?php

/**
 * @file
 * Allows content to be updated and reviewed before submitting it for
 * publication, while the current live revision remains unchanged and publicly
 * visible until the changes have been reviewed and found fit for publication
 * by a moderator.
 */

// The 3 states a piece of content may be saved as.
define('REVISIONING_NO_REVISION', 0);
define('REVISIONING_NEW_REVISION_NO_MODERATION', 1);
define('REVISIONING_NEW_REVISION_WITH_MODERATION', 2);

// Paths node/%nid/view, node/%nid/edit open current revision.
define('REVISIONING_LOAD_CURRENT', 0);

// Paths node/%nid/view, node/%nid/edit open latest revison.
define('REVISIONING_LOAD_LATEST', 1);
define('REVISIONING_NEW_REVISION_WHEN_NOT_PENDING', 0);
define('REVISIONING_NEW_REVISION_EVERY_SAVE', 1);
define('REVISIONING_REVISIONS_BLOCK_OLDEST_AT_TOP', 0);
define('REVISIONING_REVISIONS_BLOCK_NEWEST_AT_TOP', 1);
module_load_include('inc', 'revisioning', 'revisioning_api');
module_load_include('inc', 'revisioning', 'revisioning.pages');
module_load_include('inc', 'revisioning', 'revisioning_theme');
module_load_include('inc', 'revisioning', 'revisioning_tokens');
module_load_include('inc', 'revisioning', 'revisioning_triggers_actions');
module_load_include('inc', 'revisioning', 'revisioning.taxonomy');
if (module_exists('rules')) {

  // This is not supposed to be necessary, but without it bad things happen.
  module_load_include('inc', 'revisioning', 'revisioning.rules');
}

/**
 * Implements hook_help().
 */
function revisioning_help($path, $arg) {
  switch ($path) {
    case 'admin/help#revisioning':
      $s = t('For documentation and tutorials see the <a href="@revisioning">Revisioning project page</a>', array(
        '@revisioning' => url('http://drupal.org/project/revisioning'),
      ));
      break;
    case 'node/%/revisions':
      $s = t('To edit, publish or delete one of the revisions below, click on its saved date.');
      break;
    case 'admin/structure/trigger/revisioning':
      $s = t("Below you can assign actions to run when certain publication-related events happen. For example, you could send an e-mail to an author when their pending content is pubished.");
      break;
    case 'accessible-content/i-created/pending':
      $s = t('Showing all <em>pending</em> content <em>you created</em> and still have at least view access to.');
      break;
    case 'accessible-content/i-last-modified/pending':
      $s = t('Showing all <em>pending</em> content <em>you last modified</em> and still have at least view access to.');
      break;
    case 'accessible-content/i-can-edit/pending':
      $s = t('Showing all <em>pending</em> content you can <em>edit</em>.');
      break;
    case 'accessible-content/i-can-view/pending':
      $s = t('Showing all <em>pending</em> content you have at least <em>view</em> access to.');
      break;
  }
  return empty($s) ? '' : $s . '<br/>';
}

/**
 * Implements hook_permission().
 *
 * Revisioning permissions. Note that permissions to view, revert and delete
 * revisions already exist in node.module.
 */
function revisioning_permission() {
  $edit_description = t('Also requires edit permission from either the Node or other content access module(s).');
  $moderated_content_types = implode(', ', revisioning_moderated_content_types(FALSE));
  $publish_description = empty($moderated_content_types) ? t('Please select one or more content types for moderation by ticking the <em>New revision in draft, pending moderation</em> <strong>Publishing option</strong> at <em>Structure >> Content types >> edit</em>.') : t('Applies to content types that are subject to moderation, i.e.: %moderated_content_types.', array(
    '%moderated_content_types' => $moderated_content_types,
  ));
  if (!empty($moderated_content_types) && variable_get('revisioning_require_update_to_publish', TRUE)) {
    $publish_description .= ' <br/>' . $edit_description;
  }
  $permissions = array(
    'view revision status messages' => array(
      'title' => t('View revision status messages'),
      'description' => '',
    ),
    'edit revisions' => array(
      'title' => t('Edit content revisions'),
      'description' => $edit_description,
    ),
    'publish revisions' => array(
      'title' => t("Publish content revisions (of anyone's content)"),
      'description' => $publish_description,
    ),
    'unpublish current revision' => array(
      'title' => t("Unpublish current revision (of anyone's content)"),
      'description' => $publish_description,
    ),
  );

  // Add per node-type view permissions in same way as edit permissions of node
  // module, but only for moderated content-types.
  foreach (node_type_get_types() as $type) {
    $machine_name = check_plain($type->type);
    if (revisioning_content_is_moderated($machine_name)) {
      $permissions['view revisions of own ' . $machine_name . ' content'] = array(
        'title' => t('%type-name: View revisions of own content', array(
          '%type-name' => $type->name,
        )),
      );
      $permissions['view revisions of any ' . $machine_name . ' content'] = array(
        'title' => t("%type-name: View revisions of anyone's content", array(
          '%type-name' => $type->name,
        )),
      );
      $permissions['publish revisions of own ' . $machine_name . ' content'] = array(
        'title' => t('%type-name: Publish revisions of own content', array(
          '%type-name' => $type->name,
        )),
      );
      $permissions['publish revisions of any ' . $machine_name . ' content'] = array(
        'title' => t("%type-name: Publish revisions of anyone's content", array(
          '%type-name' => $type->name,
        )),
      );
    }
  }
  return $permissions;
}

/**
 * Implements hook_menu().
 *
 * Define new menu items.
 * Existing menu items are modified through hook_menu_alter().
 */
function revisioning_menu() {
  $items = array();

  // Start with the Revisioning config menu item, put under Content Authoring.
  $items['admin/config/content/revisioning'] = array(
    'title' => 'Revisioning',
    'description' => 'Configure how content view and edit links behave. Customise revision summary listing.',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'revisioning_admin_configure',
    ),
    'access arguments' => array(
      'administer site configuration',
    ),
    'file' => 'revisioning.admin.inc',
  );

  // Plain link, not a tab, to allow users to unpublish a node.
  $items['node/%node/unpublish-current'] = array(
    // 'title' => t(Unpublish current revision'),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'revisioning_unpublish_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'unpublish current revision',
      1,
    ),
    'type' => MENU_CALLBACK,
  );

  // Revision tab local subtasks (i.e. secondary tabs), up to 8 of them:
  // list, view, edit, publish, unpublish, revert, delete and compare.
  // All revision operations 'node/%node/revisions/%vid/<op>' are defined as
  // local subtasks (subtabs) secondary to the primary 'node/%node/revisions'
  // local task (primary tab).
  //
  // Subtab to the Revisions primary tab to allow going back to the revisions
  // list without clicking the primary tab for a second time, which also works.
  $items['node/%node/revisions/list'] = array(
    'title' => 'List all revisions',
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'view revision list',
      1,
    ),
    'type' => MENU_LOCAL_TASK,
    'weight' => -20,
  );
  $items['node/%node/revisions/delete-archived'] = array(
    'title' => 'Delete archived revisions',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'revisioning_delete_archived_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'delete archived revisions',
      1,
    ),
    'type' => MENU_CALLBACK,
  );

  // View revision local subtask.
  // Note the use of %vid as opposed to %. This allows us to manipulate the
  // second argument in the path through vid_to_arg().
  $items['node/%node/revisions/%vid/view'] = array(
    'title' => 'View',
    'load arguments' => array(
      3,
    ),
    'page callback' => '_revisioning_view_revision',
    'page arguments' => array(
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'view revisions',
      1,
    ),
    'type' => MENU_LOCAL_TASK,
    'weight' => -10,
  );

  // Edit revision local subtask.
  $items['node/%node/revisions/%vid/edit'] = array(
    'title' => 'Edit',
    'load arguments' => array(
      3,
    ),
    'page callback' => '_revisioning_edit_revision',
    'page arguments' => array(
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'edit revisions',
      1,
    ),
    'file' => 'node.pages.inc',
    'file path' => drupal_get_path('module', 'node'),
    'type' => MENU_LOCAL_TASK,
    'weight' => -7,
  );

  // Publish revision local subtask.
  // As the menu is content type unaware, a further check on
  // node->revision_moderation must be made to determine whether it is
  // appropriate to show this tab.
  // This is done in _revisioning_access_node_revision.
  $items['node/%node/revisions/%vid/publish'] = array(
    'title' => 'Publish',
    'load arguments' => array(
      3,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'revisioning_publish_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'publish revisions',
      1,
    ),
    'type' => MENU_LOCAL_TASK,
    'weight' => -4,
  );

  // Unpublish node local subtask.
  // As the menu is content type unaware, a further check on
  // node->revision_moderation must be made to determine whether it is
  // appropriate to show this tab.
  // This is done in _revisioning_access_node_revision.
  $items['node/%node/revisions/%vid/unpublish'] = array(
    'title' => 'Unpublish',
    'load arguments' => array(
      3,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'revisioning_unpublish_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'unpublish current revision',
      1,
    ),
    'type' => MENU_LOCAL_TASK,
    'weight' => -3,
  );

  // Revert to revision local subtask.
  $items['node/%node/revisions/%vid/revert'] = array(
    'title' => 'Revert to this',
    'load arguments' => array(
      3,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'node_revision_revert_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'revert revisions',
      1,
    ),
    'file' => 'node.pages.inc',
    'file path' => drupal_get_path('module', 'node'),
    'type' => MENU_LOCAL_TASK,
    'weight' => -2,
  );

  // Delete revision local subtask.
  $items['node/%node/revisions/%vid/delete'] = array(
    'title' => 'Delete',
    'load arguments' => array(
      3,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'node_revision_delete_confirm',
      1,
    ),
    'access callback' => '_revisioning_access_node_revision',
    'access arguments' => array(
      'delete revisions',
      1,
    ),
    'file' => 'node.pages.inc',
    'file path' => drupal_get_path('module', 'node'),
    'type' => MENU_LOCAL_TASK,
    'weight' => 10,
  );

  // If Diff module is enabled, provide a "Compare to current" local subtask.
  if (module_exists('diff')) {
    $items['node/%node/revisions/%vid/compare'] = array(
      'title' => 'Compare to current',
      'load arguments' => array(
        3,
      ),
      'page callback' => '_revisioning_compare_to_current_revision',
      'page arguments' => array(
        1,
      ),
      'access callback' => '_revisioning_access_node_revision',
      'access arguments' => array(
        'compare to current',
        1,
      ),
      'type' => MENU_LOCAL_TASK,
      'weight' => 0,
    );
  }
  return $items;
}

/**
 * Implements hook_menu_alter().
 *
 * Modify menu items defined in other modules (in particular the Node module).
 */
function revisioning_menu_alter(&$items) {

  // Change to access callbacks for existing node paths so that we properly
  // control revision-related operation.
  // Some also have their page callbacks altered, e.g to load the latest
  // rather than the current revision of a node.
  // Can't change node load function to, say nid_load(), as we'll run into
  // trouble elsewhere, e.g. menu_get_object(), due to the fact that the
  // prefix, e.g. '%nid', is meant to be a type name, i.e. '%node'.
  //
  // Alter the 3 primary node page tabs: View tab, Edit tab, Revisions tab ...
  $items['node/%node']['access callback'] = '_revisioning_view_edit_access_callback';
  $items['node/%node']['access arguments'] = array(
    'view',
    1,
  );
  $items['node/%node']['page callback'] = '_revisioning_view';
  $items['node/%node']['page arguments'] = array(
    1,
  );

  // This is the MENU_DEFAULT_LOCAL_TASK, so inherits the above.
  $items['node/%node/view']['title callback'] = '_revisioning_title_for_tab';
  $items['node/%node/view']['title arguments'] = array(
    1,
    'view',
  );
  $items['node/%node/view']['weight'] = -10;
  $items['node/%node/edit']['access callback'] = '_revisioning_view_edit_access_callback';
  $items['node/%node/edit']['access arguments'] = array(
    'edit',
    1,
  );
  $items['node/%node/edit']['page callback'] = '_revisioning_edit';
  $items['node/%node/edit']['page arguments'] = array(
    1,
  );
  $items['node/%node/edit']['title callback'] = '_revisioning_title_for_tab';
  $items['node/%node/edit']['title arguments'] = array(
    1,
    'edit',
  );

  // 'Revisions' tab remains, but points to new page callback, allowing users
  // to pick the revision to view, edit, publish, revert, unpublish, delete.
  // Need to override _node_revision_access() call back as it disallows access
  // to the 'Revisions' tab when there's only one revision, which will prevent
  // users from getting to the publish/unpublish links.
  $items['node/%node/revisions']['access callback'] = '_revisioning_access_node_revision';
  $items['node/%node/revisions']['access arguments'] = array(
    'view revision list',
    1,
  );
  $items['node/%node/revisions']['page callback'] = 'revisioning_node_overview';
  $items['node/%node/revisions']['page arguments'] = array(
    1,
  );
  $items['node/%node/revisions']['title callback'] = '_revisioning_title_for_tab';
  $items['node/%node/revisions']['title arguments'] = array(
    1,
    'revisions',
  );

  // Remove the node.module links as we defined our own versions, using %vid
  unset($items['node/%node/revisions/%/view']);
  unset($items['node/%node/revisions/%/revert']);
  unset($items['node/%node/revisions/%/delete']);
  if (module_exists('diff')) {

    // If Diff module is enabled, make sure it uses correct access callback.
    $items['node/%node/revisions/view']['access callback'] = '_revisioning_access_node_revision';
    $items['node/%node/revisions/view']['access arguments'] = array(
      'view revisions',
      1,
    );
  }
}

/**
 * Perform path manipulations for menu items containing the %vid wildcard.
 *
 * For example the ones from revisioning_menu().
 * @see http://drupal.org/node/500864
 */
function vid_to_arg($arg, &$map, $index) {
  if (empty($arg)) {

    // For e.g. node/%/revisions.
    // Suppresses subtabs of Revisions tab where %vid is omitted.
    $map = array();
  }
  return $arg;
}

/**
 * Implements hook_admin_paths().
 */
function revisioning_admin_paths() {
  return array(
    'node/*/unpublish-current' => TRUE,
    'node/*/revisions/list' => TRUE,
    'node/*/revisions/delete-archived' => TRUE,
    'node/*/revisions/*/edit' => TRUE,
    'node/*/revisions/*/publish' => TRUE,
    'node/*/revisions/*/unpublish' => TRUE,
    'node/*/revisions/*/revert' => TRUE,
    'node/*/revisions/*/delete' => TRUE,
    'node/*/revisions/*/compare' => TRUE,
  );
}

/**
 * Implements hook_node_load().
 *
 * The same load op may occur multiple times during the same HTTP request, so
 * hooray for caching!
 *
 * hook_node_load is called when viewing a single node
 * node_load() -> node_load_multiple() ->
 * DrupalDefaultEntityController->attachLoad()
 *
 * hook_node_load is also called on the /content summary page:
 * node_admin_nodes() -> node_load_multiple() ->
 * DrupalDefaultEntityController->attachLoad()
 *
 * We do nothing in this 2nd case.
 */
function revisioning_node_load($nodes, $types) {

  // The 'taxonomy/term/%' menu callback taxonomy_term_page() selects nodes
  // based on presence of their nids in the {taxonomy_index} table, which is
  // mainly based on publication status. Revisioning also updates the table for
  // unpublished content so that in Views we can see the terms belonging to
  // published as well as unpublished content. As a result we must re-apply
  // access control when taxonomy feeds are displayed.
  // See also revisioning_update_taxonomy_index().
  //
  $double_check_access = strpos($_GET['q'], 'taxonomy/term') === 0 && variable_get('revisioning_in_views_show_unpublished_content_terms', TRUE);

  // At this point status, comment, promote and sticky have been set on all of
  // the $nodes according to the {node_revision} table (not the {node} table),
  // using {node.vid} as the foreign key into {node_revision}.
  $nodes_to_be_fixed = array();
  foreach ($nodes as $nid => $node) {
    if ($double_check_access && !node_access('view', $node)) {

      // At this point we cannot remove the node object from $nodes,
      // but we can set a flag to be checked in a later hook.
      $node->dont_display = TRUE;
    }
    else {
      revisioning_set_node_revision_info($node);
      if (!empty($node->revision_moderation) && !empty($node->is_current)) {

        // Hack!
        // Because of core issue [#1120272/#542290], if the current revision is
        // loaded, $node fields may in fact be those belonging to LATEST
        // revision.
        // So reload with FIELD_LOAD_REVISION. We can rely on $node->vid, that
        // attribute is set correctly.
        // Make sure to unset the already loaded fields or we end up with 2
        // copies of each field, e.g. 2 bodies, 2 tags, 2 image attachments etc.
        list($nid, $vid, $bundle) = entity_extract_ids('node', $node);
        $instances = _field_invoke_get_instances('node', $bundle, array(
          'deleted' => FALSE,
        ));
        foreach ($instances as $instance) {
          $field_name = $instance['field_name'];
          unset($node->{$field_name});
        }
        $nodes_to_be_fixed[$nid] = $node;
      }
    }
  }
  if (!empty($nodes_to_be_fixed)) {
    field_attach_load_revision('node', $nodes_to_be_fixed);
    foreach ($nodes_to_be_fixed as $nid => $node) {
      $nodes[$nid] = $node;
    }
  }
}

/**
 * Implements hook_entity_prepare_view().
 *
 * First of the dont_display hooks.
 */
function revisioning_entity_prepare_view($entities, $entity_type, $langcode) {
  if ($entity_type == 'node') {
    foreach ($entities as $node) {
      if (!empty($node->dont_display)) {
        $node->title = FALSE;

        // This == COMMENT_NODE_HIDDEN if Comment module enabled.
        $node->comment = 0;
        $node->link = FALSE;
        unset($node->body);
        unset($node->rss_elements);
      }
      else {

        // Display, however suppress comment form when revision is not current.
        if (isset($node->comment) && !empty($node->revision_moderation) && empty($node->is_current)) {

          // Prevent comment_node_view() from adding the comment form.
          // This == COMMENT_NODE_HIDDEN;
          $node->comment = 0;
        }
      }
    }
  }
}

/**
 * Implements hook_field_attach_view_alter().
 */
function revisioning_field_attach_view_alter(&$output, $context) {
  if ($context['entity_type'] == 'node' && !empty($context['entity']->dont_display)) {
    $output = array();
  }
}

/**
 * Implements hook_node_view().
 */
function revisioning_node_view($node, $view_mode, $langcode) {
  if (!empty($node->dont_display)) {

    // Suppress "Read more".
    $node->content = array();
  }
}

/**
 * Output a status message, provided teh user has the required permission.
 *
 * @param string $message
 *   The status message to be displayed.
 */
function revisioning_set_status_message($message) {
  if (user_access('view revision status messages')) {
    drupal_set_message(filter_xss($message), 'status');
  }
}

/**
 * Implements hook_node_prepare().
 *
 * Called when presenting edit form.
 */
function revisioning_node_prepare($node) {
  if (!empty($node->nid)) {
    $count = _revisioning_get_number_of_revisions_newer_than($node->vid, $node->nid);
    if ($count == 1) {
      drupal_set_message(t('Please note there is one revision more recent than the one you are about to edit.'), 'warning');
    }
    elseif ($count > 1) {
      drupal_set_message(t('Please note there are @count revisions more recent than the one you are about to edit.', array(
        '@count' => $count,
      )), 'warning');
    }
  }
}

/**
 * Implements hook_node_presave().
 *
 * Called when saving, be it an edit or when creating a node.
 *
 * Note that the following may be set programmatically on the $node object
 * before calling node_save($node):
 *
 * o $node->revision_operation, one of:
 *
 *   REVISIONING_NO_REVISION
 *   ($node->revision == $node->revision_moderation == FALSE)
 *
 *   REVISIONING_NEW_REVISION_NO_MODERATION
 *   ($node->revision == TRUE, $node->revision_moderation == FALSE)
 *
 *   REVISIONING_NEW_REVISION_WITH_MODERATION
 *   ($node->revision == $node->revision_moderation == TRUE)
 *
 * o $node->revision_condition (applies only to NEW_REVISION_WITH_MODERATION):
 *
 *   REVISIONING_NEW_REVISION_EVERY_SAVE
 *   REVISIONING_NEW_REVISION_WHEN_NOT_PENDING
 */
function revisioning_node_presave($node) {
  revisioning_set_node_revision_info($node);
  if (isset($node->revision_operation)) {
    $node->revision = $node->revision_operation > REVISIONING_NO_REVISION;
    $node->revision_moderation = $node->revision_operation == REVISIONING_NEW_REVISION_WITH_MODERATION;
  }

  // If a node is being unpublished, then remove from moderation.
  if (isset($node->nid) && isset($node->original)) {
    if ($node->status == 0 && $node->original->status == 1) {
      unset($node->revision_moderation);
    }
  }
  if (!empty($node->revision_moderation) && revisioning_user_may_auto_publish($node)) {
    revisioning_set_status_message(t('Auto-publishing this revision.'));

    // Follow the default saving process making this revision current and
    // published, as opposed to pending.
    unset($node->revision_moderation);

    // This is not required for correct operation, as a revision becomes
    // pending based on vid > current_revision_id. But it looks less confusing,
    // when the "Published" box is in sync with the moderation radio buttons.
    $node->status = NODE_PUBLISHED;
    $node->auto_publish = TRUE;
  }
  if (!isset($node->nid)) {

    // New node, if moderated without Auto-publish, ignore the default Publish
    // tickbox.
    if (isset($node->revision_moderation) && $node->revision_moderation == TRUE) {
      $node->status = NODE_NOT_PUBLISHED;
    }

    // Set these for Rules, see [#1627400]
    $node->current_status = $node->status;
    $node->current_title = $node->title;
    $node->current_promote = $node->promote;
    $node->current_sticky = $node->sticky;
    $node->current_comment = isset($node->comment) ? $node->comment : 0;
    return;
  }
  if (!empty($node->revision_moderation)) {

    // May want to do this for auto_publish too, to provide $node->current... to
    // other modules, as a courtesy.
    if (!isset($node->revision_condition) && !empty($node->revision) && !empty($node->is_pending) && variable_get('new_revisions_' . $node->type, REVISIONING_NEW_REVISION_WHEN_NOT_PENDING) == REVISIONING_NEW_REVISION_WHEN_NOT_PENDING) {
      revisioning_set_status_message(t('Updating existing draft, not creating new revision as this one is still pending.'));

      // To tell revisioning_node_update().
      $node->revision_condition = REVISIONING_NEW_REVISION_WHEN_NOT_PENDING;
    }
    if (isset($node->revision_condition)) {

      // Tell node_save() whether a new revision should be created.
      $node->revision = $node->revision_condition == REVISIONING_NEW_REVISION_EVERY_SAVE;
    }
    $result = db_query("SELECT status, title, comment, promote, sticky FROM {node_revision} WHERE vid = :vid", array(
      ':vid' => $node->current_revision_id,
    ));
    $current_revision = $result
      ->fetchObject();

    // Copy from {node_revision} the field values replicated on {node} before
    // handing back to node_save(). This is a side-effect of core D7's somewhat
    // "sick" table denormalisation.
    // If the Scheduler module is used take the status from there. See
    // revisioning_scheduler_api().
    $node->current_status = isset($node->scheduled_status) ? $node->scheduled_status : $current_revision->status;
    $node->current_title = $current_revision->title;
    $node->current_promote = $current_revision->promote;
    $node->current_sticky = $current_revision->sticky;
    $node->current_comment = isset($current_revision->comment) ? $current_revision->comment : 0;
  }
}

/**
 * Implements hook_node_update().
 *
 * Note: $node->revision_moderation and $node->revision_condition may be set
 * programmatically prior to calling node_save().
 * See also: revisioning_node_pre_save().
 */
function revisioning_node_update($node) {
  revisioning_update_taxonomy_index($node, variable_get('revisioning_in_views_show_unpublished_content_terms', TRUE));

  // Check whether the node has a current_status property and update the
  // node->status to allow node_save()'s call to node_access_acquire_grants()
  // to create the correct node_access entry for the current_status.
  if (isset($node->current_status)) {
    $node->status = $node->current_status;
  }
  if (!empty($node->revision_moderation) && (isset($node->revision_condition) || !empty($node->revision))) {

    // Enter when revision moderation is on and revision_condition=0,1
    // Have to do this due to D7's "sick" denormalisation of node revision data.
    // Resetting the fields duplicated from new {node_revision} back to their
    // originial values to match the current revision as opposed to the latest
    // revision. The latter is done by node_save() just before it calls this
    // function.
    // By resetting {node.vid} {node.vid} < {node_revision.vid}, which makes
    // the newly created revision a pending revision in Revisioning's books.
    // Note: cannot use $node->old_vid as set by node_save(), as this refers to
    // the revision edited, which may not be the current, which is what we are
    // after here.
    db_update('node')
      ->fields(array(
      'vid' => $node->current_revision_id,
      'status' => $node->current_status,
      'title' => $node->current_title,
      // In case Comment module enabled.
      'comment' => $node->current_comment,
      'promote' => $node->current_promote,
      'sticky' => $node->current_sticky,
    ))
      ->condition('nid', $node->nid)
      ->execute();
  }

  // Generate a 'post update' event in Rules.
  module_invoke_all('revisionapi', 'post update', $node);

  // Add new revision usage records to files to prevent them being deleted.
  $fields = field_info_instances('node', $node->type);
  foreach ($fields as $field_name => $value) {
    $field_info = field_info_field($field_name);
    if ($field_info['type'] == 'file' || $field_info['type'] == 'image') {

      // See #1996412.
      $file_fields[$field_name] = $value;
    }
  }

  // Create file revision entries for files created using older versions.
  // $old_node = isset($node->original) ? $node->original : NULL;
  // [#2276657]
  $old_node = isset($node->original) && !empty($node->original) ? $node->original : NULL;
  if (isset($old_node) && !empty($file_fields)) {
    foreach ($file_fields as $file_field) {
      if ($old_files = field_get_items('node', $old_node, $file_field['field_name'], $old_node->language)) {
        foreach ($old_files as $old_single_file) {
          if (!empty($old_single_file)) {
            $old_file = (object) $old_single_file;
            file_usage_add($old_file, 'revisioning', 'revision', $old_node->vid);
          }
        }
      }
    }
  }
  if (!empty($file_fields)) {
    foreach ($file_fields as $file_field) {
      if ($files = field_get_items('node', $node, $file_field['field_name'], $node->language)) {
        foreach ($files as $single_file) {
          $file = (object) $single_file;
          file_usage_add($file, 'revisioning', 'revision', $node->vid);
        }
      }
    }
  }
}

/**
 * Implements hook_node_insert().
 *
 * New node.
 */
function revisioning_node_insert($node) {
  revisioning_update_taxonomy_index($node, variable_get('revisioning_in_views_show_unpublished_content_terms', TRUE));
  if (!empty($node->revision_moderation)) {
    revisioning_set_status_message($node->status ? t('Initial revision created and published.') : t('Initial draft created, pending publication.'));
  }

  // Add revision usage records to files to prevent them being deleted.
  $fields = field_info_instances('node', $node->type);
  foreach ($fields as $field_name => $value) {
    $field_info = field_info_field($field_name);
    if ($field_info['type'] == 'file') {
      $file_fields[$field_name] = $value;
    }
  }
  if (!empty($file_fields)) {
    foreach ($file_fields as $file_field) {
      if ($files = field_get_items('node', $node, $file_field['field_name'], $node->language)) {
        foreach ($files as $single_file) {
          $file = (object) $single_file;
          file_usage_add($file, 'revisioning', 'revision', $node->vid);
        }
      }
    }
  }
}

/**
 * Implements hook_node_delete().
 */
function revisioning_node_delete($node) {
  if ($revisions = node_revision_list($node)) {
    $vids = array_keys($revisions);
    db_delete('file_usage')
      ->condition('module', 'revisioning')
      ->condition('id', $vids, 'IN')
      ->execute();
  }
}

/**
 * Implements hook_node_access_records_alter().
 *
 * If the node is not the current node this function clears the grants array and
 * rebuilds it using the current node.
 */
function revisioning_node_access_records_alter(&$grants, $node) {
  if (!revisioning_revision_is_current($node)) {
    $current_node = node_load($node->nid, NULL, TRUE);
    $grants = array();
    foreach (module_implements('node_access_records') as $module) {
      $function = $module . '_node_access_records';
      if (function_exists($function)) {
        $result = call_user_func_array($function, array(
          $current_node,
        ));
        if (isset($result)) {
          if (is_array($result)) {
            $grants = array_merge_recursive($grants, $result);
          }
          else {
            $grants[] = $result;
          }
        }
      }
    }
  }
}

/**
 * Implements hook_views_api().
 */
function revisioning_views_api() {
  return array(
    'api' => views_api_version(),
    'path' => drupal_get_path('module', 'revisioning') . '/views',
  );
}

/**
 * Implements hook_user_node_access().
 */
function revisioning_user_node_access($revision_op, $node, $account = NULL) {
  if (!isset($account)) {
    $account = $GLOBALS['user'];
  }
  $type = check_plain($node->type);
  switch ($revision_op) {
    case 'view current':
      break;
    case 'compare to current':
    case 'view revisions':
    case 'view revision list':
      if (user_access('view revisions', $account)) {
        break;
      }
      if (user_access('view revisions of any ' . $type . ' content', $account)) {
        break;
      }
      if ($node->uid == $account->uid && user_access('view revisions of own ' . $type . ' content', $account)) {
        break;
      }
      return FALSE;
    case 'edit current':
      return 'update';
    case 'edit revisions':
    case 'revert revisions':
      return user_access($revision_op, $account) ? 'update' : FALSE;
    case 'publish revisions':
      $node_op = variable_get('revisioning_require_update_to_publish', TRUE) ? 'update' : 'view';
      if (user_access('publish revisions', $account)) {
        return $node_op;
      }
      if (user_access('publish revisions of any ' . $type . ' content', $account)) {
        return $node_op;
      }
      if ($node->uid == $account->uid && user_access('publish revisions of own ' . $type . ' content', $account)) {
        return $node_op;
      }
      return FALSE;
    case 'unpublish current revision':
      $node_op = variable_get('revisioning_require_update_to_publish', TRUE) ? 'update' : 'view';
      return user_access('unpublish current revision', $account) ? $node_op : FALSE;
    case 'delete revisions':
    case 'delete archived revisions':
      if (!user_access('delete revisions', $account)) {
        return FALSE;
      }
    case 'delete node':
      return 'delete';
    default:
      drupal_set_message(t("Unknown Revisioning operation '%revision_op'. Treating as 'view'.", array(
        '%revision_op' => $revision_op,
      )), 'warning', FALSE);
  }
  return 'view';
}

/**
 * Implements hook_scheduler_api().
 */
function revisioning_scheduler_api($node, $action) {
  if ($action == 'pre_publish') {
    $node->scheduled_status = NODE_PUBLISHED;
  }
  elseif ($action == 'pre_unpublish') {
    $node->scheduled_status = NODE_NOT_PUBLISHED;
  }
}

/**
 * Test whether the supplied revision operation is appropriate for the node.
 *
 * This is irrespective of user permissions, e.g. even for an administrator it
 * doesn't make sense to publish a node that is already published or to
 * "revert" to the current revision.
 *
 * @param string $revision_op
 *   For instance 'publish revisions', 'delete revisions'
 * @param object $node
 *   The node object
 *
 * @return bool
 *   TRUE if the operation is appropriate for this node at this point
 */
function _revisioning_operation_appropriate($revision_op, $node) {
  switch ($revision_op) {
    case 'compare to current':

    // Can't compare against itself.
    case 'delete revisions':

      // If the revision is the current one, suppress the delete operation
      // @TODO ...unless it's the only revision, in which case delete the
      // entire node; however this requires a different URL.
      return !$node->is_current;
    case 'delete archived revisions':
      break;
    case 'view revision list':

      // For i.e. node revisions summary.
      if (empty($node->revision_moderation) && isset($node->num_revisions) && $node->num_revisions == 1) {

        // Suppress Revisions tab when when there's only 1 revision. This is
        // consistent with core.
        // However, when content is moderated (i.e. "New revision in draft,
        // pending moderation" is ticked) we want to be able to get to the
        // 'Unpublish current' link on this page and the 'Publish' tab on
        // the next.
        return FALSE;
      }
      break;
    case 'edit revisions':
      if (empty($node->revision_moderation)) {
        return FALSE;
      }
      break;
    case 'publish revisions':

      // If the node isn't meant to be moderated,
      // or the revision is not either pending or current but not published,
      // then disallow publication.
      if (empty($node->revision_moderation) || !($node->is_pending || $node->is_current && !$node->status)) {
        return FALSE;
      }
      break;
    case 'unpublish current revision':

      // If the node isn't meant to be moderated or it is unpublished already
      // or we're not looking at the current revision, then unpublish is not an
      // option.
      if (empty($node->revision_moderation) || !$node->status || !$node->is_current) {
        return FALSE;
      }
      break;
    case 'revert revisions':

      // If this revision is pending or current, suppress the reversion.
      if ($node->is_pending || $node->is_current) {
        return FALSE;
      }
      break;
  }
  return TRUE;
}

/**
 * Determine whether the supplied revision operation is permitted on the node.
 *
 * This requires getting through three levels of defence:
 * o Is the operation appropriate for this node at this time, e.g. a node must
 *   not be published if it already is or if it isn't under moderation control
 * o Does the user have permission for the requested REVISION operation?
 * o Does the user have the NODE access rights (view/update/delete) for this
 *   operation?
 *
 * @param string $revision_op
 *   For instance 'publish revisions', 'delete revisions'
 * @param object $node
 *   The node object
 *
 * @return bool
 *   TRUE if the user has access
 */
function _revisioning_access_node_revision($revision_op, $node) {
  if (!_revisioning_operation_appropriate($revision_op, $node)) {
    return FALSE;
  }

  // Check the revision-aspect of the operation.
  $node_op = revisioning_user_node_access($revision_op, $node);

  // ... then check with core to assess node permissions
  // node_access will invoke hook_node_access(), i.e. revisioning_node_access().
  $access = $node_op && node_access($node_op, $node);

  // Let other modules override the outcome, if there are any.
  // If any module denies access that is the final result, otherwise allow.
  $overrides = module_invoke_all('revisioning_access_node_revision', $revision_op, $node);
  return empty($overrides) ? $access : !in_array(NODE_ACCESS_DENY, $overrides, TRUE);
}

/**
 * Implements hook_node_access().
 *
 * This gets invoked from node.module/node_access() after it has checked the
 * standard node permissions using node_node_access() and just before it checks
 * the node_access grants table.
 * We basically return "don't care" except for one 'view' case, which replicates
 * the node.module. "Don't care" in this case would result in "access denied".
 */
function revisioning_node_access($node, $node_op, $account) {

  // Taken from node.module/node_access():
  // If no modules implement hook_node_grants(), the default behaviour is to
  // allow all users to view published nodes, so reflect that here,
  // augmented for the 'view revisions' family of permissions, which apply to
  // both published and unpublished nodes.
  if ($node_op == 'view' && !module_implements('node_grants')) {
    if ($node->status == NODE_PUBLISHED || !empty($node->revision_moderation) && revisioning_user_node_access('view revisions', $node, $account)) {
      return NODE_ACCESS_ALLOW;
    }
  }

  // [#1492246]
  // Deny access to unpublished, moderated content by anonymous users.
  if (empty($node->status) && !empty($node->revision_moderation) && empty($account->uid)) {
    return NODE_ACCESS_DENY;
  }
  return NODE_ACCESS_IGNORE;
}

/**
 * Access callback function.
 *
 * Access callback for 'node/%', 'node/%/view' and 'node/%/edit' links that
 * may appear anywhere on the site.
 * At the time that this function is called the CURRENT revision will already
 * have been loaded by the system. However depending on the value of the
 * 'revisioning_view_callback' and 'revisioning_edit_callback' variables (as
 * set on the admin/config/content/revisioning page), this may not be the
 * desired revision.
 * If these variables state that the LATEST revision should be loaded, we need
 * to check at this point whether the user has permission to view this revision.
 *
 * The 'View current' and/or 'Edit current' tabs are suppressed when the current
 * revision is already displayed via one of the Revisions subtabs.
 * The 'View latest' and/or 'Edit latest' tabs are suppressed when the latest
 * revision is already displayed via one of the Revisions subtabs.
 *
 * @param string $op
 *   must be one of 'view' or 'edit'
 * @param object $node
 *   the node object
 *
 * @return bool
 *   FALSE if access to the desired revision is denied
 */
function _revisioning_view_edit_access_callback($op, $node) {
  $load_op = _revisioning_load_op($node, $op);
  $vid = arg(3);
  if (!empty($node->revision_moderation) && is_numeric($vid)) {

    // The View, Edit primary tabs are requested indirectly, in the context of
    // the secondary tabs under Revisions, e.g. node/%/revisions/%
    if ($load_op == REVISIONING_LOAD_CURRENT && $vid == $node->current_revision_id) {

      // Suppress 'View current' and 'Edit current' tabs when viewing current.
      return FALSE;
    }
    if ($load_op == REVISIONING_LOAD_LATEST && $vid == revisioning_get_latest_revision_id($node->nid)) {

      // Suppress 'View latest' and 'Edit latest' tabs when viewing latest.
      return FALSE;
    }
  }
  if ($load_op == REVISIONING_LOAD_LATEST) {

    // _revisioning_load_op has already checked permission to view latest.
    return TRUE;
  }
  $revision_op = $op == 'view' ? 'view current' : 'edit current';
  return _revisioning_access_node_revision($revision_op, $node);
}

/**
 * Load a revision.
 *
 * Assuming that the node passed in is the current revision (core default),
 * this function determines whether the lastest revision should be loaded
 * instead, in which case it returns REVISIONING_LOAD_LATEST.
 *
 * @param object $node
 *   only nodes of content types subject to moderation are
 *   processed by this function
 * @param string $op
 *   either 'edit' or 'view'
 * @param bool $check_access
 *   whether revision access permissions should be checked; if the user has no
 *   permission to load the latest revisions, then the function returns
 *   REVISIONING_LOAD_CURRENT
 *
 * @return int
 *   REVISIONING_LOAD_LATEST or REVISIONING_LOAD_CURRENT
 */
function _revisioning_load_op($node, $op, $check_access = TRUE) {
  if (!empty($node->revision_moderation)) {
    $view_mode = (int) variable_get('revisioning_view_callback', REVISIONING_LOAD_CURRENT);
    $edit_mode = (int) variable_get('revisioning_edit_callback', REVISIONING_LOAD_CURRENT);
    $load_op = $op == 'edit' ? $edit_mode : $view_mode;
    if ($load_op == REVISIONING_LOAD_LATEST) {

      // Site is configured to load latest revision, but we'll only do this if
      // the latest isn't loaded already and user has the permission to do so.
      $latest_vid = revisioning_get_latest_revision_id($node->nid);
      if ($latest_vid != $node->current_revision_id) {
        if (!$check_access) {
          return REVISIONING_LOAD_LATEST;
        }
        $original_vid = $node->vid;
        $node->vid = $latest_vid;
        $node->is_current = revisioning_revision_is_current($node);
        $revision_op = $op == 'view' ? 'view revisions' : 'edit revisions';
        $access = _revisioning_access_node_revision($revision_op, $node);

        // Restore $node (even though called by value), to remain consistent.
        $node->vid = $original_vid;
        $node->is_current = revisioning_revision_is_current($node);
        if ($access) {
          return REVISIONING_LOAD_LATEST;
        }
      }
    }
  }
  return REVISIONING_LOAD_CURRENT;
}

/**
 * Display node overview.
 *
 * Display all revisions of the supplied node in a themed table with links for
 * the permitted operations above it.
 *
 * @return array
 *   render array as returned by drupal_get_form()
 */
function revisioning_node_overview($node) {
  return _revisioning_theme_revisions_summary($node);
}

/**
 * Menu callback for the primary View tab.
 *
 * This is the same callback as used in core, except that in core current and
 * latest revisions are always the same.
 */
function _revisioning_view($node) {
  if (_revisioning_load_op($node, 'view') == REVISIONING_LOAD_LATEST) {
    $vid_to_load = revisioning_get_latest_revision_id($node->nid);
    $node = node_load($node->nid, $vid_to_load);
  }

  // This is the callback used by node.module for node/%node & node/%node/view
  return node_page_view($node);
}

/**
 * Callback for the primary Edit tab.
 *
 * This is the same callback as used in core, except that in core current and
 * latest revisions are always the same.
 */
function _revisioning_edit($node) {
  if (_revisioning_load_op($node, 'edit') == REVISIONING_LOAD_LATEST) {
    $vid_to_load = revisioning_get_latest_revision_id($node->nid);
    $node = node_load($node->nid, $vid_to_load);
  }
  _revisioning_set_custom_theme_if_necessary();

  // This is the callback used by node.module for node/%node/edit
  return node_page_edit($node);
}

/**
 * Callback to view a particular revision.
 */
function _revisioning_view_revision($node) {
  if (isset($node->nid)) {

    // For Panels, see [#1567880]
    $router_item = menu_get_item('node/' . $node->nid);
    if (!empty($router_item['include_file'])) {
      $path = DRUPAL_ROOT . '/';

      // $_SERVER['DOCUMENT_ROOT'] . base_path();
      require_once $path . $router_item['include_file'];
    }

    // Call whatever function is assigned to the main node path but pass the
    // current node as an argument. This approach allows for the reuse of Panel
    // definition acting on node/%node.
    if (isset($router_item['page_callback'])) {
      return $router_item['page_callback']($node);
    }
  }

  // This is the callback used by node.module for node/%node/revisions/%/view
  return node_show($node, TRUE);
}

/**
 * Callback to edit a particular revision.
 *
 * Note that there is no equivalent of this in core and we should not allow
 * editing of a non-current revision, if $node->revision_moderation is not set.
 * This is the job of the access callback _revisioning_access_node_revision().
 */
function _revisioning_edit_revision($node) {
  _revisioning_set_custom_theme_if_necessary();
  return node_page_edit($node);
}

/**
 * Callback for the primary View, Edit and Revisions tabs titles.
 *
 * @param object $node
 *   the node object
 * @param string $tab
 *   'view', 'edit' or 'revisions'
 *
 * @return string
 *   translatable title string
 */
function _revisioning_title_for_tab($node, $tab) {
  if ($tab == 'revisions') {
    return is_numeric(arg(3)) ? t('Revision operations') : t('Revisions');
  }

  /*
  if (empty($node->revision_moderation) || $node->num_revisions <= 1) {
    return ($tab == 'edit' ? t('Edit') : t('View'));
  }
  if (_revisioning_load_op($node, $tab) == REVISIONING_LOAD_LATEST) {
    return ($tab == 'edit' ? t('Edit latest') : t('View latest'));
  }
  return ($tab == 'edit' ? t('Edit current') : t('View current'));
  */
  if (!empty($node->revision_moderation) && $node->num_revisions > 1) {
    if (_revisioning_load_op($node, $tab) == REVISIONING_LOAD_LATEST) {
      return $tab == 'edit' ? t('Edit latest') : t('View latest');
    }
    if (_revisioning_access_node_revision('view revisions', $node)) {
      return $tab == 'edit' ? t('Edit current') : t('View current');
    }
  }
  return $tab == 'edit' ? t('Edit') : t('View');
}

/**
 * Set custom theme.
 */
function _revisioning_set_custom_theme_if_necessary() {

  // Use the admin theme if the user specified this at Appearance >> Settings.
  // Note: first tick 'View the administration theme' at People >> Permissions.
  if (variable_get('node_admin_theme', FALSE)) {
    global $theme, $custom_theme;
    $custom_theme = variable_get('admin_theme', $theme);
  }
}
if (module_exists('diff')) {

  /**
   * Compare two revisions.
   *
   * Use diff's diff_diffs_show() function to compare specific revision to the
   * current one.
   */
  function _revisioning_compare_to_current_revision($node) {

    // For diff_diffs_show().
    module_load_include('inc', 'diff', 'diff.pages');

    // Make sure that latest of the two revisions is on the right.
    if ($node->current_revision_id < $node->vid) {
      return diff_diffs_show($node, $node->current_revision_id, $node->vid);
    }
    return diff_diffs_show($node, $node->vid, $node->current_revision_id);
  }
}

/**
 * Implements hook_page_manager_override().
 *
 * See http://drupal.org/node/1509674#comment-6702798
 */
function revisioning_page_manager_override($task_name) {
  switch ($task_name) {
    case 'node_view':
      return '_revisioning_view';
  }
}

Functions

Namesort descending Description
revisioning_admin_paths Implements hook_admin_paths().
revisioning_entity_prepare_view Implements hook_entity_prepare_view().
revisioning_field_attach_view_alter Implements hook_field_attach_view_alter().
revisioning_help Implements hook_help().
revisioning_menu Implements hook_menu().
revisioning_menu_alter Implements hook_menu_alter().
revisioning_node_access Implements hook_node_access().
revisioning_node_access_records_alter Implements hook_node_access_records_alter().
revisioning_node_delete Implements hook_node_delete().
revisioning_node_insert Implements hook_node_insert().
revisioning_node_load Implements hook_node_load().
revisioning_node_overview Display node overview.
revisioning_node_prepare Implements hook_node_prepare().
revisioning_node_presave Implements hook_node_presave().
revisioning_node_update Implements hook_node_update().
revisioning_node_view Implements hook_node_view().
revisioning_page_manager_override Implements hook_page_manager_override().
revisioning_permission Implements hook_permission().
revisioning_scheduler_api Implements hook_scheduler_api().
revisioning_set_status_message Output a status message, provided teh user has the required permission.
revisioning_user_node_access Implements hook_user_node_access().
revisioning_views_api Implements hook_views_api().
vid_to_arg Perform path manipulations for menu items containing the %vid wildcard.
_revisioning_access_node_revision Determine whether the supplied revision operation is permitted on the node.
_revisioning_edit Callback for the primary Edit tab.
_revisioning_edit_revision Callback to edit a particular revision.
_revisioning_load_op Load a revision.
_revisioning_operation_appropriate Test whether the supplied revision operation is appropriate for the node.
_revisioning_set_custom_theme_if_necessary Set custom theme.
_revisioning_title_for_tab Callback for the primary View, Edit and Revisions tabs titles.
_revisioning_view Menu callback for the primary View tab.
_revisioning_view_edit_access_callback Access callback function.
_revisioning_view_revision Callback to view a particular revision.

Constants