diff --git a/core/includes/entity.inc b/core/includes/entity.inc index 44c3040aba6582b3ef8cff262d8eb8f1a29a5c97..c89121542d32f59fad09ff74314ddfbd1d287b93 100644 --- a/core/includes/entity.inc +++ b/core/includes/entity.inc @@ -762,3 +762,63 @@ function entity_page_create_access($entity_type) { ->create(array()); return $entity->access('create'); } + +/** + * Gets the edit revision identifier for an entity from the database. + * + * @param string $entity_type + * The entity type, e.g. node or user. + * @param int $id + * The id of the entity who's edit revision you want. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity object, or FALSE if there is no entity with the given id. + */ +function entity_get_edit_revision_id($entity_type, $id) { + $entities = entity_load_multiple($entity_type, array($id)); + return isset($entities[$id]) ? $entities[$id]->getEditRevisionId() : FALSE; +} + +/** + * Loads the edit revision of this entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to load the edit revision. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity object, or FALSE if there is no edit revision for this entity. + */ +function entity_load_edit_revision(EntityInterface $entity) { + if($entity->isEditRevision()) { + // If this is the edit revision, return the entity as is. + return $entity; + } + elseif ($entity->isRevisionable(TRUE)) { + $entity_info = $entity->entityInfo(); + $edit_revision_key = $entity_info['entity_keys']['edit_revision']; + $edit_revision_id = $entity->$edit_revision_key; + if (!empty($edit_revision_id)) { + return entity_revision_load($entity->entityType(), $edit_revision_id); + } + + } + + return $entity; +} + +/** + * Loads the edit revision of this entity. + * + * @param string $entity_type + * The entity type, e.g. node or user. + * @param int $id + * The id of the entity who's edit revision you want. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity object, or FALSE if there is no entity with the given id. + */ +function entity_load_edit_revision_by_id($entity_type, $id) { + $edit_revision_id = entity_get_edit_revision_id($entity_type, $id); + return entity_revision_load($entity_type, $edit_revision_id); +} + diff --git a/core/includes/menu.inc b/core/includes/menu.inc index ef9f7e2a8cc711eb9747bc31f55bd9da8b91a3ca..caf8627188d496c4debf749c3c9dbe5db4c5a1fa 100644 --- a/core/includes/menu.inc +++ b/core/includes/menu.inc @@ -750,13 +750,18 @@ function _menu_translate(&$router_item, $map, $to_arg = FALSE) { $tab_parent_map = explode('/', $router_item['tab_parent']); } for ($i = 0; $i < $router_item['number_parts']; $i++) { - if ($link_map[$i] == '%') { - $link_map[$i] = $path_map[$i]; + if ($link_map[$i] == '%' ) { + if (isset($path_map[$i])) { + $link_map[$i] = $path_map[$i]; + } + else { + $router_item['access'] = FALSE; + } } - if (isset($tab_root_map[$i]) && $tab_root_map[$i] == '%') { + if (isset($tab_root_map[$i]) && $tab_root_map[$i] == '%' && isset($path_map[$i])) { $tab_root_map[$i] = $path_map[$i]; } - if (isset($tab_parent_map[$i]) && $tab_parent_map[$i] == '%') { + if (isset($tab_parent_map[$i]) && $tab_parent_map[$i] == '%' && isset($path_map[$i])) { $tab_parent_map[$i] = $path_map[$i]; } } @@ -764,7 +769,9 @@ function _menu_translate(&$router_item, $map, $to_arg = FALSE) { $router_item['tab_root_href'] = implode('/', $tab_root_map); $router_item['tab_parent_href'] = implode('/', $tab_parent_map); $router_item['options'] = array(); - _menu_check_access($router_item, $map); + if (!isset($router_item['access'])) { + _menu_check_access($router_item, $map); + } // For performance, don't localize an item the user can't access. if ($router_item['access']) { diff --git a/core/lib/Drupal/Core/Entity/DatabaseStorageController.php b/core/lib/Drupal/Core/Entity/DatabaseStorageController.php index 9092a1372490f3e3b8ffe503380ae4f86dbf8cef..d154091b32a5bd11331c92dc1a13c672747f2585 100644 --- a/core/lib/Drupal/Core/Entity/DatabaseStorageController.php +++ b/core/lib/Drupal/Core/Entity/DatabaseStorageController.php @@ -145,9 +145,16 @@ public function __construct($entityType) { if (!empty($this->entityInfo['entity_keys']['revision'])) { $this->revisionKey = $this->entityInfo['entity_keys']['revision']; $this->revisionTable = $this->entityInfo['revision_table']; + if (!empty($this->entityInfo['entity_keys']['edit_revision'])) { + $this->editRevisionKey = $this->entityInfo['entity_keys']['edit_revision']; + } + else { + $this->editRevisionKey = FALSE; + } } else { $this->revisionKey = FALSE; + $this->editRevisionKey = FALSE; } // Check if the entity type supports static caching of loaded entities. @@ -357,6 +364,13 @@ protected function buildQuery($ids, $revision_id = FALSE) { // Compare revision id of the base and revision table, if equal then this // is the default revision. $query->addExpression('base.' . $this->revisionKey . ' = revision.' . $this->revisionKey, 'isDefaultRevision'); + // Alias the default revision id to default_revision. + $query->addField('base', $this->revisionKey, 'default_revision'); + if ($this->editRevisionKey) { + // Compare the edit revision id with the id of the revision being + // loaded. If equal then this is the edit revision. + $query->addExpression('base.' . $this->editRevisionKey . ' = revision.' . $this->revisionKey, 'isEditRevision'); + } } $query->fields('base', $entity_fields); diff --git a/core/lib/Drupal/Core/Entity/Entity.php b/core/lib/Drupal/Core/Entity/Entity.php index b8feea4c5642bf48dee4aedbdcf068e86e9937a7..d3a102a0fdaa451ceb3d9ae704cedfe6fe085e80 100644 --- a/core/lib/Drupal/Core/Entity/Entity.php +++ b/core/lib/Drupal/Core/Entity/Entity.php @@ -58,6 +58,13 @@ class Entity implements IteratorAggregate, EntityInterface { protected $isDefaultRevision = TRUE; /** + * Indicates whether this is the edit revision. + * + * @var bool + */ + protected $isEditRevision = TRUE; + + /** * Constructs an Entity object. * * @param array $values @@ -364,6 +371,16 @@ public function entityInfo() { } /** + * Implements \Drupal\Core\Entity\EntityInterface::isRevisionable(). + */ + public function isRevisionable($edit_revision_check = FALSE) { + $entity_info = $this->entityInfo(); + $return = (!empty($entity_info['revision_table']) && !empty($entity_info['entity_keys']['revision'])) ? TRUE: FALSE; + $return = ($edit_revision_check) ? ($return && !empty($entity_info['entity_keys']['edit_revision'])): $return; + return $return; + } + + /** * Implements \Drupal\Core\Entity\EntityInterface::getRevisionId(). */ public function getRevisionId() { @@ -371,6 +388,13 @@ public function getRevisionId() { } /** + * Implements \Drupal\Core\Entity\EntityInterface::getEditRevisionId(). + */ + public function getEditRevisionId() { + return NULL; + } + + /** * Implements \Drupal\Core\Entity\EntityInterface::isDefaultRevision(). */ public function isDefaultRevision($new_value = NULL) { @@ -382,6 +406,58 @@ public function isDefaultRevision($new_value = NULL) { } /** + * Implements \Drupal\Core\Entity\EntityInterface::isEditRevision(). + */ + public function isEditRevision($new_value = NULL) { + $return = $this->isEditRevision; + if (isset($new_value)) { + $this->isEditRevision = (bool) $new_value; + } + return $return; + } + + /** + * Implements \Drupal\Core\Entity\EntityInterface::setEditRevision(). + */ + public function setEditRevision($new_value = NULL) { + // Check that this entity has been saved to the database, is revisionable + // and supports edit revisions. + if (!$this->isNew() && $this->isRevisionable(TRUE)) { + $entity_info = $this->entityInfo(); + $id_key = $entity_info['entity_keys']['id']; + $revision_key = $entity_info['entity_keys']['revision']; + $edit_revision_key = $entity_info['entity_keys']['edit_revision']; + $base_table = $entity_info['base_table']; + $revision_table = $entity_info['revision_table']; + + // If no edit revision ID was specified, query the revision table for the + // largest revision ID for this entity. + if (empty($new_value)) { + $new_value = db_select($revision_table, 'rt') + ->fields('rt', array($revision_key)) + ->condition($id_key, $this->id()) + ->range(0,1) + ->orderBy('rt.' . $revision_key, 'DESC') + ->execute() + ->fetchField(); + } + + // Set the edit revision in the base table. + db_update($base_table) + ->fields(array($edit_revision_key => $new_value)) + ->condition($id_key, $this->id()) + ->execute(); + + // Update the edit revision in the current entity object. + $this->$edit_revision_key = $new_value; + + return $new_value; + } + + return NULL; + } + + /** * Implements \Drupal\Core\Entity\EntityInterface::getExportProperties(). */ public function getExportProperties() { diff --git a/core/lib/Drupal/Core/Entity/EntityBCDecorator.php b/core/lib/Drupal/Core/Entity/EntityBCDecorator.php index 7e941a9fb39891b483f97786628ddaf1b4d2fe12..9d46ab9224cabea818f49ad483fbacb2bfda953b 100644 --- a/core/lib/Drupal/Core/Entity/EntityBCDecorator.php +++ b/core/lib/Drupal/Core/Entity/EntityBCDecorator.php @@ -326,6 +326,13 @@ public function entityInfo() { /** * Forwards the call to the decorated entity. */ + public function isRevisionable($edit_revision_check = FALSE) { + return $this->decorated->getEditRevisionId(); + } + + /** + * Forwards the call to the decorated entity. + */ public function getRevisionId() { return $this->decorated->getRevisionId(); } @@ -333,6 +340,13 @@ public function getRevisionId() { /** * Forwards the call to the decorated entity. */ + public function getEditRevisionId() { + return $this->decorated->getEditRevisionId(); + } + + /** + * Forwards the call to the decorated entity. + */ public function isDefaultRevision($new_value = NULL) { return $this->decorated->isDefaultRevision($new_value); } @@ -340,6 +354,20 @@ public function isDefaultRevision($new_value = NULL) { /** * Forwards the call to the decorated entity. */ + public function isEditRevision($new_value = NULL) { + return $this->decorated->isEditRevision($new_value); + } + + /** + * Forwards the call to the decorated entity. + */ + public function setEditRevision($new_value = NULL) { + return $this->decorated->setEditRevision($new_value); + } + + /** + * Forwards the call to the decorated entity. + */ public function language() { return $this->decorated->language(); } diff --git a/core/lib/Drupal/Core/Entity/EntityInterface.php b/core/lib/Drupal/Core/Entity/EntityInterface.php index 40b9f71723fc19bf47fc8e97bdd682c5556a8047..9df716d1481c33567db3a51351a87e77dd543cc0 100644 --- a/core/lib/Drupal/Core/Entity/EntityInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityInterface.php @@ -173,6 +173,18 @@ public function createDuplicate(); public function entityInfo(); /** + * Checks if the entity supports revisions. + * + * @param bool $edit_revision_check + * (optional) A Boolean to determine if this function shoud check for edit + * revision support. + * + * @return + * TRUE if the entity supports revisions, FALSE otherwise. + */ + public function isRevisionable($edit_revision_check = FALSE); + + /** * Returns the revision identifier of the entity. * * @return @@ -182,6 +194,15 @@ public function entityInfo(); public function getRevisionId(); /** + * Returns the revision identifier of the entity's edit revision. + * + * @return + * The revision identifier of the entity's edit revision, or NULL if the + * entity does not have a revision identifier. + */ + public function getEditRevisionId(); + + /** * Checks if this entity is the default revision. * * @param bool $new_value @@ -194,6 +215,32 @@ public function getRevisionId(); public function isDefaultRevision($new_value = NULL); /** + * Checks if this entity is the edit revision. + * + * @param bool $new_value + * (optional) A Boolean to (re)set the isEditRevision flag. + * + * @return bool + * TRUE if the entity is the edit revision, FALSE otherwise. If + * $new_value was passed, the previous value is returned. + */ + public function isEditRevision($new_value = NULL); + + /** + * Sets the edit revision for this entity. + * + * @param int $new_value + * (optional) An integer to (re)set the edit revision listed in the + * entity's base table. Defaults to the highest revision identifier listed + * in the revision table if none is specified. + * + * @return bool + * The new edit revision identifier, if this entity supports edit + * revisions, NULL otherwise. + */ + public function setEditRevision($new_value = NULL); + + /** * Retrieves the exportable properties of the entity. * * @return array diff --git a/core/lib/Drupal/Core/Entity/EntityManager.php b/core/lib/Drupal/Core/Entity/EntityManager.php index 42e4d77f6a712cd03a65fa9e63e74b1de2b1700f..394233e0c9875666c7e7baffe3fdeaeed9034821 100644 --- a/core/lib/Drupal/Core/Entity/EntityManager.php +++ b/core/lib/Drupal/Core/Entity/EntityManager.php @@ -89,6 +89,9 @@ * revision ID of the entity. The Field API assumes that all revision IDs * are unique across all entities of a type. This entry can be omitted if * the entities of this type are not versionable. + * - edit_revision: (optional) The name of the property that contains the + * edit revision ID of the entity. This entry can be omitted if the + * entities of this type are not versionable. * - bundle: (optional) The name of the property that contains the bundle * name for the entity. The bundle name defines which set of fields are * attached to the entity (e.g. what nodes call "content type"). This @@ -152,6 +155,7 @@ class EntityManager extends PluginManagerBase { 'controller_class' => 'Drupal\Core\Entity\DatabaseStorageController', 'entity_keys' => array( 'revision' => '', + 'edit_revision' => '', 'bundle' => '', ), 'fieldable' => FALSE, diff --git a/core/modules/node/lib/Drupal/node/NodeFormController.php b/core/modules/node/lib/Drupal/node/NodeFormController.php index 80823229ee5b29d4471b86222b2c5d9b9aaa8394..e00eb2fbbebb2e284623852837fad9930eee2383 100644 --- a/core/modules/node/lib/Drupal/node/NodeFormController.php +++ b/core/modules/node/lib/Drupal/node/NodeFormController.php @@ -248,44 +248,84 @@ protected function actions(array $form, array &$form_state) { // need a way to plug themselves into 1) the ::submit() step, and // 2) the ::save() step, both decoupled from the pressed form button. if ($element['submit']['#access'] && user_access('administer nodes')) { - // isNew | prev status » default & publish label & unpublish label - // 1 | 1 » publish & Save and publish & Save as unpublished - // 1 | 0 » unpublish & Save and publish & Save as unpublished - // 0 | 1 » publish & Save and keep published & Save and unpublish - // 0 | 0 » unpublish & Save and keep unpublished & Save and publish - - // Add a "Publish" button. - $element['publish'] = $element['submit']; - $element['publish']['#dropbutton'] = 'save'; - if ($node->isNew()) { - $element['publish']['#value'] = t('Save and publish'); - } - else { - $element['publish']['#value'] = $node->status ? t('Save and keep published') : t('Save and publish'); - } - $element['publish']['#weight'] = 0; - array_unshift($element['publish']['#submit'], array($this, 'publish')); + // Revisions disabled: + // isNew | status » default | publish label | unpublish label + // ======|========|===========|===========================|==================== + // 1 | 1 » publish | Save and publish | Save as draft + // 1 | 0 » unpublish | Save and publish | Save as draft + // 0 | 1 » publish | Save and keep published | Save and unpublish + // 0 | 0 » unpublish | Save and publish | Save and keep unpublished + + // Revisions enabled: + // isNew | status | default_status » default | publish label | draft label | unpublish label + // ======|========|================|===========|=========================|===============|============= + // 1 | 1 | 1 » publish | Save and publish | | Save as draft + // 1 | 0 | 0 » unpublish | Save and publish | | Save as draft + // 0 | 1 | 1 » draft | Save and keep published | Save as draft | Save draft and unpublish + // 0 | 0 | 1 » draft | Save and publish | Save as draft | Save draft and unpublish + // 0 | 0 | 0 » draft | Save and publish | Save as draft | - // Add a "Unpublish" button. - $element['unpublish'] = $element['submit']; - $element['unpublish']['#dropbutton'] = 'save'; if ($node->isNew()) { - $element['unpublish']['#value'] = t('Save as unpublished'); + $actions['publish'] = array( + 'label' => t('Save and publish'), + 'weight' => ($node->status) ? -1 : 1, + 'primary' => ($node->status) ? TRUE : '', + ); + $actions['unpublish'] = array( + 'label' => t('Save as draft'), + 'weight' => 0, + 'primary' => ($node->status) ? '' : TRUE, + ); + } + elseif ($node->isNewRevision()) { + $actions['draft'] = array( + 'label' => t('Save as draft'), + 'primary' => TRUE, + 'weight' => -1, + ); + $actions['publish'] = array( + 'label' => ($node->status) ? t('Save and keep published') : t('Save and publish'), + 'weight' => 0, + ); + if ($node->default_status){ + $actions['unpublish'] = array( + 'label' => t('Save draft and unpublish'), + 'weight' => 1, + ); + } } else { - $element['unpublish']['#value'] = !$node->status ? t('Save and keep unpublished') : t('Save and unpublish'); + if ($node->status) { + $actions['publish'] = array( + 'label' => t('Save and keep published'), + 'weight' => -1, + 'primary' => TRUE, + ); + $actions['unpublish'] = array( + 'label' => t('Save and unpublish'), + 'weight' => 0, + ); + } + else { + $actions['unpublish'] = array( + 'label' => t('Save and keep unpublished'), + 'weight' => -1, + 'primary' => TRUE, + ); + $actions['publish'] = array( + 'label' => t('Save and publish'), + 'weight' => 0, + ); + } } - $element['unpublish']['#weight'] = 10; - array_unshift($element['unpublish']['#submit'], array($this, 'unpublish')); - // If already published, the 'publish' button is primary. - if ($node->status) { - unset($element['unpublish']['#button_type']); - } - // Otherwise, the 'unpublish' button is primary and should come first. - else { - unset($element['publish']['#button_type']); - $element['unpublish']['#weight'] = -10; + foreach ($actions as $key => $action) { + $element[$key] = $element['submit']; + $element[$key]['#dropbutton'] = 'save'; + $element[$key]['#value'] = $action['label']; + $element[$key]['#weight'] = $action['weight']; + $element[$key]['#button_type'] = !empty($action['primary']) ? 'primary' : ''; + $element[$key]['#submit'] = array(array($this, $key), array($this, 'submit'), array($this, 'save')); } // Remove the "Save" button. @@ -368,6 +408,9 @@ public function submit(array $form, array &$form_state) { $node->setNewRevision(); } + // Make this the new edit revision. + $node->set('isEditRevision', TRUE); + node_submit($node); foreach (module_implements('node_submit') as $module) { $function = $module . '_node_submit'; @@ -404,7 +447,8 @@ public function preview(array $form, array &$form_state) { */ public function publish(array $form, array &$form_state) { $node = $this->getEntity($form_state); - $node->status = 1; + $node->status = NODE_PUBLISHED; + $node->set('isDefaultRevision', TRUE); return $node; } @@ -418,7 +462,24 @@ public function publish(array $form, array &$form_state) { */ public function unpublish(array $form, array &$form_state) { $node = $this->getEntity($form_state); - $node->status = 0; + $node->status = NODE_NOT_PUBLISHED; + $node->set('isDefaultRevision', TRUE); + return $node; + } + + /** + * Form submission handler for the 'draft' action. + * + * @param $form + * An associative array containing the structure of the form. + * @param $form_state + * A reference to a keyed array containing the current state of the form. + */ + public function draft(array $form, array &$form_state) { + $node = $this->getEntity($form_state); + $node->status = NODE_NOT_PUBLISHED; + $is_default = ($node->default_status) ? FALSE : TRUE; + $node->set('isDefaultRevision', $is_default); return $node; } @@ -445,7 +506,12 @@ public function save(array $form, array &$form_state) { if ($node->nid) { $form_state['values']['nid'] = $node->nid; $form_state['nid'] = $node->nid; - $form_state['redirect'] = node_access('view', $node) ? 'node/' . $node->nid : ''; + if (!$node->isDefaultRevision && node_access('update', $node)) { + $form_state['redirect'] = 'node/' . $node->nid . '/draft'; + } + else { + $form_state['redirect'] = node_access('view', $node) ? 'node/' . $node->nid : ''; + } } else { // In the unlikely case something went wrong on save, the node will be diff --git a/core/modules/node/lib/Drupal/node/NodeStorageController.php b/core/modules/node/lib/Drupal/node/NodeStorageController.php index d855384e95a821682ff64e63e5bf5a926ed66920..4c0063287b6d5b4d096dce0a72d7e43e6d87ae5f 100644 --- a/core/modules/node/lib/Drupal/node/NodeStorageController.php +++ b/core/modules/node/lib/Drupal/node/NodeStorageController.php @@ -70,6 +70,8 @@ protected function buildQuery($ids, $revision_id = FALSE) { $query->addField('revision', 'timestamp', 'revision_timestamp'); $fields['uid']['table'] = 'base'; $query->addField('revision', 'uid', 'revision_uid'); + // Alias {node}.status to default_status. + $query->addField('base', 'status', 'default_status'); return $query; } @@ -140,7 +142,16 @@ function postSave(EntityInterface $node, $update) { if ($node->isDefaultRevision()) { node_access_acquire_grants($node, $update); } + + // If this is a new edit revision, update the pointer in {node}.edit_vid. + if ($node->isNew() || $node->isEditRevision()) { + db_update('node') + ->condition('nid', $node->nid, '=') + ->fields(array('edit_vid' => $node->vid,)) + ->execute(); + } } + /** * Overrides Drupal\Core\Entity\DatabaseStorageController::preDelete(). */ diff --git a/core/modules/node/lib/Drupal/node/Plugin/Core/Entity/Node.php b/core/modules/node/lib/Drupal/node/Plugin/Core/Entity/Node.php index a4cf26428c2025a34ec1e0d00fd8c705ac321981..b765930b1e226e0db4ffa2c830efb84b3bc09fd6 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Core/Entity/Node.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Core/Entity/Node.php @@ -33,6 +33,7 @@ * entity_keys = { * "id" = "nid", * "revision" = "vid", + * "edit_revision" = "edit_vid", * "bundle" = "type", * "label" = "title", * "uuid" = "uuid" @@ -70,6 +71,16 @@ class Node extends Entity implements ContentEntityInterface { public $isDefaultRevision = TRUE; /** + * Indicates whether this is the edit revision of the node. + * + * The edit revision of a node is the one loaded on the edit page when no + * specific revision has been specified. + * + * @var boolean + */ + public $isEditRevision = TRUE; + + /** * The node UUID. * * @var string @@ -222,4 +233,10 @@ public function getRevisionId() { return $this->vid; } + /** + * Overrides Drupal\Core\Entity\Entity::getEditRevisionId(). + */ + public function getEditRevisionId() { + return $this->edit_vid; + } } diff --git a/core/modules/node/node.admin.css b/core/modules/node/node.admin.css index 101a38d5390485f8f36f2a72aa88f3d45e8430d3..a2a2affef2add6ddb0c6b89d828863e95d39085b 100644 --- a/core/modules/node/node.admin.css +++ b/core/modules/node/node.admin.css @@ -6,6 +6,9 @@ /** * Revisions overview screen. */ -.revision-current { +.revision-default { + background: #afa; +} +.revision-edit { background: #ffc; } diff --git a/core/modules/node/node.install b/core/modules/node/node.install index 12a6945bc18e711de125719b54707076f81e3def..f486ddea1b07aaa307360470c7a199a131b13def 100644 --- a/core/modules/node/node.install +++ b/core/modules/node/node.install @@ -33,6 +33,13 @@ function node_schema() { 'not null' => FALSE, 'default' => NULL, ), + 'edit_vid' => array( + 'description' => 'The version identifier for this node\'s edit revision.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + ), 'type' => array( 'description' => 'The {node_type}.type of this node.', 'type' => 'varchar', @@ -124,12 +131,13 @@ function node_schema() { ), 'unique keys' => array( 'vid' => array('vid'), + 'edit_vid' => array('edit_vid'), 'uuid' => array('uuid'), ), 'foreign keys' => array( 'node_revision' => array( 'table' => 'node_revision', - 'columns' => array('vid' => 'vid'), + 'columns' => array('vid' => 'vid', 'edit_vid' => 'vid'), ), 'node_author' => array( 'table' => 'users', @@ -707,6 +715,75 @@ function node_update_8013() { } /** + * Create a edit_vid column for nodes. + */ +function node_update_8014(&$sandbox) { + // This is a multi-pass update. On the first call we need to update the + // database schema and initialize some variables. + if (!isset($sandbox['total'])) { + // Add a published column to the node table. + $spec = array( + 'description' => 'The version identifier for this node\'s edit revision.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + ); + $keys = array( + 'unique keys' => array( + 'edit_vid' => array('edit_vid'), + ), + 'foreign keys' => array( + 'node_revision' => array( + 'table' => 'node_revision', + 'columns' => array('edit_vid' => 'vid'), + ), + ), + ); + db_add_field('node', 'edit_vid', $spec, $keys); + + // Initialize update variables. + $sandbox['last'] = 0; + $sandbox['count'] = 0; + $sandbox['total'] = db_query('SELECT COUNT(*) FROM {node}')->fetchField(); + } + else { + // We do each pass in batches of 1000. + $batch = 1000; + + $nids = db_select('node', 'n') + ->fields('n', array(nid)) + ->range($sandbox['last'],$batch) + ->execute() + ->fetchCol(); + + foreach ($nids as $nid) { + $sandbox['count'] += 1; + $highest_vid = db_select('node_revision', 'nr') + ->fields('nr', array('vid')) + ->condition('nid', $nid, '=') + ->range(0,1) + ->orderBy('nr.' . 'vid', 'DESC') + ->execute() + ->fetchField(); + + db_update('node') + ->condition('nid', $nid, '=') + ->fields(array('edit_vid' => $highest_vid,)) + ->execute(); + } + $sandbox['last'] += $batch; + } + + if ($sandbox['count'] < $sandbox['total']) { + $sandbox['#finished'] = FALSE; + } + else { + $sandbox['#finished'] = TRUE; + } +} + +/** * @} End of "addtogroup updates-7.x-to-8.x" * The next series of updates should start at 9000. */ diff --git a/core/modules/node/node.module b/core/modules/node/node.module index f2206a088568b2ab8c3eceb2edb365e27b2442cd..0753b62578eb5fe36b8ee59352c47d345d559ab4 100644 --- a/core/modules/node/node.module +++ b/core/modules/node/node.module @@ -977,6 +977,19 @@ function node_revision_load($vid = NULL) { } /** + * Loads a node revision from the database. + * + * @param int $nid + * The node ID. + * + * @return Drupal\node\Node|false + * A fully-populated node entity, or FALSE if the node is not found. + */ +function node_edit_revision_load($nid = NULL) { + return entity_load_edit_revision_by_id('node', $nid); +} + +/** * Prepares a node for saving by populating the author and creation date. * * @param Drupal\node\Node $node @@ -1645,6 +1658,93 @@ function _node_add_access() { } /** + * Access callback: Checks a user's permission for viewing a node's draft. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * TRUE if the view draft tab should be visible, FALSE otherwise. + * + * @see node_menu() + */ +function _node_draft_page_access($node) { + // If trying to view the draft page and there is no forward revision, then + // redirect to the main node view. + if ($node->default_revision == $node->edit_vid && current_path() == 'node/' . $node->nid . '/draft') { + drupal_goto('node/' . $node->nid); + } + return ($node->default_revision != $node->edit_vid && node_access('update', $node)); +} + +/** + * Access callback: Checks a user's permission for viewing a node's edit page. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * TRUE if the view draft tab should be visible, FALSE otherwise. + * + * @see node_menu() + */ +function _node_edit_page_access($node) { + return (current_path() == 'node/' . $node->nid . '/edit' && node_access('update', $node)); +} + +/** + * Access callback: Checks a user's permission for viewing a specific version + * of a node. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * TRUE if the version tab should be visible, FALSE otherwise. + * + * @see node_menu() + */ +function _node_version_page_access($node) { + // If trying to view the default or edit revision, then + // redirect to the appropriate tab. + if ($node->isDefaultRevision()) { + drupal_goto('node/' . $node->nid); + } + elseif ($node->isEditRevision()) { + drupal_goto('node/' . $node->nid . '/draft'); + } + return _node_revision_access($node); +} + +/** + * Access callback: Checks a user's permission for viewing the next, or + * previous, revision of a node. + * + * @param Drupal\node\Node $node + * The node entity. + * @param $direction + * Indicates whether to check the 'next' or 'previous' revision. Defaults to + * 'next'. + * + * @return + * TRUE if the option should be visible, FALSE otherwise. + * + * @see node_menu() + */ +function _node_increment_revision_access($node, $direction = 'next') { + $new_vid = ''; + switch ($direction) { + case 'next': + $new_vid = node_next_revision($node); + break; + case 'previous': + $new_vid = node_previous_revision($node); + break; + } + return (!empty($new_vid) && _node_revision_access($node)); +} + +/** * Implements hook_menu(). */ function node_menu() { @@ -1757,16 +1857,97 @@ function node_menu() { 'access arguments' => array('view', 1), ); $items['node/%node/view'] = array( + 'title callback' => 'node_view_tab_title', + 'title arguments' => array(1), + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + $items['node/%node/view/view'] = array( 'title' => 'View', 'type' => MENU_DEFAULT_LOCAL_TASK, 'weight' => -10, ); - $items['node/%node/edit'] = array( - 'title' => 'Edit', + $items['node/%node/view/edit'] = array( + 'title callback' => 'node_edit_tab_title', + 'title arguments' => array(1), 'page callback' => 'node_page_edit', 'page arguments' => array(1), 'access callback' => 'node_access', 'access arguments' => array('update', 1), + 'weight' => -9, + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'file' => 'node.pages.inc', + ); + $items['node/%node/view/publish'] = array( + 'title callback' => 'node_view_publish_action_title', + 'title arguments' => array(1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('node_publish_confirm', 1), + // @todo: Fix access callback. + 'access arguments' => array('administer content types'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -8, + 'file' => 'node.pages.inc', + ); + $items['node/%node/draft'] = array( + 'title' => 'Draft', + 'page callback' => 'node_draft_page_view', + 'page arguments' => array(1), + 'access callback' => '_node_draft_page_access', + 'access arguments' => array(1), + 'weight' => -9, + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'file' => 'node.pages.inc', + ); + $items['node/%node/draft/view'] = array( + 'title' => 'View', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -10, + ); + $items['node/%node/draft/edit'] = array( + 'title' => 'Edit draft', + 'page callback' => 'node_page_edit', + 'page arguments' => array(1), + 'access callback' => 'node_access', + 'access arguments' => array('update', 1), + 'weight' => -9, + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'file' => 'node.pages.inc', + ); + $items['node/%node_edit_revision/draft/publish'] = array( + 'title' => 'Publish this version', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('node_draft_publish_confirm', 1), + // @todo: Fix access callback. + 'access arguments' => array('administer content types'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -8, + 'file' => 'node.pages.inc', + ); + $items['node/%node_edit_revision/draft/delete'] = array( + 'title' => 'Delete this version', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('node_draft_delete_confirm', 1), + // @todo: Fix access callback. + 'access arguments' => array('administer content types'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => 9, + 'file' => 'node.pages.inc', + ); + $items['node/%node/edit'] = array( + 'title callback' => 'node_edit_tab_title', + 'title arguments' => array(1), + 'page callback' => 'node_page_edit', + 'page arguments' => array(1), + 'access callback' => '_node_edit_page_access', + 'access arguments' => array(1), 'weight' => 0, 'type' => MENU_LOCAL_TASK, 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, @@ -1783,8 +1964,49 @@ function node_menu() { 'context' => MENU_CONTEXT_INLINE, 'file' => 'node.pages.inc', ); + $items['node/%node/version/%node_revision'] = array( + 'title callback' => 'node_version_tab_title', + 'title arguments' => array(3), + 'page callback' => 'node_show', + 'page arguments' => array(3, TRUE), + 'access callback' => '_node_version_page_access', + 'access arguments' => array(3), + 'tab_parent' => 'node/%', + 'tab_root' => 'node/%', + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -8, + ); + $items['node/%node/version/%node_revision/view'] = array( + 'title' => 'View', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -10, + ); + $items['node/%node/version/%node_revision/revert'] = array( + 'title' => 'Revert to this version', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('node_revision_revert_confirm', 3), + 'access callback' => '_node_revision_access', + 'access arguments' => array(3, 'update'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => -9, + 'file' => 'node.pages.inc', + ); + $items['node/%node/version/%node_revision/delete'] = array( + 'title' => 'Delete this version', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('node_revision_delete_confirm', 3), + 'access callback' => '_node_revision_access', + 'access arguments' => array(3, 'delete'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => 9, + 'file' => 'node.pages.inc', + ); $items['node/%node/revisions'] = array( - 'title' => 'Revisions', + 'title' => 'Revision history', 'page callback' => 'node_revision_overview', 'page arguments' => array(1), 'access callback' => '_node_revision_access', @@ -1866,6 +2088,70 @@ function node_page_title(Node $node) { } /** + * Title callback: Displays the node view tab title. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The title of the view tab. + * + * @see node_menu() + */ +function node_view_tab_title(Node $node) { + $title = ($node->default_status == NODE_PUBLISHED) ? t("Published") : t("Draft"); + return $title; +} + +/** + * Title callback: Displays the node edit tab title. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The title of the edit tab. + * + * @see node_menu() + */ +function node_edit_tab_title(Node $node) { + $title = ($node->default_revision == $node->edit_vid) ? t("Edit") : t("Edit draft"); + return $title; +} + +/** + * Title callback: Displays the node version tab title. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The title of the version tab. + * + * @see node_menu() + */ +function node_version_tab_title(Node $node) { + $title = 'Version #' . $node->vid; + return $title; +} + +/** + * Title callback: Displays the publish action title on the node view tab. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The title of the action. + * + * @see node_menu() + */ +function node_view_publish_action_title(Node $node) { + $title = ($node->default_status == NODE_PUBLISHED) ? t("Unpublish this") : t("Publish this"); + return $title; +} + +/** * Finds the last time a node was changed. * * @param $nid @@ -1889,7 +2175,7 @@ function node_last_changed($nid) { */ function node_revision_list(Node $node) { $revisions = array(); - $result = db_query('SELECT r.vid, r.title, r.log, r.uid, n.vid AS current_vid, r.timestamp, u.name FROM {node_revision} r LEFT JOIN {node} n ON n.vid = r.vid INNER JOIN {users} u ON u.uid = r.uid WHERE r.nid = :nid ORDER BY r.vid DESC', array(':nid' => $node->nid)); + $result = db_query('SELECT r.vid, r.title, r.log, r.uid, r.timestamp, r.status, u.name FROM {node_revision} r INNER JOIN {users} u ON u.uid = r.uid WHERE r.nid = :nid ORDER BY r.vid DESC', array(':nid' => $node->nid)); foreach ($result as $revision) { $revisions[$revision->vid] = $revision; } @@ -1898,6 +2184,64 @@ function node_revision_list(Node $node) { } /** + * Returns the revision with the next highest ID for the node passed in. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The next highest revision ID, or its node object, if $load is TRUE. + */ +function node_next_revision(Node $node, $load = FALSE) { + $next_vid = db_select('node_revision', 'nr') + ->fields('nr', array('vid')) + ->condition('nid', $node->nid) + ->condition('vid', $node->vid, '>') + ->range(0,1) + ->orderBy('nr.' . 'vid', 'ASC') + ->execute() + ->fetchField(); + + if (empty($next_vid)) { + return FALSE; + } + elseif ($load) { + return node_revision_load($next_vid); + } + + return $next_vid; +} + +/** + * Returns the revision with the next lowest ID for the node passed in. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * The next lowest revision ID, or its node object, if $load is TRUE. + */ +function node_previous_revision(Node $node, $load = FALSE) { + $previous_vid = db_select('node_revision', 'nr') + ->fields('nr', array('vid')) + ->condition('nid', $node->nid) + ->condition('vid', $node->vid, '<') + ->range(0,1) + ->orderBy('nr.' . 'vid', 'DESC') + ->execute() + ->fetchField(); + + if (empty($previous_vid)) { + return FALSE; + } + elseif ($load) { + return node_revision_load($previous_vid); + } + + return $previous_vid; +} + +/** * Finds the most recently changed nodes that are available to the current user. * * @param $number @@ -2276,6 +2620,23 @@ function node_page_view(Node $node) { } /** + * Draft page callback: Displays a single node. + * + * @param Drupal\node\Node $node + * The node entity. + * + * @return + * A page array suitable for use by drupal_render(). + * + * @see node_menu() + */ +function node_draft_page_view(Node $node) { + // Load the edit revision and pass that to node_page_view(). + $edit_revision = entity_load_edit_revision($node); + return node_page_view($edit_revision); +} + +/** * Implements hook_update_index(). */ function node_update_index() { diff --git a/core/modules/node/node.pages.inc b/core/modules/node/node.pages.inc index 245fef793928747f8895f1c64f640a9b1bbd8f4f..85625dbc93c8db0acf8dde6d7f34d83395af4085 100644 --- a/core/modules/node/node.pages.inc +++ b/core/modules/node/node.pages.inc @@ -23,8 +23,47 @@ * @see node_menu() */ function node_page_edit($node) { - drupal_set_title(t('Edit @type @title', array('@type' => node_get_type_label($node), '@title' => $node->label())), PASS_THROUGH); - return entity_get_form($node); + // Make sure we are on the edit tab. + if (current_path() != 'node/' . $node->nid . '/edit') { + drupal_goto('node/' . $node->nid . '/edit'); + } + // Make sure we are loading the edit revision. + $edit_revision = entity_load_edit_revision($node); + drupal_set_title(t('Edit @type @title', array('@type' => node_get_type_label($edit_revision), '@title' => $edit_revision->label())), PASS_THROUGH); + return entity_get_form($edit_revision); +} + +/** + * Page callback: Redirects to the next, or previous revision of this node. + * + * @param Drupal\node\Node $node + * The node entity. + * @param $direction + * Indicates whether to move to the 'next' or 'previous' revision. Defaults + * to 'next'. + * + * @return array + * A form array as expected by drupal_render(). + * + * @see node_menu() + */ +function node_increment_revision($node, $direction = 'next') { + $new_vid = ''; + switch ($direction) { + case 'next': + $new_vid = node_next_revision($node); + break; + case 'previous': + $new_vid = node_previous_revision($node); + break; + } + if ($new_vid == $node->default_revision) { + drupal_goto('node/' . $node->nid); + } + elseif ($new_vid == $node->edit_vid) { + drupal_goto('node/' . $node->nid . '/draft'); + } + drupal_goto('node/' . $node->nid . '/version/' . $new_vid); } /** @@ -246,7 +285,7 @@ function node_delete_confirm_submit($form, &$form_state) { function node_revision_overview($node) { drupal_set_title(t('Revisions for %title', array('%title' => $node->label())), PASS_THROUGH); - $header = array(t('Revision'), t('Operations')); + $header = array(t('Revision'), t('State'), t('Operations')); $revisions = node_revision_list($node); @@ -262,17 +301,71 @@ function node_revision_overview($node) { $delete_permission = TRUE; } foreach ($revisions as $revision) { - $row = array(); + $row = array('revision' => '', 'state' => '', 'operations' => NULL); $type = $node->type; - if ($revision->current_vid > 0) { - $row[] = array('data' => t('!date by !username', array('!date' => l(format_date($revision->timestamp, 'short'), "node/$node->nid"), '!username' => theme('username', array('account' => $revision)))) - . (($revision->log != '') ? '

' . filter_xss($revision->log) . '

' : ''), - 'class' => array('revision-current')); - $row[] = array('data' => drupal_placeholder(t('current revision')), 'class' => array('revision-current')); + $isDefaultRevision = ($revision->vid == $node->default_revision); + $isEditRevision = ($revision->vid == $node->edit_vid); + $links = array(); + if ($isDefaultRevision) { + $row['class'] = 'revision-default'; + $row['revision'] = t('!date by !username', array('!date' => l('#' . $revision->vid . ': ' . format_date($revision->timestamp, 'short'), "node/$node->nid", array('html' => TRUE)), '!username' => theme('username', array('account' => $revision)))) + . (($revision->log != '') ? '

' . filter_xss($revision->log) . '

' : ''); + $row['state'] = ($revision->status == NODE_PUBLISHED) ? '' . t('Published') . '' : t('Draft'); + $row['state'] .= ($isEditRevision) ? ' (' . t('Edit revision') . ')' : ''; + $publish_action = ($revision->status == NODE_PUBLISHED) ? 'unpublish' : 'pubish'; + // @todo: Add proper permissions check. + if (TRUE) { + $links[$publish_action] = array( + 'title' => t($publish_action), + 'href' => "node/$node->nid/view/publish", + ); + } + if (!$isEditRevision && $revert_permission) { + $links['revert'] = array( + 'title' => t('revert'), + 'href' => "node/$node->nid/revisions/$revision->vid/revert", + ); + } + } + elseif ($isEditRevision) { + $row['class'] = 'revision-edit'; + $row['revision'] = t('!date by !username', array('!date' => l('#' . $revision->vid . ': ' . format_date($revision->timestamp, 'short'), "node/$node->nid/draft", array('html' => TRUE)), '!username' => theme('username', array('account' => $revision)))) + . (($revision->log != '') ? '

' . filter_xss($revision->log) . '

' : ''); + $row['state'] = t('Draft'); + $row['state'] .= ' (' . t('Edit revision') . ')'; + // @todo: add permission check. + if (TRUE) { + $links['edit'] = array( + 'title' => t('edit'), + 'href' => "node/$node->nid/edit", + ); + } + // @todo: add permission check. + if (TRUE) { + $links['publish'] = array( + 'title' => t('publish'), + 'href' => "node/$node->nid/draft/publish", + ); + } + if ($delete_permission) { + $links['delete'] = array( + 'title' => t('delete'), + 'href' => "node/$node->nid/draft/delete", + ); + } } else { - $row[] = t('!date by !username', array('!date' => l(format_date($revision->timestamp, 'short'), "node/$node->nid/revisions/$revision->vid/view"), '!username' => theme('username', array('account' => $revision)))) + $row['revision'] = t('!date by !username', array('!date' => l('#' . $revision->vid . ': ' . format_date($revision->timestamp, 'short'), "node/$node->nid/version/$revision->vid", array('html' => TRUE)), '!username' => theme('username', array('account' => $revision)))) . (($revision->log != '') ? '

' . filter_xss($revision->log) . '

' : ''); + if ($revision->vid > $node->default_revision) { + $row['state'] = t('Draft'); + } + else { + $row['state'] = t('Archived'); + $row['state'] .= ' ('; + $row['state'] .= ($revision->status == NODE_PUBLISHED) ? t('published') : t('draft'); + $row['state'] .= ')'; + } if ($revert_permission) { $links['revert'] = array( 'title' => t('revert'), @@ -285,13 +378,22 @@ function node_revision_overview($node) { 'href' => "node/$node->nid/revisions/$revision->vid/delete", ); } - $row[] = array( + } + + if (!empty($links)) { + $row['operations'] = array( 'data' => array( '#type' => 'operations', '#links' => $links, ), ); } + if (!empty($row['class'])) { + $row['revision'] = array('data' => $row['revision'], 'class' => array($row['class'])); + $row['state'] = array('data' => $row['state'], 'class' => array($row['class'])); + $row['operations'] = array('data' => $row['operations'], 'class' => array($row['class'])); + unset($row['class']); + } $rows[] = $row; } @@ -333,8 +435,12 @@ function node_revision_revert_confirm($form, $form_state, $node_revision) { function node_revision_revert_confirm_submit($form, &$form_state) { $node_revision = $form['#node_revision']; $node_revision->setNewRevision(); - // Make this the new default revision for the node. - $node_revision->isDefaultRevision(TRUE); + // Save this as a draft revision. + $node_revision->status = NODE_NOT_PUBLISHED; + $default_setting = ($node_revision->default_status == NODE_PUBLISHED) ? FALSE : TRUE; + $node_revision->isDefaultRevision($default_setting); + // Make this the new edit revision for the node. + $node_revision->isEditRevision(TRUE); // The revision timestamp will be updated when the revision is saved. Keep the // original one for the confirmation message. @@ -346,7 +452,7 @@ function node_revision_revert_confirm_submit($form, &$form_state) { watchdog('content', '@type: reverted %title revision %revision.', array('@type' => $node_revision->type, '%title' => $node_revision->label(), '%revision' => $node_revision->vid)); drupal_set_message(t('@type %title has been reverted back to the revision from %revision-date.', array('@type' => node_get_type_label($node_revision), '%title' => $node_revision->label(), '%revision-date' => format_date($original_revision_timestamp)))); - $form_state['redirect'] = 'node/' . $node_revision->nid . '/revisions'; + $form_state['redirect'] = ($default_setting) ? 'node/' . $node_revision->nid : 'node/' . $node_revision->nid . '/draft'; } /** @@ -383,3 +489,143 @@ function node_revision_delete_confirm_submit($form, &$form_state) { $form_state['redirect'] .= '/revisions'; } } + +/** + * Page callback: Form constructor for the publish action form. + * + * This form prevents against CSRF attacks. + * + * @param object $node + * The node object. + * + * @return array + * An array as expected by drupal_render(). + * + * @see node_menu() + * @see node_revision_revert_confirm_submit() + * @ingroup forms + */ +function node_publish_confirm($form, $form_state, $node) { + $action = ($node->default_status == NODE_PUBLISHED) ? "unpublish" : "publish"; + $form['#node_publish_action'] = $action; + $form['#node_revision'] = $node; + return confirm_form($form, t('Are you sure you want to @action this @type?', array('@action' => $action, '@type' => node_get_type_label($node))), 'node/' . $node->nid, '', t($action), t('Cancel')); +} + +/** + * Form submission handler for node_revision_revert_confirm(). + */ +function node_publish_confirm_submit($form, &$form_state) { + $action = $form['#node_publish_action']; + $node_revision = $form['#node_revision']; + switch ($action) { + case 'publish': + $node_revision->status = NODE_PUBLISHED; + break; + + case 'unpublish': + $node_revision = ($node_revision->isEditRevision()) ? $node_revision : node_revision_load($node_revision->edit_vid); + $node_revision->status = NODE_NOT_PUBLISHED; + break; + } + // Make this the new default revision for the node. + $node_revision->isDefaultRevision(TRUE); + $node_revision->isEditRevision(TRUE); + $node_revision->setNewRevision(FALSE); + + // The revision timestamp will be updated when the revision is saved. Keep the + // original one for the confirmation message. + $original_revision_timestamp = $node_revision->revision_timestamp; + + $node_revision->log = t('%action the revision from %date.', array('%action' => ucfirst($action), '%date' => format_date($original_revision_timestamp))); + + $node_revision->save(); + + watchdog('content', '@type: @action %title revision %revision.', array('@type' => $node_revision->type, '@action' => $action . 'ed', '%title' => $node_revision->label(), '%revision' => $node_revision->vid)); + drupal_set_message(t('@type %title has been @action.', array('@type' => node_get_type_label($node_revision), '%title' => $node_revision->label(), '%revision-date' => format_date($original_revision_timestamp), '@action' => $action . 'ed'))); + $form_state['redirect'] = 'node/' . $node_revision->nid; +} + +/** + * Page callback: Form constructor for the draft publish form. + * + * This form prevents against CSRF attacks. + * + * @param object $node + * The node object. + * + * @return array + * An array as expected by drupal_render(). + * + * @see node_menu() + * @see node_revision_revert_confirm_submit() + * @ingroup forms + */ +function node_draft_publish_confirm($form, $form_state, $node) { + // Make sure we're loading the edit revision. + $edit_revision = entity_load_edit_revision($node); + $form['#node_revision'] = $edit_revision; + return confirm_form($form, t('Are you sure you want to publish this draft?'), 'node/' . $edit_revision->nid . '/draft', '', t('Publish'), t('Cancel')); +} + +/** + * Form submission handler for node_revision_revert_confirm(). + */ +function node_draft_publish_confirm_submit($form, &$form_state) { + $node_revision = $form['#node_revision']; + // Make this the new default revision for the node. + $node_revision->isDefaultRevision(TRUE); + $node_revision->isEditRevision(TRUE); + $node_revision->setNewRevision(FALSE); + $node_revision->status = NODE_PUBLISHED; + + // The revision timestamp will be updated when the revision is saved. Keep the + // original one for the confirmation message. + $original_revision_timestamp = $node_revision->revision_timestamp; + + $node_revision->log = t('Copy of the revision from %date.', array('%date' => format_date($original_revision_timestamp))); + + $node_revision->save(); + + watchdog('content', '@type: published %title revision %revision.', array('@type' => $node_revision->type, '%title' => $node_revision->label(), '%revision' => $node_revision->vid)); + drupal_set_message(t('The draft of @type %title has been published.', array('@type' => node_get_type_label($node_revision), '%title' => $node_revision->label(), '%revision-date' => format_date($original_revision_timestamp)))); + $form_state['redirect'] = 'node/' . $node_revision->nid; +} + +/** + * Page callback: Form constructor for the draft deletion confirmation form. + * + * This form prevents against CSRF attacks. + * + * @param object $node + * The node object. + * + * @return + * An array as expected by drupal_render(). + * + * @see node_menu() + * @see node_draft_delete_confirm_submit() + * @ingroup forms + */ +function node_draft_delete_confirm($form, $form_state, $node) { + // Make sure we're loading the edit revision. + $edit_revision = entity_load_edit_revision($node); + $form['#node_revision'] = $edit_revision; + return confirm_form($form, t('Are you sure you want to delete the draft revision from %revision-date?', array('%revision-date' => format_date($edit_revision->revision_timestamp))), 'node/' . $edit_revision->nid . '/draft', t('This action cannot be undone.'), t('Delete'), t('Cancel')); +} + +/** + * Form submission handler for node_draft_delete_confirm(). + */ +function node_draft_delete_confirm_submit($form, &$form_state) { + $node_revision = $form['#node_revision']; + node_revision_delete($node_revision->vid); + $edit_vid = $node_revision->setEditRevision(); + + watchdog('content', '@type: deleted %title revision %revision.', array('@type' => $node_revision->type, '%title' => $node_revision->label(), '%revision' => $node_revision->vid)); + drupal_set_message(t('Revision from %revision-date of @type %title has been deleted.', array('%revision-date' => format_date($node_revision->revision_timestamp), '@type' => node_get_type_label($node_revision), '%title' => $node_revision->label()))); + $form_state['redirect'] = 'node/' . $node_revision->nid; + if ($node_revision->default_revision != $edit_vid) { + $form_state['redirect'] .= '/draft'; + } +} diff --git a/modules/custom/moderation/lib/Drupal/moderation/Tests/ModerationNodeTest.php b/modules/custom/moderation/lib/Drupal/moderation/Tests/ModerationNodeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..640aa75086efccfa7859244266c7023c60fc022b --- /dev/null +++ b/modules/custom/moderation/lib/Drupal/moderation/Tests/ModerationNodeTest.php @@ -0,0 +1,180 @@ + 'Node Moderation', + 'description' => 'Create and edit forward revisions of a node.', + 'group' => 'Moderation', + ); + } + + function setUp() { + parent::setUp(); + + // Use revisions by default. + $node_options = variable_get('node_options_article', array('status', 'promote')); + $node_options[] = 'revision'; + variable_set('node_options_article', $node_options); + + // Set up an admin user. + $this->admin_user = $this->drupalCreateUser(array('bypass node access', 'administer nodes')); + } + + /** + * Helper function for checking if the draft tab exists. + */ + function _checkForDraftTab() { + $this->assertText('View draft', 'Draft Tab is present'); + } + + /** + * Helper function for checking if the draft tab does not exists. + */ + function _checkForNoDraftTab() { + $this->assertNoText('View draft', 'Draft Tab is not present'); + } + + /** + * Checks node edit functionality. + */ + function testBasicForwarRevisions() { + $this->drupalLogin($this->admin_user); + $this->drupalGet('node/add/article'); + + // Verify moderation select list is present. + // @todo, this check will change when the moderation select list is not coming + // from a field. + $this->assertRaw('edit-field-moderation-und', t('Moderation select list is present.')); + + // Verify that the published checkbox is hidden. + // @todo, Show this be an xpath check? + $this->assertNoRaw('id="edit-status"', t('The published checkbox is not present')); + + + // Helper variables for forms. + $langcode = LANGUAGE_NOT_SPECIFIED; + $title_key = "title"; + $body_key = "body[$langcode][0][value]"; + $moderation_key = "field_moderation[$langcode]"; + + /** + * Make a draft revision. + */ + $draft_one_values = array( + // @todo, should these values go in t()? + // It seems to be a standard to use randomName() for these kinds of values. + // Human-readable text seems much easier to debug. + $title_key => 'Draft One Title', + $body_key => 'Draft One Body field', + $moderation_key => 'draft', + ); + $this->drupalPost('node/add/article', $draft_one_values, t('Save')); + + // Verify that the admin user can see the node's page. + $this->drupalGet('node/1'); + $this->assertResponse(200, t('node/1 is accessible to the admin user.')); + $this->_checkForNoDraftTab(); + + // Verify that the anonymous user can't see the node's page. + $this->drupalLogout(); + $this->drupalGet('node/1'); + $this->assertResponse(403, t('node/1 is inaccessible as a 403 to anonymous user.')); + + + /** + * Make another draft revision. These values should be visible at node/1. + */ + $this->drupalLogin($this->admin_user); + $draft_two_values = array( + $title_key => 'Draft Two Title', + $body_key => 'Draft Two Body field', + $moderation_key => 'draft', + ); + $this->drupalPost('node/1/edit', $draft_two_values, t('Save')); + + // Verify that the admin user can see the node's page and sees the published values there. + $this->drupalGet('node/1'); + $this->assertResponse(200, t('node/1 is accessible to the admin user.')); + $this->assertText($draft_two_values[$title_key], t('draft_two_values Title found')); + $this->assertText($draft_two_values[$body_key], t('draft_two_values Body found')); + $this->_checkForNoDraftTab(); + + /** + * Make a published revision. + */ + $published_one_values = array( + $title_key => 'Published One Title', + $body_key => 'Published One Body field', + $moderation_key => 'published', + ); + $this->drupalPost('node/1/edit', $published_one_values, t('Save')); + + // Verify that the admin user can see the node's page. + $this->drupalGet('node/1'); + $this->assertResponse(200, t('node/1 is accessible to the admin user.')); + $this->_checkForNoDraftTab(); + + // Verify that the anonymous user can't see the node's page. + $this->drupalLogout(); + $this->drupalGet('node/1'); + $this->assertResponse(200, t('node/1 is accessible to the anonymous user.')); + + + /** + * Make a draft revision. + */ + $this->drupalLogin($this->admin_user); + $revision_three_values = array( + $title_key => 'Revision Three Title', + $body_key => 'Revision Three Body field', + $moderation_key => 'draft', + ); + $this->drupalPost('node/1/edit', $revision_three_values, t('Save')); + + // Verify that the admin user can see the node's page and sees the published values there. + $this->drupalGet('node/1'); + $this->assertResponse(200, t('node/1 is accessible to the admin user.')); + $this->assertText($published_one_values[$title_key], t('published_one_values Title found')); + $this->assertText($published_one_values[$body_key], t('published_one_values Body found')); + + + // Verify that the admin user sees revision three at node/1/draft. + $this->_checkForDraftTab(); + $this->drupalGet('node/1/draft'); + $this->assertResponse(200, t('node/1/draft is accessible to the admin user.')); + $this->assertText($revision_three_values[$title_key], t('revision_three_values Title found')); + $this->assertText($revision_three_values[$body_key], t('revision_three_values Body found')); + + + /** + * Go to edit page. Verify that draft node loads. + */ + $this->drupalGet('node/1/edit'); + // @todo Is there a better way to check field default values? + $this->assertRaw($revision_three_values[$title_key], 'Draft title is loaded into the form'); + $this->assertRaw($revision_three_values[$body_key], 'Draft body is loaded into the form'); + } +} diff --git a/modules/custom/moderation/moderation.info b/modules/custom/moderation/moderation.info new file mode 100644 index 0000000000000000000000000000000000000000..5d989257badd1e4f29f2a6b845cfc35858c09861 --- /dev/null +++ b/modules/custom/moderation/moderation.info @@ -0,0 +1,4 @@ +name = Moderation +description = Moderate revisionable entities between states. +core = 8.x +dependencies[] = options \ No newline at end of file diff --git a/modules/custom/moderation/moderation.install b/modules/custom/moderation/moderation.install new file mode 100644 index 0000000000000000000000000000000000000000..ac6daf5700902954f562072b0478f3cb5e280ece --- /dev/null +++ b/modules/custom/moderation/moderation.install @@ -0,0 +1,150 @@ + '0', + 'entity_types' => + array ( + ), + 'settings' => + array ( + 'allowed_values' => + array ( + 'draft' => 'Draft', + 'published' => 'Published', + ), + 'allowed_values_function' => '', + 'allowed_values_function_display' => '', + ), + 'storage' => + array ( + 'type' => 'field_sql_storage', + 'settings' => + array ( + ), + 'module' => 'field_sql_storage', + 'active' => '1', + 'details' => + array ( + 'sql' => + array ( + 'FIELD_LOAD_CURRENT' => + array ( + 'field_data_field_moderation' => + array ( + 'value' => 'field_moderation_value', + ), + ), + 'FIELD_LOAD_REVISION' => + array ( + 'field_revision_field_moderation' => + array ( + 'value' => 'field_moderation_value', + ), + ), + ), + ), + ), + 'foreign keys' => + array ( + ), + 'indexes' => + array ( + 'value' => + array ( + 0 => 'value', + ), + ), + 'id' => '6', + 'field_name' => 'field_moderation', + 'type' => 'list_text', + 'module' => 'options', + 'active' => '1', + 'locked' => '0', + 'cardinality' => '1', + 'deleted' => '0', + 'columns' => + array ( + 'value' => + array ( + 'type' => 'varchar', + 'length' => 255, + 'not null' => false, + ), + ), + 'bundles' => + array ( + 'node' => + array ( + 0 => 'article', + ), + ), + ); + + // This code was generated from debug(field_info_instance('node', 'field_moderation', 'article')); + $field_instance = + array ( + 'label' => 'Moderation', + 'widget' => + array ( + 'weight' => 0, + 'type' => 'options_select', + 'module' => 'options', + 'active' => 0, + 'settings' => + array ( + ), + ), + 'settings' => + array ( + 'user_register_form' => false, + ), + 'display' => + array ( + 'default' => + array ( + 'label' => 'above', + 'type' => 'list_default', + 'settings' => + array ( + ), + 'module' => 'options', + 'weight' => 11, + ), + ), + // @todo, this should be required. + 'required' => TRUE, + 'description' => '', + 'default_value' => NULL, + 'id' => '8', + 'field_id' => '6', + 'field_name' => 'field_moderation', + 'entity_type' => 'node', + 'bundle' => 'article', + 'deleted' => '0', + ); + field_cache_clear(); + field_create_field($field_config); + field_cache_clear(); + field_create_instance($field_instance); +} diff --git a/modules/custom/moderation/moderation.module b/modules/custom/moderation/moderation.module new file mode 100644 index 0000000000000000000000000000000000000000..3ead5ec9c3c6890c9a6ea9317562be9431aab42c --- /dev/null +++ b/modules/custom/moderation/moderation.module @@ -0,0 +1,199 @@ +entityType(), moderation_load_active_vid($entity)); + // @todo, hard-coding for nodes might be unavoidable here. + return node_page_edit($active_revision); +} + +/** + * Implements hook_menu(). + */ +function moderation_menu() { + + $items = array(); + + // View the current draft of a node. + $items["node/%node/draft"] = array( + 'title' => 'View draft', + 'page callback' => 'moderation_view_draft', + 'page arguments' => array(1), + 'access callback' => 'moderation_draft_tab_access', + 'access arguments' => array(1), + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'type' => MENU_LOCAL_TASK, + 'weight' => -9, + ); + + return $items; +} + +/** + * @todo, this should be switched to an entity. + */ +function moderation_draft_tab_access($node) { + + if (node_access('update', $node)) { + $active_vid = moderation_load_active_vid($node); + // return TRUE if the active vid is different from the incoming vid. + return ($active_vid != $node->vid); + } + return FALSE; +} + +/** + * Utility function to load the active/editing revision of an entity. + * + * @param $entity + * The entity being acted upon. + * + * @return + * The entity with the highest revision id. + * @todo, a more intelligent way of determining active vid. + * + * @todo, make tests for just this function. + */ +function moderation_load_active_vid(Drupal\Core\Entity\EntityInterface $entity) { + + // @todo, loading entity info to get things that should be method calls. + $entity_info = entity_get_info($entity->entityType()); + + // The id() check should ensure that this entity has been saved to the db. + // Checking the revision table as a proxy for isRevisionable(). + if ($entity->id() && !empty($entity_info['revision_table'])) { + + // @todo Seems like there should be a method call for these. + if (!empty($entity_info['entity_keys']['id']) && !empty($entity_info['entity_keys']['revision'])) { + $id_key = $entity_info['entity_keys']['id']; + $revision_key = $entity_info['entity_keys']['revision']; + + $revision_table = $entity_info['revision_table']; + // @todo, node_revision can be derived from entity_info. Along with all of the + // other keys here. + $highest_vid = db_select($revision_table, 'rt') + ->fields('rt', array($revision_key)) + ->condition($id_key, $entity->id()) + ->range(0,1) + ->orderBy('rt.' . $revision_key, 'DESC') + ->execute() + ->fetchCol(); + + $highest_vid = reset($highest_vid); + return $highest_vid; + } + } + + // @todo, better error handling? + return FALSE; +} + +/** + * Displays the current draft the node, if it is not published. + * + * @param $node + * The node being acted upon. + * + * @return + * A fully themed node page. + */ +function moderation_view_draft(Drupal\Core\Entity\EntityInterface $entity) { + + // @todo, this function is not tied to nodes but the related functions are. + + // Replace "vid" with something abstracted. + $forward_revision = entity_revision_load($entity->entityType(), moderation_load_active_vid($entity)); + return moderation_router_item_page_callback($forward_revision); +} + +/** + * Get the menu router item for nodes. + * + * @param $node + * The node being acted upon. + * @return + * A fully themed node page. + */ +function moderation_router_item_page_callback($node) { + $router_item = menu_get_item('node/' . $node->nid); + if ($router_item['include_file']) { + require_once DRUPAL_ROOT . '/' . $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 of Panel + // definition acting on node/%node. + return $router_item['page_callback']($node); +} + +/** + * Implements hook_entity_presave(); + **/ +function moderation_entity_presave(Drupal\Core\Entity\EntityInterface $entity) { + + $field_moderation = $entity->get('field_moderation'); + + // @todo, In a full implementation of this module there should be a much cleaner + // way of getting this value. + if (!empty($field_moderation[LANGUAGE_NOT_SPECIFIED][0]['value'])) { + $moderation_value = $field_moderation[LANGUAGE_NOT_SPECIFIED][0]['value']; + + // If "published," set status as TRUE; + // @todo A full implementation of this module would do better than check for a hardcoded string. + // @todo, this might belong better in hook_node_presave() since 'status' + // is a node property here. + if ($moderation_value === 'published') { + $entity->set('status', TRUE); + } else { + $entity->set('status', FALSE); + + // A new revision entity would also be the default entity. + if (!$entity->isNew()) { + + $original_revision = $entity->get('original'); + + if (!empty($original_revision)) { + $entity_status = $original_revision->get('status'); + if (!empty($entity_status)) { + $entity->set('isDefaultRevision', FALSE); + } + } + } + } + } +} + +/** + * Implments hook_form_alter(). + */ +function moderation_form_alter(&$form, &$form_state, $form_id) { + + // @todo, In a real implementation, this would need a more intelligent check for + // whether this is a form that should be altered. + if (!empty($form['field_moderation']) && !empty($form_state['entity'])) { + + // Restrict access to the Published checkbox. Moderation module will take over + // the published behavior. + if (!empty($form['options']['status'])) { + $form['options']['status']['#access'] = FALSE; + // @todo validate the state selected. There could be restrictions on when + // a state can be entered. + // $form['#validate'][] = 'moderation_form_alter_validate'; + } + } +}