Index: includes/batch.inc =================================================================== RCS file: includes/batch.inc diff -N includes/batch.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/batch.inc 21 Mar 2007 04:37:40 -0000 @@ -0,0 +1,290 @@ + t('batch_title'), + * 'init_message' => t('batch_init_message'), + * 'error_message' => t('batch_message_error'), + * 'progress_message' => t('Remaining @remaining of @total.'), + * 'finished_callback' => 'my_finished_callback', + * 'operations' => array( + * array('callback' => 'my_func_1', 'arguments' => array('foo', 'bar')), + * array('callback' => 'my_func_2', 'arguments' => array('foo2', 'bar2', 'baz2')), + * ... + * ); + * ); + * return drupal_set_batch($batch); + * } + * + */ + + +// TODO : this part is awkward / needs work... +function drupal_set_batch($batch, $go = FALSE) { + static $_batch_id; + + // TODO : we could accept several batches and have them processed sequentially ? + + // We accept the batch if none has been set before, or if the batch has already been + // registered and is being altered (in drupal_submit_form - or in a batch_alter hook ?) + if ($batch) { + // if it's the first batch, accept it + if (!isset($_batch_id)) { + $_batch_id = time(); // md5(mt_rand()) ? + $batch += array( + 'batch_id' => $_batch_id, + 'title' => t('Processing'), + 'init_message' => t('Initializing...'), + 'progress_message' => t('Remaining @remaining of @total.'), + 'path' => 'batch', + ); + } + + // If the batch has the correct batch_id, accept it + if (isset($batch['batch_id']) && $batch['batch_id'] == $_batch_id) { + if ($go) { + // TODO : delete first ? + db_query("INSERT INTO {batch} (bid, batch) VALUES (%d, '%s')", $batch['batch_id'], serialize($batch)); + drupal_goto($batch['path'], 'batch_id='.$batch['batch_id']); + } + return $batch; + } + } +} + +// page callback for the regular '/batch' path +// update.php and co would call directly _batch_page() +function batch_page() { + $output = _batch_page(); + print theme('page', $output, FALSE); +} + +function _batch_page() { + global $_batch; + + if (isset($_REQUEST['batch_id']) && $data = db_result(db_query("SELECT batch FROM {batch} WHERE bid = %d", $_REQUEST['batch_id']))) { + $_batch = unserialize($data); + } + else { + // TODO : no good for update.php... + return drupal_not_found(); + } + + register_shutdown_function('batch_shutdown'); + + $op = isset($_REQUEST['op']) ? $_REQUEST['op'] : ''; + switch ($op) { + case 'finished': + $output = _batch_finished(TRUE); + break; + + // TODO : this is update.php specific (link in the error message) + case 'error': + $output = _batch_finished(FALSE); + break; + + case 'do': + $output = _batch_do(); + break; + + case 'do_nojs': + $output = _batch_progress_page_nojs(); + break; + + default: + $output = _batch_prepare(); + break; + } + + return $output; +} + +function _batch_prepare() { + global $_batch; + + // TODO : hook_batch_alter ? + + if (empty($_batch['operations'])) { + return _batch_finished(TRUE); // error or no error ? + } + + // TODO : additional validation ? + + $_batch['total'] = count($_batch['operations']); + $_batch['results'] = array(); + + // NOTE : nojs if the user did not visit any js enabled page during his browser session ? + if (isset($_COOKIE['has_js']) && $_COOKIE['has_js']) { + return _batch_progress_page_js(); + } + else { + return _batch_progress_page_nojs(); + } +} + +function _batch_progress_page_js() { + global $_batch; + + // TODO : is there a way to have this page not appear in the browser's history ? + drupal_set_title($_batch['title']); + + // Prevent browser from using cached drupal.js or update.js + drupal_add_js('misc/progress.js', 'core', 'header', FALSE, TRUE); + $js_setting = array( + 'batch' => array( + 'errorMessage' => $_batch['error_message'], + 'initMessage' => $_batch['init_message'], + // TODO : account for paths like 'update.php' + 'uri' => url($_batch['path'], array('query' => array('batch_id' => $_batch['batch_id']))), + ), + ); + drupal_add_js($js_setting, 'setting'); + drupal_add_js('misc/batch.js', 'core', 'header', FALSE, TRUE); + + $output = '
'; + return $output; +} + +function _batch_do() { + // HTTP Post required + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + drupal_set_message('HTTP Post is required.', 'error'); + drupal_set_title('Error'); + return ''; + } + + list($percentage, $message) = _batch_process(); + + drupal_set_header('Content-Type: text/plain; charset=utf-8'); + print drupal_to_js(array('status' => TRUE, 'percentage' => $percentage, 'message' => $message)); + exit(); +} + +function _batch_progress_page_nojs() { + global $_batch; + + drupal_set_title($_batch['title']); + + $new_op = 'do_nojs'; + + // TODO : sort of is hackish ? + if (!isset($_batch['running'])) { + // This is the first page so return some output immediately. + $percentage = 0; + $message = $_batch['init_message']; + $_batch['running'] = TRUE; + } + else { + // This is one of the later requests: do some updates first. + + // Error handling: if PHP dies due to a fatal error (e.g. non-existant + // function), it will output whatever is in the output buffer, + // followed by the error message. + ob_start(); + $fallback = $_batch['error_message_no_js']; + $fallback = theme('maintenance_page', $fallback, FALSE); + + // We strip the end of the page using a marker in the template, so any + // additional HTML output by PHP shows up inside the page rather than + // below it. While this causes invalid HTML, the same would be true if + // we didn't, as content is not allowed to appear after anyway. + list($fallback) = explode('', $fallback); + print $fallback; + + list($percentage, $message) = _batch_process($_batch); + if ($percentage == 100) { + $new_op = 'finished'; + } + + // Updates successful; remove fallback + ob_end_clean(); + } + $url = url($_batch['path'], array('query' => array('batch_id' => $_batch['batch_id'], 'op' => $new_op))); + drupal_set_html_head(''); + $output = theme('progress_bar', $percentage, $message); + return $output; + + // NOTE : update.php has the following : + // Note: do not output drupal_set_message()s until the summary page. + //print theme('maintenance_page', $output, FALSE); + //return NULL; +} + +function _batch_process() { + global $_batch; + + while (isset($_batch['operations']) && $op = reset($_batch['operations'])) { + $key = key($_batch['operations']); + $op_finished = 1; + if (is_array($op) && isset($op['callback']) && function_exists($op['callback'])) { + $arg = $op['arguments']; + $additions = array(&$_batch['results'], &$op_finished); + call_user_func_array($op['callback'], array_merge($arg, $additions)); + $task_message = isset($op['task']) ? $op['task'] : ''; + } + if ($op_finished == 1) { + unset($_batch['operations'][$key]); + $op_finished = 0; + } + if (timer_read('page') > 1000) { + break; + } + } + + $remaining = count($_batch['operations']); + $total = $_batch['total']; + $current = $total - $remaining + $op_finished; + $percentage = floor($current / $total * 100); + $values = array( + '@current' => floor($current), + '@remaining' => $remaining, + '@total' => $total, + '@percent' => $percentage + ); + $progress_message = strtr($_batch['progress_message'], $values); + + $message = $progress_message .'
'; + // NOTE : $task_message will be used by update.php ('Updating foo.module') + $message.= $task_message ? $task_message : ' '; + + return array($percentage, $message); +} + +// TODO : the end of the processing is yet to be clarified : +// redirection / result display / what if chained batches ? +function _batch_finished($success) { + global $_batch; + + // Set the flag for batch_shutdown() to clean the db record. + $_batch['finished'] = TRUE; + + // Call the 'finished' callback + if (isset($_batch['finished_callback']) && function_exists($_batch['finished_callback'])) { + $return = $_batch['finished_callback']($_batch, $success, $_batch['results']); + } + // Perform the remaining _submit callbacks if any + if (isset($_batch['post_process'])) { + global $form_values; + $form_values = $_batch['post_process']['form_values']; + $redirect = drupal_submit_form($_batch['post_process']['form_id'], $_batch['post_process']['form']); + // TODO : We have to mimick the end of drupal_process_form + } + + return $return; +} + +function batch_shutdown() { + global $_batch; + if (isset($_batch['finished']) && $_batch['finished']) { + db_query("DELETE FROM {batch} WHERE bid = %d", $_batch['batch_id']); + } + else { + db_query("UPDATE {batch} SET batch = '%s' WHERE bid = %d", serialize($_batch), $_batch['batch_id']); + } +} + +// TODO : upgrade for system.install +// TODO : remove old / stale batches from db on cron Index: includes/common.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/common.inc,v retrieving revision 1.619 diff -u -p -r1.619 common.inc --- includes/common.inc 8 Mar 2007 19:33:55 -0000 1.619 +++ includes/common.inc 21 Mar 2007 04:37:40 -0000 @@ -1898,6 +1898,7 @@ function _drupal_bootstrap_full() { require_once './includes/unicode.inc'; require_once './includes/image.inc'; require_once './includes/form.inc'; + require_once './includes/batch.inc'; // Set the Drupal custom error handler. set_error_handler('error_handler'); // Emit the correct charset HTTP header. Index: includes/form.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/form.inc,v retrieving revision 1.182 diff -u -p -r1.182 form.inc --- includes/form.inc 17 Mar 2007 18:30:14 -0000 1.182 +++ includes/form.inc 21 Mar 2007 04:37:41 -0000 @@ -418,13 +418,27 @@ function drupal_submit_form($form_id, $f $goto = NULL; if (isset($form['#submit'])) { - foreach ($form['#submit'] as $function => $args) { + while (list($function, $args) = each($form['#submit'])) { + array_shift($form['#submit']); if (function_exists($function)) { $args = array_merge($default_args, (array) $args); // Since we can only redirect to one page, only the last redirect // will work. $redirect = call_user_func_array($function, $args); $submitted = TRUE; + if (is_array($redirect) && isset($redirect['batch_id'])) { + // Save remaining _submit callbacks + // TODO : or append all the callbacks at the end of the batch ? + if (!empty($form['#submit'])) { + $redirect['post_process'] = array( + 'form' => $form, + 'form_id' => $form_id, + 'form_values' => $form_values + ); + } + drupal_set_batch($redirect, TRUE); + break; + } if (isset($redirect)) { $goto = $redirect; } Index: includes/theme.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/theme.inc,v retrieving revision 1.343 diff -u -p -r1.343 theme.inc --- includes/theme.inc 2 Mar 2007 09:40:13 -0000 1.343 +++ includes/theme.inc 21 Mar 2007 04:37:42 -0000 @@ -1115,8 +1115,8 @@ function theme_username($object) { function theme_progress_bar($percent, $message) { $output = '
'; $output .= '
'. $percent .'%
'; - $output .= '
'. $message .'
'; $output .= '
'; + $output .= '
'. $message .'
'; $output .= '
'; return $output; Index: misc/batch.js =================================================================== RCS file: misc/batch.js diff -N misc/batch.js --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ misc/batch.js 21 Mar 2007 04:37:42 -0000 @@ -0,0 +1,32 @@ +if (Drupal.jsEnabled) { + $(document).ready(function() { +// $('#edit-has-js').each(function() { this.value = 1; }); + $('#progress').each(function () { + var holder = this; + var uri = Drupal.settings.batch.uri; + var initMessage = Drupal.settings.batch.initMessage; + var errorMessage = Drupal.settings.batch.errorMessage; + + // Success: redirect to the summary. + var updateCallback = function (progress, status, pb) { + if (progress == 100) { + pb.stopMonitoring(); + window.location = uri+'&op=finished'; + } + } + + var errorCallback = function (pb) { + var div = document.createElement('p'); + div.className = 'error'; + $(div).html(errorMessage); + $(holder).prepend(div); + $('#wait').hide(); + } + + var progress = new Drupal.progressBar('updateprogress', updateCallback, "POST", errorCallback); + progress.setProgress(-1, initMessage); + $(holder).append(progress.element); + progress.startMonitoring(uri+'&op=do', 10); + }); + }); +} \ No newline at end of file Index: misc/drupal.js =================================================================== RCS file: /cvs/drupal/drupal/misc/drupal.js,v retrieving revision 1.29 diff -u -p -r1.29 drupal.js --- misc/drupal.js 14 Oct 2006 02:39:48 -0000 1.29 +++ misc/drupal.js 21 Mar 2007 04:37:42 -0000 @@ -200,7 +200,98 @@ Drupal.encodeURIComponent = function (it return uri.indexOf('?q=') ? item : item.replace('%26', '%2526').replace('%23', '%2523'); }; -// Global Killswitch on the element +/** + * Cookie plugin + * + * Copyright (c) 2006 Klaus Hartl (stilbuero.de) + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + * + */ + +/** + * Create a cookie with the given name and value and other optional parameters. + * + * @example $.cookie('the_cookie', 'the_value'); + * @desc Set the value of a cookie. + * @example $.cookie('the_cookie', 'the_value', {expires: 7, path: '/', domain: 'jquery.com', secure: true}); + * @desc Create a cookie with all available options. + * @example $.cookie('the_cookie', 'the_value'); + * @desc Create a session cookie. + * @example $.cookie('the_cookie', '', {expires: -1}); + * @desc Delete a cookie by setting a date in the past. + * + * @param String name The name of the cookie. + * @param String value The value of the cookie. + * @param Object options An object literal containing key/value pairs to provide optional cookie attributes. + * @option Number|Date expires Either an integer specifying the expiration date from now on in days or a Date object. + * If a negative value is specified (e.g. a date in the past), the cookie will be deleted. + * If set to null or omitted, the cookie will be a session cookie and will not be retained + * when the the browser exits. + * @option String path The value of the path atribute of the cookie (default: path of page that created the cookie). + * @option String domain The value of the domain attribute of the cookie (default: domain of page that created the cookie). + * @option Boolean secure If true, the secure attribute of the cookie will be set and the cookie transmission will + * require a secure protocol (like HTTPS). + * @type undefined + * + * @name $.cookie + * @cat Plugins/Cookie + * @author Klaus Hartl/klaus.hartl@stilbuero.de + */ + +/** + * Get the value of a cookie with the given name. + * + * @example $.cookie('the_cookie'); + * @desc Get the value of a cookie. + * + * @param String name The name of the cookie. + * @return The value of the cookie. + * @type String + * + * @name $.cookie + * @cat Plugins/Cookie + * @author Klaus Hartl/klaus.hartl@stilbuero.de + */ +jQuery.cookie = function(name, value, options) { + if (typeof value != 'undefined') { // name and value given, set cookie + options = options || {}; + var expires = ''; + if (options.expires && (typeof options.expires == 'number' || options.expires.toGMTString)) { + var date; + if (typeof options.expires == 'number') { + date = new Date(); + date.setTime(date.getTime() + (options.expires * 24 * 60 * 60 * 1000)); + } else { + date = options.expires; + } + expires = '; expires=' + date.toGMTString(); // use expires attribute, max-age is not supported by IE + } + var path = options.path ? '; path=' + options.path : ''; + var domain = options.domain ? '; domain=' + options.domain : ''; + var secure = options.secure ? '; secure' : ''; + document.cookie = [name, '=', encodeURIComponent(value), expires, path, domain, secure].join(''); + } else { // only name given, get cookie + var cookieValue = null; + if (document.cookie && document.cookie != '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) == (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } +}; + if (Drupal.jsEnabled) { + // Global Killswitch on the element document.documentElement.className = 'js'; + // Set "Javascript enabled" cookie + $.cookie('has_js', 1); } Index: misc/progress.js =================================================================== RCS file: /cvs/drupal/drupal/misc/progress.js,v retrieving revision 1.14 diff -u -p -r1.14 progress.js --- misc/progress.js 14 Dec 2006 14:21:36 -0000 1.14 +++ misc/progress.js 21 Mar 2007 04:37:42 -0000 @@ -20,9 +20,9 @@ Drupal.progressBar = function (id, updat this.element = document.createElement('div'); this.element.id = id; this.element.className = 'progress'; - $(this.element).html('
'+ - '
 
