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	12 Apr 2007 21:29:21 -0000
@@ -0,0 +1,322 @@
+<?php
+// $Id$
+
+/**
+ * @file Batch processing API for processes to run in multiple HTTP requests
+ */
+
+/**
+ * Stores a batch array in the database and returns the extended structure.
+ *
+ * Example:
+ * @code
+ * function my_form_submit($form_id, $form_values) {
+ *   $batch = array(
+ *     'title' => t('Descriptive batch title'),
+ *     'init_message' => t('Initial message'),
+ *     'error_message' => t('Error message'),
+ *     // @current, @remaining, @total and @percent can be used here
+ *     '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);
+ * }
+ * @endcode
+ *
+ * @param $batch
+ *   Associative array with initial batch options.
+ * @param $execute
+ *   Boolean to run the batch (TRUE) or just store the options (FALSE).
+ * @return
+ *   An extended batch array.
+ */
+function drupal_set_batch($batch, $execute = FALSE) {
+  static $_batch_id = NULL;
+
+  // We accept the batch if none has been set before in this HTTP request,
+  // or if the batch has already been registered and is being altered (in
+  // drupal_submit_form)
+  if (is_array($batch)) {
+    if (!isset($_batch_id)) {
+      $_batch_id = time();
+      $batch += array(
+        'batch_id'         => $_batch_id,
+        'title'            => t('Processing'),
+        'init_message'     => t('Initializing...'),
+        'progress_message' => t('Remaining @remaining of @total.'),
+        'path'             => 'batch',
+      );
+    }
+
+    // New batch or replacing previously returned batch data
+    if (isset($batch['batch_id']) && $batch['batch_id'] == $_batch_id) {
+      if ($execute) {
+        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;
+    }
+  }
+}
+
+/**
+ * Default page callback and state based dispatcher for batches.
+ */
+function batch_page() {
+  global $_batch;
+
+  // Grab the stored batch operations
+  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: not good for update.php
+    return drupal_not_found();
+  }
+
+  // Register database update for end of processing
+  register_shutdown_function('batch_shutdown');
+
+  $op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
+  switch ($op) {
+    // Finish with success.
+    case 'finished':
+      $output = _batch_finished(TRUE);
+      break;
+
+    // Finish with error.
+    // @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;
+}
+
+/**
+ * Perform initial preparation for running a batch, choose between the
+ * JS and non-JS version.
+ */
+function _batch_prepare() {
+  global $_batch;
+
+  // Return with success if we have no operations.
+  if (empty($_batch['operations'])) {
+    return _batch_finished(TRUE);
+  }
+
+  $_batch['total'] = count($_batch['operations']);
+  $_batch['results'] = array();
+
+  // drupal.js remembers js enabled users via the has_js cookie.
+  if (isset($_COOKIE['has_js']) && $_COOKIE['has_js']) {
+    return _batch_progress_page_js();
+  }
+  else {
+    return _batch_progress_page_nojs();
+  }
+}
+
+/**
+ * Batch processing page with JavaScript support.
+ */
+function _batch_progress_page_js() {
+  global $_batch;
+
+  drupal_set_title($_batch['title']);
+
+  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 = '<div id="progress"></div>';
+  return $output;
+}
+
+/**
+ * Inform the browser about progress made in the batch.
+ */
+function _batch_do() {
+  if ($_SERVER['REQUEST_METHOD'] != 'POST') {
+    drupal_set_message(t('HTTP POST is required.'), 'error');
+    drupal_set_title(t('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();
+}
+
+/**
+ * Batch processing page without JavaScript support.
+ */
+function _batch_progress_page_nojs() {
+  global $_batch;
+
+  drupal_set_title($_batch['title']);
+
+  $new_op = 'do_nojs';
+
+  // This is the first page so return some output immediately.
+  if (!isset($_batch['running'])) {
+    $percentage = 0;
+    $message = $_batch['init_message'];
+    $_batch['running'] = TRUE;
+  }
+
+  // This is one of the later requests: do some updates first.
+  else {
+
+    // 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 </html> anyway.
+    list($fallback) = explode('<!--partial-->', $fallback);
+    print $fallback;
+
+    list($percentage, $message) = _batch_process($_batch);
+    if ($percentage == 100) {
+      $new_op = 'finished';
+    }
+
+    // Updates successful; remove fallback
+    ob_end_clean();
+  }
+  // @todo: account for update.php path
+  $url = url($_batch['path'], array('query' => array('batch_id' => $_batch['batch_id'], 'op' => $new_op)));
+  drupal_set_html_head('<meta http-equiv="Refresh" content="0; URL='. $url .'">');
+  $output = theme('progress_bar', $percentage, $message);
+  return $output;
+
+  // @todo: 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;
+}
+
+/**
+ * Process as many updates as possible in this HTTP request.
+ */
+function _batch_process() {
+  global $_batch;
+
+  // While/list/each allows us to traverse a shirinking array.
+  while (list($key, $op) = each($_batch['operations'])) {
+    $op_finished = 1;
+    if (is_array($op) && isset($op['callback']) && function_exists($op['callback'])) {
+      $additions = array(&$_batch['results'], &$op_finished);
+      call_user_func_array($op['callback'], array_merge($op['arguments'], $additions));
+      $task_message = isset($op['task']) ? $op['task'] : '';
+    }
+    // Remove operation if finished
+    if ($op_finished == 1) {
+      unset($_batch['operations'][$key]);
+      $op_finished = 0;
+    }
+    // Skip until next HTTP request if too much time passed
+    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 .'<br/>';
+  // @todo: $task_message will be used by update.php ('Updating foo.module')
+  $message.= $task_message ? $task_message : '&nbsp';
+
+  return array($percentage, $message);
+}
+
+/**
+ * Display a result page when processing ends.
+ *
+ * @param $success
+ *   A boolean flag on whether processing was successful.
+ * @return
+ *   Page text to display on result page.
+ */
+function _batch_finished($success) {
+  global $_batch;
+
+  // Set the flag for batch_shutdown() to clean the db record.
+  $_batch['finished'] = TRUE;
+
+  // Determine page contents to output to user
+  $output = t('Batch processing done.');
+  if (isset($_batch['finished_callback']) && function_exists($_batch['finished_callback'])) {
+    $output = $_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 $output;
+}
+
+/**
+ * Ensures that the batch data is updated in the database on shutdown.
+ */
+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.627
diff -u -p -r1.627 common.inc
--- includes/common.inc	6 Apr 2007 13:27:20 -0000	1.627
+++ includes/common.inc	12 Apr 2007 21:29:22 -0000
@@ -1877,6 +1877,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('drupal_error_handler');
   // Emit the correct charset HTTP header.
Index: includes/form.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/form.inc,v
retrieving revision 1.187
diff -u -p -r1.187 form.inc
--- includes/form.inc	9 Apr 2007 13:58:02 -0000	1.187
+++ includes/form.inc	12 Apr 2007 21:29:23 -0000
@@ -415,15 +415,35 @@ function drupal_submit_form($form_id, $f
   $goto = NULL;
 
   if (isset($form['#submit'])) {
-    foreach ($form['#submit'] as $function => $args) {
+    // While/list/each allows us to traverse a shrinking array
+    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 (isset($redirect)) {
-          $goto = $redirect;
+          // We got redirected to a batch
+          if (is_array($redirect) && isset($redirect['batch_id'])) {
+            // Save remaining _submit callbacks
+            if (!empty($form['#submit'])) {
+              $redirect['post_process'] = array(
+                'form' => $form,
+                'form_id' =>  $form_id,
+                'form_values' => $form_values
+              );
+            }
+            // Overwrite previously set batch and execute
+            drupal_set_batch($redirect, TRUE);
+            break;
+          }
+          else {
+            $goto = $redirect;
+          }
         }
       }
     }
Index: includes/theme.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/theme.inc,v
retrieving revision 1.347
diff -u -p -r1.347 theme.inc
--- includes/theme.inc	9 Apr 2007 13:43:57 -0000	1.347
+++ includes/theme.inc	12 Apr 2007 21:29:25 -0000
@@ -1395,8 +1395,8 @@ function theme_username($object) {
 function theme_progress_bar($percent, $message) {
   $output = '<div id="progress" class="progress">';
   $output .= '<div class="percentage">'. $percent .'%</div>';
-  $output .= '<div class="status">'. $message .'</div>';
   $output .= '<div class="bar"><div class="filled" style="width: '. $percent .'%"></div></div>';
+  $output .= '<div class="message">'. $message .'</div>';
   $output .= '</div>';
 
   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	12 Apr 2007 21:29:25 -0000
@@ -0,0 +1,33 @@
+// $Id$
+
+if (Drupal.jsEnabled) {
+  $(document).ready(function() {
+    $('#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.30
diff -u -p -r1.30 drupal.js
--- misc/drupal.js	9 Apr 2007 13:58:02 -0000	1.30
+++ misc/drupal.js	12 Apr 2007 21:29:25 -0000
@@ -220,7 +220,98 @@ Drupal.getSelection = function (element)
   return { 'start': element.selectionStart, 'end': element.selectionEnd };
 }
 
-// Global Killswitch on the <html> 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 <html> 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	12 Apr 2007 21:29:25 -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('<div class="percentage"></div>'+
-                       '<div class="message">&nbsp;</div>'+
-                       '<div class="bar"><div class="filled"></div></div>');
+  $(this.element).html('<div class="bar"><div class="filled"></div></div>'+
+                       '<div class="percentage"></div>'+
+                       '<div class="message">&nbsp;</div>');
 }
 
 /**
Index: modules/system/system.install
===================================================================
RCS file: /cvs/drupal/drupal/modules/system/system.install,v
retrieving revision 1.89
diff -u -p -r1.89 system.install
--- modules/system/system.install	10 Apr 2007 10:10:27 -0000	1.89
+++ modules/system/system.install	12 Apr 2007 21:29:26 -0000
@@ -190,6 +190,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',
@@ -659,6 +665,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.464
diff -u -p -r1.464 system.module
--- modules/system/system.module	10 Apr 2007 10:10:27 -0000	1.464
+++ modules/system/system.module	12 Apr 2007 21:29:28 -0000
@@ -327,6 +327,12 @@ function system_menu() {
     'page callback' => 'system_sql',
     'type' => MENU_CALLBACK,
   );
+  // Default callback for batch operations
+  $items['batch'] = array(
+    'page callback' => 'batch_page',
+    'access callback' => TRUE,
+    'type' => MENU_CALLBACK,
+  );
   return $items;
 }
 
