Chris@0: query->get('id'))) { Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Retrieve the current state of the batch. Chris@0: if (!$batch) { Chris@0: $batch = \Drupal::service('batch.storage')->load($request_id); Chris@0: if (!$batch) { Chris@17: \Drupal::messenger()->addError(t('No active batch.')); Chris@18: return new RedirectResponse(Url::fromRoute('', [], ['absolute' => TRUE])->toString()); Chris@0: } Chris@0: } Chris@0: Chris@0: // We need to store the updated batch information in the batch storage after Chris@0: // processing the batch. In order for the error page to work correctly this Chris@0: // needs to be done even in case of a PHP fatal error in which case the end of Chris@0: // this function is never reached. Therefore we register a shutdown function Chris@0: // to handle this case. Because with FastCGI and fastcgi_finish_request() Chris@0: // shutdown functions are called after the HTTP connection is closed, updating Chris@0: // the batch information in a shutdown function would lead to race conditions Chris@0: // between consecutive requests if the batch processing continues. In case of Chris@0: // a fatal error the processing stops anyway, so it works even with FastCGI. Chris@0: // However, we must ensure to only update in the shutdown phase in this Chris@0: // particular case we track whether the batch information still needs to be Chris@0: // updated. Chris@0: // @see _batch_shutdown() Chris@0: // @see \Symfony\Component\HttpFoundation\Response::send() Chris@0: drupal_register_shutdown_function('_batch_shutdown'); Chris@0: _batch_needs_update(TRUE); Chris@0: Chris@0: $build = []; Chris@0: Chris@0: // Add batch-specific libraries. Chris@0: foreach ($batch['sets'] as $batch_set) { Chris@0: if (isset($batch_set['library'])) { Chris@0: foreach ($batch_set['library'] as $library) { Chris@0: $build['#attached']['library'][] = $library; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: $op = $request->query->get('op', ''); Chris@0: switch ($op) { Chris@0: case 'start': Chris@0: case 'do_nojs': Chris@0: // Display the full progress page on startup and on each additional Chris@0: // non-JavaScript iteration. Chris@0: $current_set = _batch_current_set(); Chris@0: $build['#title'] = $current_set['title']; Chris@0: $build['content'] = _batch_progress_page(); Chris@0: Chris@0: $response = $build; Chris@0: break; Chris@0: Chris@0: case 'do': Chris@0: // JavaScript-based progress page callback. Chris@0: $response = _batch_do(); Chris@0: break; Chris@0: Chris@0: case 'finished': Chris@0: // _batch_finished() returns a RedirectResponse. Chris@0: $response = _batch_finished(); Chris@0: break; Chris@0: } Chris@0: Chris@0: if ($batch) { Chris@0: \Drupal::service('batch.storage')->update($batch); Chris@0: } Chris@0: _batch_needs_update(FALSE); Chris@0: Chris@0: return $response; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Checks whether the batch information needs to be updated in the storage. Chris@0: * Chris@0: * @param bool $new_value Chris@0: * (optional) A new value to set. Chris@0: * Chris@0: * @return bool Chris@0: * TRUE if the batch information needs to be updated; FALSE otherwise. Chris@0: */ Chris@0: function _batch_needs_update($new_value = NULL) { Chris@0: $needs_update = &drupal_static(__FUNCTION__, FALSE); Chris@0: Chris@0: if (isset($new_value)) { Chris@0: $needs_update = $new_value; Chris@0: } Chris@0: Chris@0: return $needs_update; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Does one execution pass with JavaScript and returns progress to the browser. Chris@0: * Chris@0: * @see _batch_progress_page_js() Chris@0: * @see _batch_process() Chris@0: */ Chris@0: function _batch_do() { Chris@0: // Perform actual processing. Chris@0: list($percentage, $message, $label) = _batch_process(); Chris@0: Chris@0: return new JsonResponse(['status' => TRUE, 'percentage' => $percentage, 'message' => $message, 'label' => $label]); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Outputs a batch processing page. Chris@0: * Chris@0: * @see _batch_process() Chris@0: */ Chris@0: function _batch_progress_page() { Chris@0: $batch = &batch_get(); Chris@0: Chris@0: $current_set = _batch_current_set(); Chris@0: Chris@0: $new_op = 'do_nojs'; Chris@0: Chris@0: if (!isset($batch['running'])) { Chris@0: // This is the first page so we return some output immediately. Chris@0: $percentage = 0; Chris@0: $message = $current_set['init_message']; Chris@0: $label = ''; Chris@0: $batch['running'] = TRUE; Chris@0: } Chris@0: else { Chris@0: // This is one of the later requests; do some processing first. Chris@0: Chris@0: // Error handling: if PHP dies due to a fatal error (e.g. a nonexistent Chris@0: // function), it will output whatever is in the output buffer, followed by Chris@0: // the error message. Chris@0: ob_start(); Chris@0: $fallback = $current_set['error_message'] . '
' . $batch['error_message']; Chris@0: Chris@0: // We strip the end of the page using a marker in the template, so any Chris@0: // additional HTML output by PHP shows up inside the page rather than below Chris@0: // it. While this causes invalid HTML, the same would be true if we didn't, Chris@0: // as content is not allowed to appear after anyway. Chris@0: $bare_html_page_renderer = \Drupal::service('bare_html_page_renderer'); Chris@0: $response = $bare_html_page_renderer->renderBarePage(['#markup' => $fallback], $current_set['title'], 'maintenance_page', [ Chris@0: '#show_messages' => FALSE, Chris@0: ]); Chris@0: Chris@0: // Just use the content of the response. Chris@0: $fallback = $response->getContent(); Chris@0: Chris@0: list($fallback) = explode('', $fallback); Chris@0: print $fallback; Chris@0: Chris@0: // Perform actual processing. Chris@0: list($percentage, $message, $label) = _batch_process($batch); Chris@0: if ($percentage == 100) { Chris@0: $new_op = 'finished'; Chris@0: } Chris@0: Chris@0: // PHP did not die; remove the fallback output. Chris@0: ob_end_clean(); Chris@0: } Chris@0: Chris@0: // Merge required query parameters for batch processing into those provided by Chris@0: // batch_set() or hook_batch_alter(). Chris@0: $query_options = $batch['url']->getOption('query'); Chris@0: $query_options['id'] = $batch['id']; Chris@0: $query_options['op'] = $new_op; Chris@0: $batch['url']->setOption('query', $query_options); Chris@0: Chris@0: $url = $batch['url']->toString(TRUE)->getGeneratedUrl(); Chris@0: Chris@0: $build = [ Chris@0: '#theme' => 'progress_bar', Chris@0: '#percent' => $percentage, Chris@0: '#message' => ['#markup' => $message], Chris@0: '#label' => $label, Chris@0: '#attached' => [ Chris@0: 'html_head' => [ Chris@0: [ Chris@0: [ Chris@0: // Redirect through a 'Refresh' meta tag if JavaScript is disabled. Chris@0: '#tag' => 'meta', Chris@0: '#noscript' => TRUE, Chris@0: '#attributes' => [ Chris@0: 'http-equiv' => 'Refresh', Chris@0: 'content' => '0; URL=' . $url, Chris@0: ], Chris@0: ], Chris@0: 'batch_progress_meta_refresh', Chris@0: ], Chris@0: ], Chris@0: // Adds JavaScript code and settings for clients where JavaScript is enabled. Chris@0: 'drupalSettings' => [ Chris@0: 'batch' => [ Chris@0: 'errorMessage' => $current_set['error_message'] . '
' . $batch['error_message'], Chris@0: 'initMessage' => $current_set['init_message'], Chris@0: 'uri' => $url, Chris@0: ], Chris@0: ], Chris@0: 'library' => [ Chris@0: 'core/drupal.batch', Chris@0: ], Chris@0: ], Chris@0: ]; Chris@0: return $build; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Processes sets in a batch. Chris@0: * Chris@0: * If the batch was marked for progressive execution (default), this executes as Chris@0: * many operations in batch sets until an execution time of 1 second has been Chris@0: * exceeded. It will continue with the next operation of the same batch set in Chris@0: * the next request. Chris@0: * Chris@0: * @return array Chris@0: * An array containing a completion value (in percent) and a status message. Chris@0: */ Chris@0: function _batch_process() { Chris@0: $batch = &batch_get(); Chris@0: $current_set = &_batch_current_set(); Chris@0: // Indicate that this batch set needs to be initialized. Chris@0: $set_changed = TRUE; Chris@18: $task_message = ''; Chris@0: Chris@0: // If this batch was marked for progressive execution (e.g. forms submitted by Chris@0: // \Drupal::formBuilder()->submitForm(), initialize a timer to determine Chris@0: // whether we need to proceed with the same batch phase when a processing time Chris@0: // of 1 second has been exceeded. Chris@0: if ($batch['progressive']) { Chris@0: Timer::start('batch_processing'); Chris@0: } Chris@0: Chris@0: if (empty($current_set['start'])) { Chris@0: $current_set['start'] = microtime(TRUE); Chris@0: } Chris@0: Chris@0: $queue = _batch_queue($current_set); Chris@0: Chris@0: while (!$current_set['success']) { Chris@0: // If this is the first time we iterate this batch set in the current Chris@0: // request, we check if it requires an additional file for functions Chris@0: // definitions. Chris@0: if ($set_changed && isset($current_set['file']) && is_file($current_set['file'])) { Chris@0: include_once \Drupal::root() . '/' . $current_set['file']; Chris@0: } Chris@0: Chris@18: $task_message = ''; Chris@0: // Assume a single pass operation and set the completion level to 1 by Chris@0: // default. Chris@0: $finished = 1; Chris@0: Chris@0: if ($item = $queue->claimItem()) { Chris@0: list($callback, $args) = $item->data; Chris@0: Chris@0: // Build the 'context' array and execute the function call. Chris@0: $batch_context = [ Chris@0: 'sandbox' => &$current_set['sandbox'], Chris@0: 'results' => &$current_set['results'], Chris@0: 'finished' => &$finished, Chris@0: 'message' => &$task_message, Chris@0: ]; Chris@0: call_user_func_array($callback, array_merge($args, [&$batch_context])); Chris@0: Chris@0: if ($finished >= 1) { Chris@0: // Make sure this step is not counted twice when computing $current. Chris@0: $finished = 0; Chris@0: // Remove the processed operation and clear the sandbox. Chris@0: $queue->deleteItem($item); Chris@0: $current_set['count']--; Chris@0: $current_set['sandbox'] = []; Chris@0: } Chris@0: } Chris@0: Chris@0: // When all operations in the current batch set are completed, browse Chris@0: // through the remaining sets, marking them 'successfully processed' Chris@0: // along the way, until we find a set that contains operations. Chris@0: // _batch_next_set() executes form submit handlers stored in 'control' Chris@0: // sets (see \Drupal::service('form_submitter')), which can in turn add new Chris@0: // sets to the batch. Chris@0: $set_changed = FALSE; Chris@0: $old_set = $current_set; Chris@0: while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) { Chris@0: $current_set = &_batch_current_set(); Chris@0: $current_set['start'] = microtime(TRUE); Chris@0: $set_changed = TRUE; Chris@0: } Chris@0: Chris@0: // At this point, either $current_set contains operations that need to be Chris@0: // processed or all sets have been completed. Chris@0: $queue = _batch_queue($current_set); Chris@0: Chris@0: // If we are in progressive mode, break processing after 1 second. Chris@0: if ($batch['progressive'] && Timer::read('batch_processing') > 1000) { Chris@0: // Record elapsed wall clock time. Chris@0: $current_set['elapsed'] = round((microtime(TRUE) - $current_set['start']) * 1000, 2); Chris@0: break; Chris@0: } Chris@0: } Chris@0: Chris@0: if ($batch['progressive']) { Chris@0: // Gather progress information. Chris@0: Chris@0: // Reporting 100% progress will cause the whole batch to be considered Chris@0: // processed. If processing was paused right after moving to a new set, Chris@0: // we have to use the info from the new (unprocessed) set. Chris@0: if ($set_changed && isset($current_set['queue'])) { Chris@0: // Processing will continue with a fresh batch set. Chris@0: $remaining = $current_set['count']; Chris@0: $total = $current_set['total']; Chris@0: $progress_message = $current_set['init_message']; Chris@0: $task_message = ''; Chris@0: } Chris@0: else { Chris@0: // Processing will continue with the current batch set. Chris@0: $remaining = $old_set['count']; Chris@0: $total = $old_set['total']; Chris@0: $progress_message = $old_set['progress_message']; Chris@0: } Chris@0: Chris@0: // Total progress is the number of operations that have fully run plus the Chris@0: // completion level of the current operation. Chris@0: $current = $total - $remaining + $finished; Chris@0: $percentage = _batch_api_percentage($total, $current); Chris@0: $elapsed = isset($current_set['elapsed']) ? $current_set['elapsed'] : 0; Chris@0: $values = [ Chris@0: '@remaining' => $remaining, Chris@0: '@total' => $total, Chris@0: '@current' => floor($current), Chris@0: '@percentage' => $percentage, Chris@0: '@elapsed' => \Drupal::service('date.formatter')->formatInterval($elapsed / 1000), Chris@0: // If possible, estimate remaining processing time. Chris@0: '@estimate' => ($current > 0) ? \Drupal::service('date.formatter')->formatInterval(($elapsed * ($total - $current) / $current) / 1000) : '-', Chris@0: ]; Chris@0: $message = strtr($progress_message, $values); Chris@0: Chris@18: return [$percentage, $message, $task_message]; Chris@0: } Chris@0: else { Chris@0: // If we are not in progressive mode, the entire batch has been processed. Chris@0: return _batch_finished(); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Formats the percent completion for a batch set. Chris@0: * Chris@0: * @param int $total Chris@0: * The total number of operations. Chris@0: * @param int|float $current Chris@0: * The number of the current operation. This may be a floating point number Chris@0: * rather than an integer in the case of a multi-step operation that is not Chris@0: * yet complete; in that case, the fractional part of $current represents the Chris@0: * fraction of the operation that has been completed. Chris@0: * Chris@0: * @return string Chris@0: * The properly formatted percentage, as a string. We output percentages Chris@0: * using the correct number of decimal places so that we never print "100%" Chris@0: * until we are finished, but we also never print more decimal places than Chris@0: * are meaningful. Chris@0: * Chris@0: * @see _batch_process() Chris@0: */ Chris@0: function _batch_api_percentage($total, $current) { Chris@0: return Percentage::format($total, $current); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Returns the batch set being currently processed. Chris@0: */ Chris@0: function &_batch_current_set() { Chris@0: $batch = &batch_get(); Chris@0: return $batch['sets'][$batch['current_set']]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Retrieves the next set in a batch. Chris@0: * Chris@0: * If there is a subsequent set in this batch, assign it as the new set to Chris@0: * process and execute its form submit handler (if defined), which may add Chris@0: * further sets to this batch. Chris@0: * Chris@0: * @return true|null Chris@0: * TRUE if a subsequent set was found in the batch; no value will be returned Chris@0: * if no subsequent set was found. Chris@0: */ Chris@0: function _batch_next_set() { Chris@0: $batch = &batch_get(); Chris@0: if (isset($batch['sets'][$batch['current_set'] + 1])) { Chris@0: $batch['current_set']++; Chris@0: $current_set = &_batch_current_set(); Chris@0: if (isset($current_set['form_submit']) && ($callback = $current_set['form_submit']) && is_callable($callback)) { Chris@0: // We use our stored copies of $form and $form_state to account for Chris@0: // possible alterations by previous form submit handlers. Chris@0: $complete_form = &$batch['form_state']->getCompleteForm(); Chris@0: call_user_func_array($callback, [&$complete_form, &$batch['form_state']]); Chris@0: } Chris@0: return TRUE; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Ends the batch processing. Chris@0: * Chris@0: * Call the 'finished' callback of each batch set to allow custom handling of Chris@0: * the results and resolve page redirection. Chris@0: */ Chris@0: function _batch_finished() { Chris@0: $batch = &batch_get(); Chris@0: $batch_finished_redirect = NULL; Chris@0: Chris@0: // Execute the 'finished' callbacks for each batch set, if defined. Chris@0: foreach ($batch['sets'] as $batch_set) { Chris@0: if (isset($batch_set['finished'])) { Chris@0: // Check if the set requires an additional file for function definitions. Chris@0: if (isset($batch_set['file']) && is_file($batch_set['file'])) { Chris@0: include_once \Drupal::root() . '/' . $batch_set['file']; Chris@0: } Chris@0: if (is_callable($batch_set['finished'])) { Chris@0: $queue = _batch_queue($batch_set); Chris@0: $operations = $queue->getAllItems(); Chris@0: $batch_set_result = call_user_func_array($batch_set['finished'], [$batch_set['success'], $batch_set['results'], $operations, \Drupal::service('date.formatter')->formatInterval($batch_set['elapsed'] / 1000)]); Chris@0: // If a batch 'finished' callback requested a redirect after the batch Chris@0: // is complete, save that for later use. If more than one batch set Chris@0: // returned a redirect, the last one is used. Chris@0: if ($batch_set_result instanceof RedirectResponse) { Chris@0: $batch_finished_redirect = $batch_set_result; Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: // Clean up the batch table and unset the static $batch variable. Chris@0: if ($batch['progressive']) { Chris@0: \Drupal::service('batch.storage')->delete($batch['id']); Chris@0: foreach ($batch['sets'] as $batch_set) { Chris@0: if ($queue = _batch_queue($batch_set)) { Chris@0: $queue->deleteQueue(); Chris@0: } Chris@0: } Chris@0: // Clean-up the session. Not needed for CLI updates. Chris@0: if (isset($_SESSION)) { Chris@0: unset($_SESSION['batches'][$batch['id']]); Chris@0: if (empty($_SESSION['batches'])) { Chris@0: unset($_SESSION['batches']); Chris@0: } Chris@0: } Chris@0: } Chris@0: $_batch = $batch; Chris@0: $batch = NULL; Chris@0: Chris@0: // Redirect if needed. Chris@0: if ($_batch['progressive']) { Chris@0: // Revert the 'destination' that was saved in batch_process(). Chris@0: if (isset($_batch['destination'])) { Chris@0: \Drupal::request()->query->set('destination', $_batch['destination']); Chris@0: } Chris@0: Chris@0: // Determine the target path to redirect to. If a batch 'finished' callback Chris@0: // returned a redirect response object, use that. Otherwise, fall back on Chris@0: // the form redirection. Chris@0: if (isset($batch_finished_redirect)) { Chris@0: return $batch_finished_redirect; Chris@0: } Chris@0: elseif (!isset($_batch['form_state'])) { Chris@0: $_batch['form_state'] = new FormState(); Chris@0: } Chris@0: if ($_batch['form_state']->getRedirect() === NULL) { Chris@0: $redirect = $_batch['batch_redirect'] ?: $_batch['source_url']; Chris@0: // Any path with a scheme does not correspond to a route. Chris@0: if (!$redirect instanceof Url) { Chris@0: $options = UrlHelper::parse($redirect); Chris@0: if (parse_url($options['path'], PHP_URL_SCHEME)) { Chris@0: $redirect = Url::fromUri($options['path'], $options); Chris@0: } Chris@0: else { Chris@0: $redirect = \Drupal::pathValidator()->getUrlIfValid($options['path']); Chris@0: if (!$redirect) { Chris@0: // Stay on the same page if the redirect was invalid. Chris@0: $redirect = Url::fromRoute(''); Chris@0: } Chris@0: $redirect->setOptions($options); Chris@0: } Chris@0: } Chris@0: $_batch['form_state']->setRedirectUrl($redirect); Chris@0: } Chris@0: Chris@0: // Use \Drupal\Core\Form\FormSubmitterInterface::redirectForm() to handle Chris@0: // the redirection logic. Chris@0: $redirect = \Drupal::service('form_submitter')->redirectForm($_batch['form_state']); Chris@0: if (is_object($redirect)) { Chris@0: return $redirect; Chris@0: } Chris@0: Chris@0: // If no redirection happened, redirect to the originating page. In case the Chris@0: // form needs to be rebuilt, save the final $form_state for Chris@0: // \Drupal\Core\Form\FormBuilderInterface::buildForm(). Chris@0: if ($_batch['form_state']->isRebuilding()) { Chris@0: $_SESSION['batch_form_state'] = $_batch['form_state']; Chris@0: } Chris@0: $callback = $_batch['redirect_callback']; Chris@0: $_batch['source_url']->mergeOptions(['query' => ['op' => 'finish', 'id' => $_batch['id']]]); Chris@0: if (is_callable($callback)) { Chris@0: $callback($_batch['source_url'], $_batch['source_url']->getOption('query')); Chris@0: } Chris@0: elseif ($callback === NULL) { Chris@0: // Default to RedirectResponse objects when nothing specified. Chris@0: return new RedirectResponse($_batch['source_url']->setAbsolute()->toString()); Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Shutdown function: Stores the current batch data for the next request. Chris@0: * Chris@0: * @see _batch_page() Chris@0: * @see drupal_register_shutdown_function() Chris@0: */ Chris@0: function _batch_shutdown() { Chris@0: if (($batch = batch_get()) && _batch_needs_update()) { Chris@0: \Drupal::service('batch.storage')->update($batch); Chris@0: } Chris@0: }