'+ - '
'); + $(this.element).html('
'+ + '
'+ + '
 
'); } /** Index: modules/system/system.install =================================================================== RCS file: /cvs/drupal/drupal/modules/system/system.install,v retrieving revision 1.85 diff -u -p -r1.85 system.install --- modules/system/system.install 19 Mar 2007 01:13:28 -0000 1.85 +++ modules/system/system.install 21 Mar 2007 04:37:42 -0000 @@ -195,6 +195,12 @@ function system_install() { UNIQUE KEY authname (authname) ) /*!40100 DEFAULT CHARACTER SET UTF8 */ "); + db_query("CREATE TABLE {batch} ( + bid int(11) NOT NULL, + batch longtext, + PRIMARY KEY (bid) + ) /*!40100 DEFAULT CHARACTER SET UTF8 */ "); + db_query("CREATE TABLE {blocks} ( module varchar(64) DEFAULT '' NOT NULL, delta varchar(32) NOT NULL default '0', @@ -680,6 +686,12 @@ function system_install() { UNIQUE (authname) )"); + db_query("CREATE TABLE {batch} ( + bid int NOT NULL default '0', + batch text, + PRIMARY KEY (bid) + )"); + db_query("CREATE TABLE {blocks} ( module varchar(64) DEFAULT '' NOT NULL, delta varchar(32) NOT NULL default '0', Index: modules/system/system.module =================================================================== RCS file: /cvs/drupal/drupal/modules/system/system.module,v retrieving revision 1.457 diff -u -p -r1.457 system.module --- modules/system/system.module 17 Mar 2007 18:30:14 -0000 1.457 +++ modules/system/system.module 21 Mar 2007 04:37:44 -0000 @@ -286,6 +286,12 @@ function system_menu() { 'page callback' => 'system_sql', 'type' => MENU_CALLBACK, ); + // Batch operations + $items['batch'] = array( + 'page callback' => 'batch_page', + 'access callback' => TRUE, + 'type' => MENU_CALLBACK, + ); return $items; }