'Drafty', 'description' => 'Manage Drafty settings.', 'type' => MENU_NORMAL_ITEM, 'page callback' => 'drupal_get_form', 'page arguments' => array('drafty_form'), 'access arguments' => array('administer drafty'), 'file' => 'includes/drafty.admin.inc', ); return $items; } /** * Implements hook_permission(). */ function drafty_permission() { return array( 'administer drafty' => array( 'title' => t('Administer Drafty'), ), ); } /** * Implements hook_module_implements_alter(). */ function drafty_module_implements_alter(&$implementations) { if (isset($implementations['drafty'])) { $group = $implementations['drafty']; unset($implementations['drafty']); $implementations['drafty'] = $group; } } /** * Implements hook_entity_presave(). */ function drafty_entity_presave($entity, $type) { $recursion_level = &drupal_static('drafty_recursion_level', 0); if (!$recursion_level && !drafty_entity_is_new($entity, $type) && !empty($entity->is_draft_revision)) { // Since this is a draft revision, after saving we want the current, // published revision to remain in place in the base entity table and // field_data_*() tables. Set the revision to publish once the draft entity // has been written to the database. list($id) = entity_extract_ids($type, $entity); $vid = drafty()->getPublishedRevisionId($type, $id); drafty()->setRevisionToBePublished($type, $id, $vid); } $recursion_level++; } /** * Implements hook_entity_update(). */ function drafty_entity_update($entity, $type) { $recursion_level = &drupal_static('drafty_recursion_level', 0); if ($recursion_level == 1) { // Doing this in hook_entity_update() so that the entire process is // completed within entity saving. However this results in two entity saves // within entity insert. The other option is hook_exit(), which is not // better since for example that would happen outside the transaction. drafty()->restorePublishedRevisions(); } $recursion_level--; } /** * Implements hook_entity_insert(). */ function drafty_entity_insert($entity, $type) { $recursion_level = &drupal_static('drafty_recursion_level', 0); $recursion_level--; } /** * Implements hook_field_attach_load(). */ function drafty_field_attach_load($entity_type, $entities, $age, $options) { // Entity API provides the function entity_revision_is_default() to determine // whether an entity was loaded with the default revision or not. However this // is not sufficient for two reasons. // - It relies on entity_load() for core entities, which makes it unsafe to // call within hook_entity_load() implementations. This can be useful when // allowing drafts to be previewed in context such as listings. // - The entity API implementation only tells you whether the entity was // loaded with that revision or not, but not whether it was requested // with the ID or with the revision explicitly specified. // Note that hook_field_attach_load() is the only hook in core where it is // possible to determine whether calling code requested a revision or not, // this information is not available to hook_entity_load(). Also note that // hook_field_attach_load() is cached when entities are loaded only be ID, but // since revision loads don't use the field cache it works fine for our // purposes. foreach ($entities as $entity) { $entity->_drafty_revision_requested = $age; } } /** * Callback function to delete a single temporary-draft revision. * * Could be called either from a batch job or from a cron-queue. */ function drafty_queue_delete_revision($drafty_queue_item, &$context = NULL) { try { // Trigger hook_drafty_predelete_revision(). Allow other modules to // preserve information on the old revision before it's deleted. module_invoke_all('drafty_predelete_revision', $drafty_queue_item['entity_type'], $drafty_queue_item['entity_id'], $drafty_queue_item['revision_id'], $drafty_queue_item['replaced_by']); // Delete the old revision. entity_revision_delete($drafty_queue_item['entity_type'], $drafty_queue_item['revision_id']); // Document the successful batch operation for the batch-finished function // to use. if (!empty($context) && isset($context['results'])){ $context['results'][] = $drafty_queue_item; } } catch (Exception $ex){ // Log the problem. $message = 'An error occurred while deleting temporary revision: @args. Exception details: @error'; $args = array( '@args' => print_r($drafty_queue_item, TRUE), '@error' => print_r($ex, TRUE), ); watchdog('drafty', $message, $args, WATCHDOG_ERROR); throw $ex; // Batch API will pass $success = FALSE. } } /** * Implements hook_cron_queue_info(). */ function drafty_cron_queue_info() { $queues['drafty_revision_delete'] = array( 'worker callback' => 'drafty_queue_delete_revision', 'time' => 60, ); return $queues; } /** * Batch completion callback for when draft-deletes happen in a batch job. * $results contains info about successfully deleted revisions, and * exception objects for revision-delete options that caused problems. * $operations contains the operations that remained unprocessed. */ function drafty_delete_batch_finished($success, $results, $operations) { // TODO: Display a message about temporary-revision-cleanup here? // Note that logging of failed deletes is handled by the operation callback. } /** * Factory function for the DraftyTracker class. */ function drafty() { $tracker = &drupal_static(__FUNCTION__); if (!isset($tracker)) { $tracker = new Drafty(); } return $tracker; } /** * Helper function to determine if an entity is new or not. * * The $is_new property cannot be relied on, as not all entities use this. E.g. * files. * * @param mixed $entity * The entity to check. * @param string $entity_type * The entity type being passed in. * * @return bool */ function drafty_entity_is_new($entity, $entity_type) { // Use is_new if it's available, since it's not impossible for new entities to // have an ID set. if (!empty($entity->is_new)) { return TRUE; } list($id) = entity_extract_ids($entity_type, $entity); return empty($id); } /** * Handles tracking, selecting and publishing revisions. */ class Drafty { /** * A list of entity types, ids and version IDs to be published. */ protected $revisionsToPublish = array(); public function wasRevisionRequested($entity) { return isset($entity->_drafty_revision_requested) && $entity->_drafty_revision_requested === FIELD_LOAD_REVISION; } public function isDraftRevision($entity) { return !empty($entity->is_draft_revision); } /** * Get the current published revision for an entity. * * @param $type * The entity type. * @param $id * The entity ID. * * @return A version ID. */ public function getPublishedRevisionId($type, $id) { $info = entity_get_info(); // Get the version ID of the published revision directly from the database. // It is not possible to rely on $entity->original here since that does not // guarantee being the published revision. Also avoid loading the entity // because we may be in the process of saving it. $query = db_select($info[$type]['base table'], 'b'); $query->addField('b', $info[$type]['entity keys']['revision']); $query->condition($info[$type]['entity keys']['id'], $id); $vid = $query->execute()->fetchField(); return $vid; } /** * Add a revision to be published to the tracker. * * @param $type * The entity type. * @param $id * The entity ID. * @param $vid * The entity version ID. * * @return $this */ public function setRevisionToBePublished($type, $id, $vid) { // Only one revision can be published during a request, so just overwrite // and for now last one wins. $this->revisionsToPublish[$type][$id] = $vid; return $this; } /** * Publish a revision. * * @param $type * The entity type. * @param $vid * The entity version ID. * * @return The newly published revision. */ function publishRevision($type, $id, $vid) { // Title module assumes that the current content language is used when // saving an entity. This is OK for the new draft revision, but it does not // work when publishing a revision. Therefore, ensure that // title_active_language() reflects the original language of the entity. // Without this, title may overwrite {$title}_field in the original language // with the contents of the legacy field. // @todo: this might not be necessary after one or both of these patches // lands: // https://www.drupal.org/node/2267251 // https://www.drupal.org/node/2098097 if (module_exists('title')) { $entity = entity_load_single($type, $id); $langcode = entity_language($type, $entity); title_active_language($langcode); } $revision = entity_revision_load($type, $vid); // Publishing a revision sometimes happens within hook_entity_update(). When // we do that, set $entity->original to the entity we're in the process of // saving. i.e. the draft we're in the process of creating and need to // replace with the published version again. $revision->is_draft_revision = FALSE; return $this->saveRevisionAsNew($type, $revision); } /** * Save a revision as new. * * @param $type * The entity type. * @param $revision * An entity object. * * @return The newly saved revision. */ public function saveRevisionAsNew($type, $revision) { list($id) = entity_extract_ids($type, $revision); entity_get_controller($type)->resetCache(); $original = entity_load_single($type, $id); $revision->original = $original; // @todo: entity API function? $revision->revision = TRUE; $revision->is_new_revision = TRUE; $revision->default_revision = TRUE; entity_save($type, $revision); return $revision; } /** * Publish revisions previously set with setRevisionToBePublished(). */ public function restorePublishedRevisions() { $delete_old_revisions = variable_get('drafty_delete_old_revisions', FALSE); $delete_with_cron = variable_get('drafty_delete_with_cron', TRUE); $queue = NULL; $operations = array(); foreach ($this->revisionsToPublish as $type => $value) { foreach ($value as $id => $vid) { unset($this->revisionsToPublish[$type][$id]); $published_revision = $this->publishRevision($type, $id, $vid); // Now that the revision is deleted, there are two identical copies of // the revision in the system. The original 'draft' revision and the // newly saved published revision. if ($delete_old_revisions) { list (, $replaced_by) = entity_extract_ids($type, $published_revision); // Deletion must be done on a different request thread, or any other // hooks called for the revision-to-be-deleted will fail. $drafty_queue_item = array( 'entity_type' => $type, 'entity_id' => $id, 'revision_id' => $vid, 'replaced_by' => $replaced_by, ); if ($delete_with_cron) { if (empty($queue)) { $queue = DrupalQueue::get('drafty_revision_delete'); } $queue->createItem($drafty_queue_item); } else { $operations[] = array( 'drafty_queue_delete_revision', array($drafty_queue_item), ); } } // @todo: when restoring a published revision, should the revision // timestamp be set to the old value? } } if (!empty($operations)) { $batch = array( 'operations' => $operations, 'finished' => 'drafty_delete_batch_finished', ); batch_set($batch); } } }