diff options
Diffstat (limited to 'sites/all/modules/views_bulk_operations/views_bulk_operations.module')
-rw-r--r-- | sites/all/modules/views_bulk_operations/views_bulk_operations.module | 1298 |
1 files changed, 1298 insertions, 0 deletions
diff --git a/sites/all/modules/views_bulk_operations/views_bulk_operations.module b/sites/all/modules/views_bulk_operations/views_bulk_operations.module new file mode 100644 index 000000000..e74eeb1df --- /dev/null +++ b/sites/all/modules/views_bulk_operations/views_bulk_operations.module @@ -0,0 +1,1298 @@ +<?php + +/** + * @file + * Allows operations to be performed on items selected in a view. + */ + +// Access operations. +define('VBO_ACCESS_OP_VIEW', 0x01); +define('VBO_ACCESS_OP_UPDATE', 0x02); +define('VBO_ACCESS_OP_CREATE', 0x04); +define('VBO_ACCESS_OP_DELETE', 0x08); + +/** + * Implements hook_action_info(). + * Registers custom VBO actions as Drupal actions. + */ +function views_bulk_operations_action_info() { + $actions = array(); + $files = views_bulk_operations_load_action_includes(); + foreach ($files as $filename) { + $action_info_fn = 'views_bulk_operations_'. str_replace('.', '_', basename($filename, '.inc')).'_info'; + $action_info = call_user_func($action_info_fn); + if (is_array($action_info)) { + $actions += $action_info; + } + } + + return $actions; +} + +/** + * Loads the VBO actions placed in their own include files (under actions/). + * + * @return + * An array of containing filenames of the included actions. + */ +function views_bulk_operations_load_action_includes() { + static $loaded = FALSE; + + // The list of VBO actions is fairly static, so it's hardcoded for better + // performance (hitting the filesystem with file_scan_directory(), and then + // caching the result has its cost). + $files = array( + 'archive.action', + 'argument_selector.action', + 'book.action', + 'delete.action', + 'modify.action', + 'script.action', + 'user_roles.action', + 'user_cancel.action', + ); + + if (!$loaded) { + foreach ($files as $file) { + module_load_include('inc', 'views_bulk_operations', 'actions/' . $file); + } + $loaded = TRUE; + } + + return $files; +} + +/** + * Implements hook_cron(). + * + * Deletes queue items belonging to VBO active queues (used by VBO's batches) + * that are older than a day (since they can only be a result of VBO crashing + * or the execution being interrupted in some other way). This is the interval + * used to cleanup batches in system_cron(), so it can't be increased. + * + * Note: This code is specific to SystemQueue. Other queue implementations will + * need to do their own garbage collection. + */ +function views_bulk_operations_cron() { + db_delete('queue') + ->condition('name', db_like('views_bulk_operations_active_queue_'), 'LIKE') + ->condition('created', REQUEST_TIME - 86400, '<') + ->execute(); +} + +/** + * Implements of hook_cron_queue_info(). + */ +function views_bulk_operations_cron_queue_info() { + return array( + 'views_bulk_operations' => array( + 'worker callback' => 'views_bulk_operations_queue_item_process', + 'time' => 30, + ), + ); +} + +/** + * Implements hook_views_api(). + */ +function views_bulk_operations_views_api() { + return array( + 'api' => 3, + 'path' => drupal_get_path('module', 'views_bulk_operations') . '/views', + ); +} + +/** + * Implements hook_theme(). + */ +function views_bulk_operations_theme() { + $themes = array( + 'views_bulk_operations_select_all' => array( + 'variables' => array('view' => NULL, 'enable_select_all_pages' => TRUE), + ), + 'views_bulk_operations_confirmation' => array( + 'variables' => array('rows' => NULL, 'vbo' => NULL, 'operation' => NULL, 'select_all_pages' => FALSE), + ), + ); + $files = views_bulk_operations_load_action_includes(); + foreach ($files as $filename) { + $action_theme_fn = 'views_bulk_operations_'. str_replace('.', '_', basename($filename, '.inc')).'_theme'; + if (function_exists($action_theme_fn)) { + $themes += call_user_func($action_theme_fn); + } + } + + return $themes; +} + +/** + * Implements hook_ctools_plugin_type(). + */ +function views_bulk_operations_ctools_plugin_type() { + return array( + 'operation_types' => array( + 'classes' => array( + 'handler', + ), + ), + ); +} + +/** + * Implements hook_ctools_plugin_directory(). + */ +function views_bulk_operations_ctools_plugin_directory($module, $plugin) { + if ($module == 'views_bulk_operations') { + return 'plugins/' . $plugin; + } +} + +/** + * Fetch metadata for a specific operation type plugin. + * + * @param $operation_type + * Name of the plugin. + * + * @return + * An array with information about the requested operation type plugin. + */ +function views_bulk_operations_get_operation_type($operation_type) { + ctools_include('plugins'); + return ctools_get_plugins('views_bulk_operations', 'operation_types', $operation_type); +} + +/** + * Fetch metadata for all operation type plugins. + * + * @return + * An array of arrays with information about all available operation types. + */ +function views_bulk_operations_get_operation_types() { + ctools_include('plugins'); + return ctools_get_plugins('views_bulk_operations', 'operation_types'); +} + +/** + * Gets the info array of an operation from the provider plugin. + * + * @param $operation_id + * The id of the operation for which the info shall be returned, or NULL + * to return an array with info about all operations. + */ +function views_bulk_operations_get_operation_info($operation_id = NULL) { + $operations = &drupal_static(__FUNCTION__); + + if (!isset($operations)) { + $operations = array(); + $plugins = views_bulk_operations_get_operation_types(); + foreach ($plugins as $plugin) { + $operations += $plugin['list callback'](); + } + + uasort($operations, create_function('$a, $b', 'return strcasecmp($a["label"], $b["label"]);')); + } + + if (!empty($operation_id)) { + return $operations[$operation_id]; + } + else { + return $operations; + } +} + +/** + * Returns an operation instance. + * + * @param $operation_id + * The id of the operation to instantiate. + * For example: action::node_publish_action. + * @param $entity_type + * The entity type on which the operation operates. + * @param $options + * Options for this operation (label, operation settings, etc.) + */ +function views_bulk_operations_get_operation($operation_id, $entity_type, $options) { + $operations = &drupal_static(__FUNCTION__); + + if (!isset($operations[$operation_id])) { + // Intentionally not using views_bulk_operations_get_operation_info() here + // since it's an expensive function that loads all the operations on the + // system, despite the fact that we might only need a few. + $id_fragments = explode('::', $operation_id); + $plugin = views_bulk_operations_get_operation_type($id_fragments[0]); + $operation_info = $plugin['list callback']($operation_id); + + if ($operation_info) { + $operations[$operation_id] = new $plugin['handler']['class']($operation_id, $entity_type, $operation_info, $options); + } + else { + $operations[$operation_id] = FALSE; + } + } + + return $operations[$operation_id]; +} + +/** + * Get all operations that match the current entity type. + * + * @param $entity_type + * Entity type. + * @param $options + * An array of options for all operations, in the form of + * $operation_id => $operation_options. + */ +function views_bulk_operations_get_applicable_operations($entity_type, $options) { + $operations = array(); + foreach (views_bulk_operations_get_operation_info() as $operation_id => $operation_info) { + if ($operation_info['type'] == $entity_type || $operation_info['type'] == 'entity' || $operation_info['type'] == 'system') { + $options[$operation_id] = !empty($options[$operation_id]) ? $options[$operation_id] : array(); + $operations[$operation_id] = views_bulk_operations_get_operation($operation_id, $entity_type, $options[$operation_id]); + } + } + + return $operations; +} + +/** + * Gets the VBO field if it exists on the passed-in view. + * + * @return + * The field object if found. Otherwise, FALSE. + */ +function _views_bulk_operations_get_field($view) { + foreach ($view->field as $field_name => $field) { + if ($field instanceof views_bulk_operations_handler_field_operations) { + // Add in the view object for convenience. + $field->view = $view; + return $field; + } + } + return FALSE; +} + +/** + * Implements hook_views_form_substitutions(). + */ +function views_bulk_operations_views_form_substitutions() { + // Views check_plains the column label, so VBO needs to do the same + // in order for the replace operation to succeed. + $select_all_placeholder = check_plain('<!--views-bulk-operations-select-all-->'); + $select_all = array( + '#type' => 'checkbox', + '#default_value' => FALSE, + '#attributes' => array('class' => array('vbo-table-select-all')), + ); + + return array( + $select_all_placeholder => drupal_render($select_all), + ); +} + +/** + * Implements hook_form_alter(). + */ +function views_bulk_operations_form_alter(&$form, &$form_state, $form_id) { + if (strpos($form_id, 'views_form_') === 0) { + $vbo = _views_bulk_operations_get_field($form_state['build_info']['args'][0]); + } + // Not a VBO-enabled views form. + if (empty($vbo)) { + return; + } + + // Add basic VBO functionality. + if ($form_state['step'] == 'views_form_views_form') { + // The submit button added by Views Form API might be used by a non-VBO Views + // Form handler. If there's no such handler on the view, hide the button. + $has_other_views_form_handlers = FALSE; + foreach ($vbo->view->field as $field) { + if (property_exists($field, 'views_form_callback') || method_exists($field, 'views_form')) { + if (!($field instanceof views_bulk_operations_handler_field_operations)) { + $has_other_views_form_handlers = TRUE; + } + } + } + if (!$has_other_views_form_handlers) { + $form['actions']['#access'] = FALSE; + } + // The VBO field is excluded from display, stop here. + if (!empty($vbo->options['exclude'])) { + return; + } + + $form = views_bulk_operations_form($form, $form_state, $vbo); + } + + // Cache the built form to prevent it from being rebuilt prior to validation + // and submission, which could lead to data being processed incorrectly, + // because the views rows (and thus, the form elements as well) have changed + // in the meantime. Matching views issue: http://drupal.org/node/1473276. + $form_state['cache'] = TRUE; + + if (empty($vbo->view->override_url)) { + // If the VBO view is embedded using views_embed_view(), or in a block, + // $view->get_url() doesn't point to the current page, which means that + // the form doesn't get processed. + if (!empty($vbo->view->preview) || $vbo->view->display_handler instanceof views_plugin_display_block) { + $vbo->view->override_url = $_GET['q']; + // We are changing the override_url too late, the form action was already + // set by Views to the previous URL, so it needs to be overriden as well. + $query = drupal_get_query_parameters($_GET, array('q')); + $form['#action'] = url($_GET['q'], array('query' => $query)); + } + } + + // Give other modules a chance to alter the form. + drupal_alter('views_bulk_operations_form', $form, $form_state, $vbo); +} + +/** + * Implements hook_views_post_build(). + * + * Hides the VBO field if no operations are available. + * This causes the entire VBO form to be hidden. + * + * @see views_bulk_operations_form_alter(). + */ +function views_bulk_operations_views_post_build(&$view) { + $vbo = _views_bulk_operations_get_field($view); + if ($vbo && count($vbo->get_selected_operations()) < 1) { + $vbo->options['exclude'] = TRUE; + } +} + +/** + * Returns the 'select all' div that gets inserted below the table header row + * (for table style plugins with grouping disabled), or above the view results + * (for non-table style plugins), providing a choice between selecting items + * on the current page, and on all pages. + * + * The actual insertion is done by JS, matching the degradation behavior + * of Drupal core (no JS - no select all). + */ +function theme_views_bulk_operations_select_all($variables) { + $view = $variables['view']; + $enable_select_all_pages = $variables['enable_select_all_pages']; + $form = array(); + + if ($view->style_plugin instanceof views_plugin_style_table && empty($view->style_plugin->options['grouping'])) { + if (!$enable_select_all_pages) { + return ''; + } + + $wrapper_class = 'vbo-table-select-all-markup'; + $this_page_count = format_plural(count($view->result), '1 row', '@count rows'); + $this_page = t('Selected <strong>!row_count</strong> in this page.', array('!row_count' => $this_page_count)); + $all_pages_count = format_plural($view->total_rows, '1 row', '@count rows'); + $all_pages = t('Selected <strong>!row_count</strong> in this view.', array('!row_count' => $all_pages_count)); + + $form['select_all_pages'] = array( + '#type' => 'button', + '#attributes' => array('class' => array('vbo-table-select-all-pages')), + '#value' => t('Select all !row_count in this view.', array('!row_count' => $all_pages_count)), + '#prefix' => '<span class="vbo-table-this-page">' . $this_page . ' ', + '#suffix' => '</span>', + ); + $form['select_this_page'] = array( + '#type' => 'button', + '#attributes' => array('class' => array('vbo-table-select-this-page')), + '#value' => t('Select only !row_count in this page.', array('!row_count' => $this_page_count)), + '#prefix' => '<span class="vbo-table-all-pages" style="display: none">' . $all_pages . ' ', + '#suffix' => '</span>', + ); + } + else { + $wrapper_class = 'vbo-select-all-markup'; + + $form['select_all'] = array( + '#type' => 'fieldset', + '#attributes' => array('class' => array('vbo-fieldset-select-all')), + ); + $form['select_all']['this_page'] = array( + '#type' => 'checkbox', + '#title' => t('Select all items on this page'), + '#default_value' => '', + '#attributes' => array('class' => array('vbo-select-this-page')), + ); + + if ($enable_select_all_pages) { + $form['select_all']['or'] = array( + '#type' => 'markup', + '#markup' => '<em>' . t('OR') . '</em>', + ); + $form['select_all']['all_pages'] = array( + '#type' => 'checkbox', + '#title' => t('Select all items on all pages'), + '#default_value' => '', + '#attributes' => array('class' => array('vbo-select-all-pages')), + ); + } + } + + $output = '<div class="' . $wrapper_class . '">'; + $output .= drupal_render($form); + $output .= '</div>'; + + return $output; +} + +/** + * Extend the views_form multistep form with elements for executing an operation. + */ +function views_bulk_operations_form($form, &$form_state, $vbo) { + $form['#attached']['js'][] = drupal_get_path('module', 'views_bulk_operations') . '/js/views_bulk_operations.js'; + $form['#attached']['js'][] = array( + 'data' => array('vbo' => array( + 'row_clickable' => $vbo->get_vbo_option('row_clickable'), + )), + 'type' => 'setting', + ); + + $form['#attached']['css'][] = drupal_get_path('module', 'views_bulk_operations') . '/css/views_bulk_operations.css'; + // Wrap the form in a div with specific classes for JS targeting and theming. + $class = 'vbo-views-form'; + if (empty($vbo->view->result)) { + $class .= ' vbo-views-form-empty'; + } + $form['#prefix'] = '<div class="' . $class . '">'; + $form['#suffix'] = '</div>'; + + // Force browser to reload the page if Back is hit. + if (!empty($_SERVER['HTTP_USER_AGENT']) && preg_match('/msie/i', $_SERVER['HTTP_USER_AGENT'])) { + drupal_add_http_header('Cache-Control', 'no-cache'); // works for IE6+ + } + else { + drupal_add_http_header('Cache-Control', 'no-store'); // works for Firefox and other browsers + } + + // Set by JS to indicate that all rows on all pages are selected. + $form['select_all'] = array( + '#type' => 'hidden', + '#attributes' => array('class' => 'select-all-rows'), + '#default_value' => FALSE, + ); + $form['select'] = array( + '#type' => 'fieldset', + '#title' => t('Operations'), + '#collapsible' => FALSE, + '#attributes' => array('class' => array('container-inline')), + ); + if ($vbo->get_vbo_option('display_type') == 0) { + $options = array(0 => t('- Choose an operation -')); + foreach ($vbo->get_selected_operations() as $operation_id => $operation) { + $options[$operation_id] = $operation->label(); + } + + // Create dropdown and submit button. + $form['select']['operation'] = array( + '#type' => 'select', + '#options' => $options, + ); + $form['select']['submit'] = array( + '#type' => 'submit', + '#value' => t('Execute'), + '#validate' => array('views_bulk_operations_form_validate'), + '#submit' => array('views_bulk_operations_form_submit'), + ); + } + else { + // Create buttons for operations. + foreach ($vbo->get_selected_operations() as $operation_id => $operation) { + $form['select'][$operation_id] = array( + '#type' => 'submit', + '#value' => $operation->label(), + '#validate' => array('views_bulk_operations_form_validate'), + '#submit' => array('views_bulk_operations_form_submit'), + '#operation_id' => $operation_id, + ); + } + } + + // Adds the "select all" functionality if the view has results. + // If the view is using a table style plugin, the markup gets moved to + // a table row below the header. + // If we are using radio buttons, we don't use select all at all. + if (!empty($vbo->view->result) && !$vbo->get_vbo_option('force_single')) { + $enable_select_all_pages = FALSE; + // If the view is paginated, and "select all items on all pages" is + // enabled, tell that to the theme function. + if (count($vbo->view->result) != $vbo->view->total_rows && $vbo->get_vbo_option('enable_select_all_pages')) { + $enable_select_all_pages = TRUE; + } + $form['select_all_markup'] = array( + '#type' => 'markup', + '#markup' => theme('views_bulk_operations_select_all', array('view' => $vbo->view, 'enable_select_all_pages' => $enable_select_all_pages)), + ); + } + + return $form; +} + +/** + * Validation callback for the first step of the VBO form. + */ +function views_bulk_operations_form_validate($form, &$form_state) { + $vbo = _views_bulk_operations_get_field($form_state['build_info']['args'][0]); + + if (!empty($form_state['triggering_element']['#operation_id'])) { + $form_state['values']['operation'] = $form_state['triggering_element']['#operation_id']; + } + if (!$form_state['values']['operation']) { + form_set_error('operation', t('No operation selected. Please select an operation to perform.')); + } + + $field_name = $vbo->options['id']; + $selection = _views_bulk_operations_get_selection($vbo, $form_state); + if (!$selection) { + form_set_error($field_name, t('Please select at least one item.')); + } +} + +/** + * Multistep form callback for the "configure" step. + */ +function views_bulk_operations_config_form($form, &$form_state, $view, $output) { + $vbo = _views_bulk_operations_get_field($view); + $operation = $form_state['operation']; + drupal_set_title(t('Set parameters for %operation', array('%operation' => $operation->label())), PASS_THROUGH); + + $context = array( + 'entity_type' => $vbo->get_entity_type(), + // Pass the View along. + // Has no performance penalty since objects are passed by reference, + // but needing the full views object in a core action is in most cases + // a sign of a wrong implementation. Do it only if you have to. + 'view' => $view, + ); + $form += $operation->form($form, $form_state, $context); + + $query = drupal_get_query_parameters($_GET, array('q')); + $form['actions'] = array( + '#type' => 'container', + '#attributes' => array('class' => array('form-actions')), + '#weight' => 999, + ); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Next'), + '#validate' => array('views_bulk_operations_config_form_validate'), + '#submit' => array('views_bulk_operations_form_submit'), + '#suffix' => l(t('Cancel'), $vbo->view->get_url(), array('query' => $query)), + ); + + return $form; +} + +/** + * Validation callback for the "configure" step. + * Gives the operation a chance to validate its config form. + */ +function views_bulk_operations_config_form_validate($form, &$form_state) { + $operation = &$form_state['operation']; + $operation->formValidate($form, $form_state); +} + +/** + * Multistep form callback for the "confirm" step. + */ +function views_bulk_operations_confirm_form($form, &$form_state, $view, $output) { + $vbo = _views_bulk_operations_get_field($view); + $operation = $form_state['operation']; + $rows = $form_state['selection']; + $query = drupal_get_query_parameters($_GET, array('q')); + $title = t('Are you sure you want to perform %operation on the selected items?', array('%operation' => $operation->label())); + $form = confirm_form($form, + $title, + array('path' => $view->get_url(), 'query' => $query), + theme('views_bulk_operations_confirmation', array('rows' => $rows, 'vbo' => $vbo, 'operation' => $operation, 'select_all_pages' => $form_state['select_all_pages'])) + ); + // Add VBO's submit handler to the Confirm button added by config_form(). + $form['actions']['submit']['#submit'] = array('views_bulk_operations_form_submit'); + + // We can't set the View title here as $view is just a copy of the original, + // and our settings changes won't "stick" for the first page load of the + // confirmation form. We also can't just call drupal_set_title() directly + // because our title will be clobbered by the actual View title later. So + // let's tuck the title away in the form for use later. + // @see views_bulk_operations_preprocess_views_view() + $form['#vbo_confirm_form_title'] = $title; + + return $form; +} + +/** + * Theme function to show the confirmation page before executing the operation. + */ +function theme_views_bulk_operations_confirmation($variables) { + $select_all_pages = $variables['select_all_pages']; + $vbo = $variables['vbo']; + $entity_type = $vbo->get_entity_type(); + $rows = $variables['rows']; + $items = array(); + // Load the entities from the current page, and show their titles. + $entities = _views_bulk_operations_entity_load($entity_type, array_values($rows), $vbo->revision); + foreach ($entities as $entity) { + $items[] = check_plain(entity_label($entity_type, $entity)); + } + // All rows on all pages have been selected, so show a count of additional items. + if ($select_all_pages) { + $more_count = $vbo->view->total_rows - count($vbo->view->result); + $items[] = t('...and <strong>!count</strong> more.', array('!count' => $more_count)); + } + + $count = format_plural(count($entities), 'item', '@count items'); + $output = theme('item_list', array('items' => $items, 'title' => t('You selected the following <strong>!count</strong>:', array('!count' => $count)))); + return $output; +} + +/** + * Implements hook_preprocess_page(). + * + * Hide action links on the configure and confirm pages. + */ +function views_bulk_operations_preprocess_page(&$variables) { + if (isset($_POST['select_all'], $_POST['operation'])) { + $variables['action_links'] = array(); + } +} + +/** + * Implements hook_preprocess_views_view(). + */ +function views_bulk_operations_preprocess_views_view($variables) { + // If we've stored a title for the confirmation form, retrieve it here and + // retitle the View. + // @see views_bulk_operations_confirm_form() + if (array_key_exists('rows', $variables) && is_array($variables['rows']) && array_key_exists('#vbo_confirm_form_title', $variables['rows'])) { + $variables['view']->set_title($variables['rows']['#vbo_confirm_form_title']); + } +} + +/** + * Goes through the submitted values, and returns + * an array of selected rows, in the form of + * $row_index => $entity_id. + */ +function _views_bulk_operations_get_selection($vbo, $form_state) { + $selection = array(); + $field_name = $vbo->options['id']; + + if (!empty($form_state['values'][$field_name])) { + // If using "force single", the selection needs to be converted to an array. + if (is_array($form_state['values'][$field_name])) { + $selection = array_filter($form_state['values'][$field_name]); + } + else { + $selection = array($form_state['values'][$field_name]); + } + } + + return $selection; +} + +/** + * Submit handler for all steps of the VBO multistep form. + */ +function views_bulk_operations_form_submit($form, &$form_state) { + $vbo = _views_bulk_operations_get_field($form_state['build_info']['args'][0]); + $entity_type = $vbo->get_entity_type(); + + switch ($form_state['step']) { + case 'views_form_views_form': + $form_state['selection'] = _views_bulk_operations_get_selection($vbo, $form_state); + $form_state['select_all_pages'] = $form_state['values']['select_all']; + + $options = $vbo->get_operation_options($form_state['values']['operation']); + $form_state['operation'] = $operation = views_bulk_operations_get_operation($form_state['values']['operation'], $entity_type, $options); + if (!$operation->configurable() && $operation->getAdminOption('skip_confirmation')) { + break; // Go directly to execution + } + $form_state['step'] = $operation->configurable() ? 'views_bulk_operations_config_form' : 'views_bulk_operations_confirm_form'; + $form_state['rebuild'] = TRUE; + return; + + case 'views_bulk_operations_config_form': + $form_state['step'] = 'views_bulk_operations_confirm_form'; + $operation = &$form_state['operation']; + $operation->formSubmit($form, $form_state); + + if ($operation->getAdminOption('skip_confirmation')) { + break; // Go directly to execution + } + $form_state['rebuild'] = TRUE; + return; + + case 'views_bulk_operations_confirm_form': + break; + } + + // Execute the operation. + views_bulk_operations_execute($vbo, $form_state['operation'], $form_state['selection'], $form_state['select_all_pages']); + + // Redirect. + $query = drupal_get_query_parameters($_GET, array('q')); + $form_state['redirect'] = array('path' => $vbo->view->get_url(), array('query' => $query)); +} + +/** + * Entry point for executing the chosen operation upon selected rows. + * + * If the selected operation is an aggregate operation (requiring all selected + * items to be passed at the same time), restricted to a single value, or has + * the skip_batching option set, the operation is executed directly. + * This means that there is no batching & queueing, the PHP execution + * time limit is ignored (if allowed), all selected entities are loaded and + * processed. + * + * Otherwise, the selected entity ids are divided into groups not larger than + * $entity_load_capacity, and enqueued for processing. + * If all items on all pages should be processed, a batch job runs that + * collects and enqueues the items from all pages of the view, page by page. + * + * Based on the "Enqueue the operation instead of executing it directly" + * VBO field setting, the newly filled queue is either processed at cron + * time by the VBO worker function, or right away in a new batch job. + * + * @param $vbo + * The VBO field, containing a reference to the view in $vbo->view. + * @param $operation + * The operation object. + * @param $selection + * An array in the form of $row_index => $entity_id. + * @param $select_all_pages + * Whether all items on all pages should be selected. + */ +function views_bulk_operations_execute($vbo, $operation, $selection, $select_all_pages = FALSE) { + global $user; + + // Determine if the operation needs to be executed directly. + $aggregate = $operation->aggregate(); + $skip_batching = $vbo->get_vbo_option('skip_batching'); + $force_single = $vbo->get_vbo_option('force_single'); + $execute_directly = ($aggregate || $skip_batching || $force_single); + // Try to load all rows without a batch if needed. + if ($execute_directly && $select_all_pages) { + views_bulk_operations_direct_adjust($selection, $vbo); + } + + // Options that affect execution. + $options = array( + 'revision' => $vbo->revision, + 'entity_load_capacity' => $vbo->get_vbo_option('entity_load_capacity', 10), + // The information needed to recreate the view, to avoid serializing the + // whole object. Passed to the executed operation. Also used by + // views_bulk_operations_adjust_selection(). + 'view_info' => array( + 'name' => $vbo->view->name, + 'display' => $vbo->view->current_display, + 'arguments' => $vbo->view->args, + 'exposed_input' => $vbo->view->get_exposed_input(), + ), + ); + // Create an array of rows in the needed format. + $rows = array(); + $current = 1; + foreach ($selection as $row_index => $entity_id) { + $rows[$row_index] = array( + 'entity_id' => $entity_id, + 'views_row' => array(), + // Some operations rely on knowing the position of the current item + // in the execution set (because of specific things that need to be done + // at the beginning or the end of the set). + 'position' => array( + 'current' => $current++, + 'total' => count($selection), + ), + ); + // Some operations require full selected rows. + if ($operation->needsRows()) { + $rows[$row_index]['views_row'] = $vbo->view->result[$row_index]; + } + } + + if ($execute_directly) { + // Execute the operation directly and stop here. + views_bulk_operations_direct_process($operation, $rows, $options); + return; + } + + // Determine the correct queue to use. + if ($operation->getAdminOption('postpone_processing')) { + // Use the site queue processed on cron. + $queue_name = 'views_bulk_operations'; + } + else { + // Use the active queue processed immediately by Batch API. + $queue_name = 'views_bulk_operations_active_queue_' . db_next_id(); + } + + $batch = array( + 'operations' => array(), + 'finished' => 'views_bulk_operations_execute_finished', + 'progress_message' => '', + 'title' => t('Performing %operation on the selected items...', array('%operation' => $operation->label())), + ); + + // All items on all pages should be selected, add a batch job to gather + // and enqueue them. + if ($select_all_pages && $vbo->view->query->pager->has_more_records()) { + $total_rows = $vbo->view->total_rows; + + $batch['operations'][] = array( + 'views_bulk_operations_adjust_selection', array($queue_name, $operation, $options), + ); + } + else { + $total_rows = count($rows); + + // We have all the items that we need, enqueue them right away. + views_bulk_operations_enqueue_rows($queue_name, $rows, $operation, $options); + + // Provide a status message to the user, since this is the last step if + // processing is postponed. + if ($operation->getAdminOption('postpone_processing')) { + drupal_set_message(t('Enqueued the selected operation (%operation).', array( + '%operation' => $operation->label(), + ))); + } + } + + // Processing is not postponed, add a batch job to process the queue. + if (!$operation->getAdminOption('postpone_processing')) { + $batch['operations'][] = array( + 'views_bulk_operations_active_queue_process', array($queue_name, $operation, $total_rows), + ); + } + + // If there are batch jobs to be processed, create the batch set. + if (count($batch['operations'])) { + batch_set($batch); + } +} + +/** + * Batch API callback: loads the view page by page and enqueues all items. + * + * @param $queue_name + * The name of the queue to which the items should be added. + * @param $operation + * The operation object. + * @param $options + * An array of options that affect execution (revision, entity_load_capacity, + * view_info). Passed along with each new queue item. + */ +function views_bulk_operations_adjust_selection($queue_name, $operation, $options, &$context) { + if (!isset($context['sandbox']['progress'])) { + $context['sandbox']['progress'] = 0; + $context['sandbox']['max'] = 0; + } + + $view_info = $options['view_info']; + $view = views_get_view($view_info['name']); + $view->set_exposed_input($view_info['exposed_input']); + $view->set_arguments($view_info['arguments']); + $view->set_display($view_info['display']); + $view->set_offset($context['sandbox']['progress']); + $view->build(); + $view->execute($view_info['display']); + // Note the total number of rows. + if (empty($context['sandbox']['max'])) { + $context['sandbox']['max'] = $view->total_rows; + } + + $vbo = _views_bulk_operations_get_field($view); + $rows = array(); + foreach ($view->result as $row_index => $result) { + $rows[$row_index] = array( + 'entity_id' => $vbo->get_value($result), + 'views_row' => array(), + 'position' => array( + 'current' => ++$context['sandbox']['progress'], + 'total' => $context['sandbox']['max'], + ), + ); + // Some operations require full selected rows. + if ($operation->needsRows()) { + $rows[$row_index]['views_row'] = $result; + } + } + + // Enqueue the gathered rows. + views_bulk_operations_enqueue_rows($queue_name, $rows, $operation, $options); + + if ($context['sandbox']['progress'] != $context['sandbox']['max']) { + // Provide an estimation of the completion level we've reached. + $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max']; + $context['message'] = t('Prepared @current out of @total', array('@current' => $context['sandbox']['progress'], '@total' => $context['sandbox']['max'])); + } + else { + // Provide a status message to the user if this is the last batch job. + if ($operation->getAdminOption('postpone_processing')) { + $context['results']['log'][] = t('Enqueued the selected operation (%operation).', array( + '%operation' => $operation->label(), + )); + } + } +} + +/** + * Divides the passed rows into groups and enqueues each group for processing + * + * @param $queue_name + * The name of the queue. + * @param $rows + * The rows to be enqueued. + * @param $operation + * The object representing the current operation. + * Passed along with each new queue item. + * @param $options + * An array of options that affect execution (revision, entity_load_capacity). + * Passed along with each new queue item. + */ +function views_bulk_operations_enqueue_rows($queue_name, $rows, $operation, $options) { + global $user; + + $queue = DrupalQueue::get($queue_name, TRUE); + $row_groups = array_chunk($rows, $options['entity_load_capacity'], TRUE); + + foreach ($row_groups as $row_group) { + $entity_ids = array(); + foreach ($row_group as $row) { + $entity_ids[] = $row['entity_id']; + } + + $job = array( + 'title' => t('Perform %operation on @type !entity_ids.', array( + '%operation' => $operation->label(), + '@type' => $operation->entityType, + '!entity_ids' => implode(',', $entity_ids), + )), + 'uid' => $user->uid, + 'arguments' => array($row_group, $operation, $options), + ); + $queue->createItem($job); + } +} + +/** + * Batch API callback: processes the active queue. + * + * @param $queue_name + * The name of the queue to process. + * @param $operation + * The object representing the current operation. + * @param $total_rows + * The total number of processable items (across all queue items), used + * to report progress. + * + * @see views_bulk_operations_queue_item_process() + */ +function views_bulk_operations_active_queue_process($queue_name, $operation, $total_rows, &$context) { + static $queue; + + // It is still possible to hit the time limit. + drupal_set_time_limit(0); + + // Prepare the sandbox. + if (!isset($context['sandbox']['progress'])) { + $context['sandbox']['progress'] = 0; + $context['sandbox']['max'] = $total_rows; + $context['results']['log'] = array(); + } + // Instantiate the queue. + if (!isset($queue)) { + $queue = DrupalQueue::get($queue_name, TRUE); + } + + // Process the queue as long as it has items for us. + $queue_item = $queue->claimItem(3600); + if ($queue_item) { + // Process the queue item, and update the progress count. + views_bulk_operations_queue_item_process($queue_item->data, $context['results']['log']); + $queue->deleteItem($queue_item); + + // Provide an estimation of the completion level we've reached. + $context['sandbox']['progress'] += count($queue_item->data['arguments'][0]); + $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max']; + $context['message'] = t('Processed @current out of @total', array('@current' => $context['sandbox']['progress'], '@total' => $context['sandbox']['max'])); + } + + if (!$queue_item || $context['finished'] === 1) { + // All done. Provide a status message to the user. + $context['results']['log'][] = t('Performed %operation on @items.', array( + '%operation' => $operation->label(), + '@items' => format_plural($context['sandbox']['progress'], '1 item', '@count items'), + )); + } +} + +/** + * Processes the provided queue item. + * + * Used as a worker callback defined by views_bulk_operations_cron_queue_info() + * to process the site queue, as well as by + * views_bulk_operations_active_queue_process() to process the active queue. + * + * @param $queue_item_arguments + * The arguments of the queue item to process. + * @param $log + * An injected array of log messages, to be modified by reference. + * If NULL, the function defaults to using watchdog. + */ +function views_bulk_operations_queue_item_process($queue_item_data, &$log = NULL) { + list($row_group, $operation, $options) = $queue_item_data['arguments']; + $account = user_load($queue_item_data['uid']); + $entity_type = $operation->entityType; + $entity_ids = array(); + foreach ($row_group as $row_index => $row) { + $entity_ids[] = $row['entity_id']; + } + + $entities = _views_bulk_operations_entity_load($entity_type, $entity_ids, $options['revision']); + foreach ($row_group as $row_index => $row) { + $entity_id = $row['entity_id']; + // A matching entity couldn't be loaded. Skip this item. + if (!isset($entities[$entity_id])) { + continue; + } + + if ($options['revision']) { + // Don't reload revisions for now, they are not statically cached and + // usually don't run into the edge case described below. + $entity = $entities[$entity_id]; + } + else { + // A previous action might have resulted in the entity being resaved + // (e.g. node synchronization from a prior node in this batch), so try + // to reload it. If no change occurred, the entity will be retrieved + // from the static cache, resulting in no performance penalty. + $entity = entity_load_single($entity_type, $entity_id); + if (empty($entity)) { + // The entity is no longer valid. + continue; + } + } + + // If the current entity can't be accessed, skip it and log a notice. + if (!_views_bulk_operations_entity_access($operation, $entity_type, $entity, $account)) { + $message = 'Skipped %operation on @type %title due to insufficient permissions.'; + $arguments = array( + '%operation' => $operation->label(), + '@type' => $entity_type, + '%title' => entity_label($entity_type, $entity), + ); + + if ($log) { + $log[] = t($message, $arguments); + } + else { + watchdog('views bulk operations', $message, $arguments, WATCHDOG_ALERT); + } + + continue; + } + + $operation_context = array( + 'progress' => $row['position'], + 'view_info' => $options['view_info'], + ); + if ($operation->needsRows()) { + $operation_context['rows'] = array($row_index => $row['views_row']); + } + $operation->execute($entity, $operation_context); + + unset($row_group[$row_index]); + } +} + +/** + * Adjusts the selection for the direct execution method. + * + * Just like the direct method itself, this is legacy code, used only for + * aggregate actions. + */ +function views_bulk_operations_direct_adjust(&$selection, $vbo) { + // Adjust selection to select all rows across pages. + $view = views_get_view($vbo->view->name); + $view->set_exposed_input($vbo->view->get_exposed_input()); + $view->set_arguments($vbo->view->args); + $view->set_display($vbo->view->current_display); + $view->display_handler->set_option('pager', array('type' => 'none', 'options' => array())); + $view->build(); + // Unset every field except the VBO one (which holds the entity id). + // That way the performance hit becomes much smaller, because there is no + // chance of views_handler_field_field::post_execute() firing entity_load(). + foreach ($view->field as $field_name => $field) { + if ($field_name != $vbo->options['id']) { + unset($view->field[$field_name]); + } + } + + $view->execute($vbo->view->current_display); + $results = array(); + foreach ($view->result as $row_index => $result) { + $results[$row_index] = $vbo->get_value($result); + } + $selection = $results; +} + +/** + * Processes the passed rows directly (without batching and queueing). + */ +function views_bulk_operations_direct_process($operation, $rows, $options) { + global $user; + + drupal_set_time_limit(0); + + // Prepare an array of status information. Imitates the Batch API naming + // for consistency. Passed to views_bulk_operations_execute_finished(). + $context = array(); + $context['results']['progress'] = 0; + $context['results']['log'] = array(); + + if ($operation->aggregate()) { + // Load all entities. + $entity_type = $operation->entityType; + $entity_ids = array(); + foreach ($rows as $row_index => $row) { + $entity_ids[] = $row['entity_id']; + } + $entities = _views_bulk_operations_entity_load($entity_type, $entity_ids, $options['revision']); + + // Filter out entities that can't be accessed. + foreach ($entities as $id => $entity) { + if (!_views_bulk_operations_entity_access($operation, $entity_type, $entity)) { + $context['results']['log'][] = t('Skipped %operation on @type %title due to insufficient permissions.', array( + '%operation' => $operation->label(), + '@type' => $entity_type, + '%title' => entity_label($entity_type, $entity), + )); + unset($entities[$id]); + } + } + + // If there are any entities left, execute the operation on them. + if ($entities) { + $operation_context = array( + 'view_info' => $options['view_info'], + ); + // Pass the selected rows to the operation if needed. + if ($operation->needsRows()) { + $operation_context['rows'] = array(); + foreach ($rows as $row_index => $row) { + $operation_context['rows'][$row_index] = $row['views_row']; + } + } + $operation->execute($entities, $operation_context); + } + } + else { + // Imitate a queue and process the entities one by one. + $queue_item_data = array( + 'uid' => $user->uid, + 'arguments' => array($rows, $operation, $options), + ); + views_bulk_operations_queue_item_process($queue_item_data, $context['results']['log']); + } + + $context['results']['progress'] += count($rows); + $context['results']['log'][] = t('Performed %operation on @items.', array( + '%operation' => $operation->label(), + '@items' => format_plural(count($rows), '1 item', '@count items'), + )); + + views_bulk_operations_execute_finished(TRUE, $context['results'], array()); +} + +/** + * Helper function that runs after the execution process is complete. + */ +function views_bulk_operations_execute_finished($success, $results, $operations) { + if ($success) { + if (count($results['log']) > 1) { + $message = theme('item_list', array('items' => $results['log'])); + } + else { + $message = reset($results['log']); + } + } + else { + // An error occurred. + // $operations contains the operations that remained unprocessed. + $error_operation = reset($operations); + $message = t('An error occurred while processing @operation with arguments: @arguments', + array('@operation' => $error_operation[0], '@arguments' => print_r($error_operation[0], TRUE))); + } + + _views_bulk_operations_log($message); +} + +/** + * Helper function to verify access permission to operate on an entity. + */ +function _views_bulk_operations_entity_access($operation, $entity_type, $entity, $account = NULL) { + if (!entity_type_supports($entity_type, 'access')) { + return TRUE; + } + + $access_ops = array( + VBO_ACCESS_OP_VIEW => 'view', + VBO_ACCESS_OP_UPDATE => 'update', + VBO_ACCESS_OP_CREATE => 'create', + VBO_ACCESS_OP_DELETE => 'delete', + ); + foreach ($access_ops as $bit => $op) { + if ($operation->getAccessMask() & $bit) { + if (!entity_access($op, $entity_type, $entity, $account)) { + return FALSE; + } + } + } + + return TRUE; +} + +/** + * Loads multiple entities by their entity or revision ids, and returns them, + * keyed by the id used for loading. + */ +function _views_bulk_operations_entity_load($entity_type, $ids, $revision = FALSE) { + if (!$revision) { + $entities = entity_load($entity_type, $ids); + } + else { + // D7 can't load multiple entities by revision_id. Lovely. + $info = entity_get_info($entity_type); + $entities = array(); + foreach ($ids as $revision_id) { + $loaded_entities = entity_load($entity_type, array(), array($info['entity keys']['revision'] => $revision_id)); + $entities[$revision_id] = reset($loaded_entities); + } + } + + return $entities; +} + +/** + * Helper function to report an error. + */ +function _views_bulk_operations_report_error($msg, $arg) { + watchdog('views bulk operations', $msg, $arg, WATCHDOG_ERROR); + if (function_exists('drush_set_error')) { + drush_set_error('VIEWS_BULK_OPERATIONS_EXECUTION_ERROR', strip_tags(dt($msg, $arg))); + } +} + +/** + * Display a message to the user through the relevant function. + */ +function _views_bulk_operations_log($msg) { + // Is VBO being run through drush? + if (function_exists('drush_log')) { + drush_log(strip_tags($msg), 'ok'); + } + else { + drupal_set_message($msg); + } +} |