'. t("Drupal's default content locking strategy is optimistic, that is, two users may start to edit the same content and the one who is hitting the save button first wins the race, while the other is displayed a message stating this content has been modified by another user, changes cannot be saved. Depending on the number of editors in your organization this might not be an acceptable solution.") .'
';
$output .= ''. t('The Checkout module implements pessimistic locking, which means that content will be exclusively locked whenever a user starts editing it. The lock will be automatically released when the user submits the form or navigates away from the edit page.') .'
';
$output .= ''. t('Users may also permanently lock content, to prevent others from editing it. Content locks that have been "forgotten" can be automatically released after a configurable time span.') .'
';
return $output;
case 'admin/content/node/checkout':
return ''. t('Below is a list of all locked documents. Click on check in to release a lock.') .'
';
case 'user/%user/checkout':
return ''. t('Below is a list of all documents locked by you. Click on check in to release a lock.') .'
';
}
}
/**
* Implementation of hook_init().
*/
function checkout_init() {
global $user;
if ($user->uid && user_access('check out documents')) {
// Avoid AJAX requests unlocking a node. This header is automatically set
// when doing AJAX requests through jQuery.
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || $_SERVER['HTTP_X_REQUESTED_WITH'] != 'XMLHttpRequest') {
checkout_handle_request($user->uid);
}
}
}
/**
* Implementation of hook_menu().
*/
function checkout_menu() {
$items['admin/content/node/checkout'] = array(
'title' => 'Locked documents',
'page callback' => 'checkout_overview',
'access callback' => 'user_access',
'access arguments' => array('administer checked out documents'),
'weight' => 5,
'type' => MENU_LOCAL_TASK,
);
$items['admin/content/node/checkout/release'] = array(
'page callback' => 'checkout_release_item',
'page arguments' => array(5, NULL),
'access arguments' => array('administer checked out documents'),
'type' => MENU_CALLBACK,
);
$items['user/%user/checkout'] = array(
'title' => 'Locked documents',
'page callback' => 'checkout_overview',
'page arguments' => array(1),
'access callback' => 'user_access',
'access arguments' => array('check out documents'),
'weight' => 5,
'type' => MENU_LOCAL_TASK
);
$items['user/%user/checkout/release'] = array(
'page callback' => 'checkout_release_item',
'page arguments' => array(4, 1),
'access arguments' => array('check out documents'),
'type' => MENU_CALLBACK
);
return $items;
}
/**
* Implementation of hook_form_alter().
*/
function checkout_form_alter(&$form, $form_state, $form_id) {
if (isset($form['type']) && $form['type']['#value'] .'_node_form' == $form_id) {
if (user_access('check out documents') && user_access('keep documents checked out')) {
$form['checkout'] = array(
'#type' => 'checkbox',
'#title' => t('Keep document locked'),
'#return_value' => 1,
'#weight' => 21, // Place immediately after log message.
'#default_value' => FALSE,
'#description' => t('Check this box if you want to keep this document locked in your name after submitting it.'),
);
}
}
else if ($form_id == 'node_configure') {
// Make sure our element appears before the submit buttons.
$form['buttons']['#weight'] = 10;
$period = array(0 => t('Disabled')) + drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval');
$form['checkout_clear'] = array(
'#type' => 'select',
'#title' => t('Automatic check-in'),
'#default_value' => variable_get('checkout_clear', 0),
'#options' => $period,
'#description' => t('The period after which locked documents will be automatically released.'),
);
}
}
/**
* Implementation of hook_nodeapi().
*/
function checkout_nodeapi(&$node, $op, $teaser, $page) {
global $user;
switch ($op) {
case 'validate':
if (isset($node->nid) && user_access('check out documents')) {
// Existing node. Check if we still own the lock.
if ($lock = checkout_fetch_lock($node->nid)) {
if ($lock->uid != $user->uid) {
// Lock is no longer ours.
form_set_error('changed', t('Your lock has been removed!') .'
'. checkout_lock_owner($lock) .'
'. t('You can still save the content if this user aborts the edit operation without saving changes.'));
}
}
else {
// Node is not locked. Try to re-lock if node is unchanged.
if (node_last_changed($node->nid) > $node->changed || !checkout_node($node->nid, $user->uid)) {
form_set_error('alsochanged', t('Your lock has been removed due to inactivity or by an administrator. Failed to regain the lock since the document has been changed since.'));
}
}
}
break;
case 'insert':
case 'update':
if (!empty($node->checkout)) {
checkout_persistent($node->nid);
}
else if ($op == 'update') {
checkout_release($node->nid, $user->uid, TRUE);
}
break;
case 'delete':
checkout_release($node->nid, NULL, TRUE);
break;
}
}
/**
* Implementation of hook_cron().
*
* Release nodes that have been locked longer than the configured period.
*/
function checkout_cron() {
$checkout_clear = variable_get('checkout_clear', 0);
if ($checkout_clear > 0) {
$result = db_query('DELETE FROM {checkout} WHERE timestamp < %d', time() - $checkout_clear);
if ($num = db_affected_rows($result)) {
$period = format_interval($checkout_clear);
drupal_set_message(format_plural($num, 'Released one document locked for more than @period.', 'Released @count documents locked for more than @period.', array('@period' => $period)));
watchdog('checkout', 'Released @count document(s) locked for more than @period.', array('@count' => $num, '@period' => $period));
}
}
}
/**
* Handle node locking.
*
* When landing on a node edit page the current node needs to be locked.
* When coming from an edit page the previous node needs to be unlocked.
*
* @param $uid
* The user id to (un)lock nodes for.
*/
function checkout_handle_request($uid) {
global $base_path;
// Build referer path
$referer_uri = parse_url(referer_uri());
if (variable_get('clean_url', 0)) {
$referer = substr($referer_uri['path'], strlen($base_path));
}
else {
$vars = array();
if (isset($referer_uri['query'])) {
parse_str($referer_uri['query'], $vars);
}
$referer = isset($vars['q']) ? $vars['q'] : '';
}
if ($referer = rtrim($referer, '/')) {
$referer = drupal_get_normal_path($referer);
}
// If refering and current paths match we can abort, since there can't be any
// locking action involved.
if ($_GET['q'] == $referer) {
return;
}
// Otherwise try to extract nid from path.
$previous_nid = checkout_get_nid($referer);
$current_nid = checkout_get_nid($_GET['q']);
// Check whether to release a previously edited node.
if ($previous_nid && (!$current_nid || $current_nid != $previous_nid)) {
checkout_release($previous_nid, $uid);
}
// Check whether to lock the current node.
if ($current_nid && (!$previous_nid || $previous_nid != $current_nid)) {
// Try to lock the node.
if (!checkout_node($current_nid, $uid)) {
// Node already locked: send back to refering page.
drupal_goto(referer_uri());
}
}
}
/**
* Extract the node id from a node edit path.
*
* @param $path
* The path to match.
* @return
* The node id extracted from the path.
*/
function checkout_get_nid($path) {
static $regexp;
if (!isset($regexp)) {
$patterns = variable_get('checkout_edit_paths', "edit\nrevisions\nrevisions/*\noutline\nclassify");
$regexp = '@^node/(\d+)/(?:'. preg_replace(array('/(\r\n?|\n)/', '/\\\\\*/'), array('|', '.*'), preg_quote($patterns, '@')) .')$@';
}
if (preg_match($regexp, $path, $match)) {
return $match[1];
}
return FALSE;
}
/**
* Fetch the lock for a node.
*
* @param $nid
* A node id.
* @return
* The lock for the node. FALSE, if the document is not locked.
*/
function checkout_fetch_lock($nid) {
return db_fetch_object(db_query("SELECT c.*, u.name FROM {checkout} c LEFT JOIN {users} u ON u.uid = c.uid WHERE c.nid = %d", $nid));
}
/**
* Tell who has locked node.
*
* @param $lock
* The lock for a node.
* @return
* String with the message.
*/
function checkout_lock_owner($lock) {
$username = theme('username', $lock);
$date = format_date($lock->timestamp, 'medium');
return t('This document is locked for editing by !name since @date.', array('!name' => $username, '@date' => $date));
}
/**
* Try to lock a document for editing.
*
* @param $nid
* A node id.
* @param $uid
* The user id to lock the node for.
* @return
* FALSE, if a document has already been locked by someone else.
*/
function checkout_node($nid, $uid) {
if ($lock = checkout_fetch_lock($nid)) {
// Node is already locked.
// Deny editing this node even if the node is locked by the same user.
// The only exception to this rule is when the user had previously acquired
// a persistent lock.
if ($lock->uid != $uid || !$lock->persistent) {
$message = checkout_lock_owner($lock);
if ($lock->uid == $uid) {
$url = "user/$uid/checkout/release/$nid";
}
else if (user_access('administer checked out documents')) {
$url = "admin/content/node/checkout/release/$nid";
}
if (isset($url)) {
$message .= '
'. t('Click here to check back in now.', array('!release-url' => url($url, array('query' => 'destination='. $_GET['q']))));
}
drupal_set_message($message, 'error');
return FALSE;
}
}
else {
// Lock node.
db_query("INSERT INTO {checkout} (nid, uid, timestamp) VALUES (%d, %d, %d)", $nid, $uid, time());
drupal_set_message(t('This document is now locked against simultaneous editing. It will unlock when you navigate elsewhere.'));
}
return TRUE;
}
/**
* Set a persistent document lock.
*
* @param $nid
* The node id to lock persistently.
*/
function checkout_persistent($nid) {
db_query("UPDATE {checkout} SET persistent = 1 WHERE nid = %d", $nid);
}
/**
* Release a locked node.
*
* @param $nid
* The node id to release the edit lock for.
* @param $uid
* If set, verify that a lock belongs to this user prior to release.
* @param $break
* Break persistent locks.
*/
function checkout_release($nid, $uid = NULL, $break = FALSE) {
$add_sql = '';
$args = array($nid);
if (isset($uid)) {
$add_sql = " AND uid = %d";
$args[] = $uid;
}
if ($break || !db_result(db_query_range("SELECT persistent FROM {checkout} WHERE nid = %d". $add_sql, $args, 0, 1))) {
db_query("DELETE FROM {checkout} WHERE nid = %d". $add_sql, $args);
}
}
/**
* Build an overview of locked documents.
*
* @param $account
* A user object.
*/
function checkout_overview($account = NULL) {
$header = array(array('data' => t('Title'), 'field' => 'n.title', 'sort' => 'asc'));
if (!$account) {
$header[] = array('data' => t('Username'), 'field' => 'u.name');
$uid = NULL;
}
else {
$uid = $account->uid;
}
$header[] = array('data' => t('Locked since'), 'field' => 'c.timestamp');
$header[] = array('data' => t('Persistent lock'), 'field' => 'c.persistent');
$header[] = t('Operations');
$rows = array();
$add_sql = $uid ? " WHERE c.uid = %d" : '';
$result = pager_query('SELECT c.*, n.title, u.name FROM {checkout} c INNER JOIN {node} n ON n.nid = c.nid INNER JOIN {users} u ON u.uid = c.uid'. $add_sql . tablesort_sql($header), 50, 0, NULL, $uid);
$url = $uid ? "user/$uid/checkout/release" : 'admin/content/node/checkout/release';
while ($data = db_fetch_object($result)) {
$row = array();
$row[] = l($data->title, "node/$data->nid");
if (!$uid) {
$row[] = theme('username', user_load(array('uid' => $data->uid)));
}
$row[] = format_date($data->timestamp, 'small');
$row[] = $data->persistent ? t('yes') : '—';
$row[] = l(t('check in'), "$url/$data->nid");
$rows[] = $row;
}
$output = theme('table', $header, $rows, array('id' => 'checkout'));
if (!$rows) {
$output .= t('No locked documents.');
}
else if ($pager = theme('pager', array(), 50, 0)) {
$output .= $pager;
}
return $output;
}
/**
* Menu callback; release a locked node for all users or a specific user.
*
* @param $nid
* A node id.
* @param $account
* A user object.
* @return
* This function will execute a redirect and doesn't return.
*/
function checkout_release_item($nid, $account = NULL) {
checkout_release($nid, $account ? $account->uid : NULL, TRUE);
drupal_set_message(t('The editing lock has been released.'));
drupal_goto($account ? "user/$account->uid/checkout" : 'admin/content/node/checkout');
}