From 7f82e1219f44e9511494a3631a0dd2c01d77eae1 Mon Sep 17 00:00:00 2001 From: Leighton Whiting Date: Sun, 18 Nov 2012 12:01:58 -0700 Subject: [PATCH 1/2] Lots of cleanup --- .../modules/project_browser/project_browser.module | 548 +++++++++++++++++++ .../project_browser/project_browser.pages.inc | 557 ++++++++++++++++++++ 2 files changed, 1105 insertions(+), 0 deletions(-) create mode 100644 core/modules/project_browser/project_browser.module create mode 100644 core/modules/project_browser/project_browser.pages.inc diff --git a/core/modules/project_browser/project_browser.module b/core/modules/project_browser/project_browser.module new file mode 100644 index 0000000..e633fe5 --- /dev/null +++ b/core/modules/project_browser/project_browser.module @@ -0,0 +1,548 @@ +' . t("Provides a UI for users to browse for and install new modules and themes from + within their Drupal admin interface.") . '

'; + break; + } + return $output; +} + +/** + * Implements hook_perm(). + */ +function project_browser_permission() { + return array( + 'use project browser' => array( + 'title' => t('Use Project Browser'), + 'description' => t('This allows the user to browse for and install new modules and themes using Project Browser.'), + 'restrict access' => TRUE, + ) + ); +} + +/** + * Implements hook_menu(). + */ +function project_browser_menu() { + $items = array(); + $items['admin/config/development/project_browser'] = array( + 'title' => 'Project Browser settings', + 'description' => 'Add new repositories and set other settings for Project Browser.', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('project_browser_admin'), + 'access arguments' => array('access administration pages'), + 'file' => 'project_browser.admin.inc', + ); + + $items['admin/modules/project-browser'] = array( + 'title' => 'Project Browser', + 'description' => 'Browse and search for new modules', + 'page callback' => 'project_browser_page', + 'page arguments' => array('module'), + 'access arguments' => array('use project browser'), + 'file' => 'project_browser.pages.inc', + ); + + $items['admin/modules/project-browser/modules'] = array( + 'title' => 'Modules', + 'description' => 'Browse and search for new modules', + 'page callback' => 'project_browser_page', + 'page arguments' => array('module'), + 'access arguments' => array('use project browser'), + 'file' => 'project_browser.pages.inc', + 'type' => MENU_DEFAULT_LOCAL_TASK, + ); + + $items['admin/modules/project-browser/themes'] = array( + 'title' => 'Themes', + 'description' => 'Browse and search for new themes', + 'page callback' => 'project_browser_page', + 'page arguments' => array('theme'), + 'access arguments' => array('use project browser'), + 'file' => 'project_browser.pages.inc', + 'type' => MENU_LOCAL_TASK, + ); + + $items['admin/modules/project-browser/install/%'] = array( + 'title' => 'Install', + 'page callback' => 'project_browser_installation_page', + 'page arguments' => array(4), + 'access arguments' => array('use project browser'), + 'type' => MENU_NORMAL_ITEM, + 'file' => 'project_browser.pages.inc', + ); + + $items['project-browser/%/install-queue/%/%'] = array( + 'page callback' => 'project_browser_install_queue_callback', + 'page arguments' => array(1, 3, 4), + 'access arguments' => array('use project browser'), + 'type' => MENU_CALLBACK, + ); + return $items; +} + +/** + * Page callback: Allows for adding to and removing from the install queue. + * + * This is invoked via AJAX most of the time. + * + * @param string $method + * The method used for this callback. + * @param string $op + * The operation to perform. + * @param string $project_name + * The short name of the project. + * + * @return array + * An array of elements that should be changed. + */ +function project_browser_install_queue_callback($method, $op, $project_name) { + module_load_include('inc', 'project_browser', 'project_browser'); + + switch ($op) { + case 'add': + $projects = project_browser_get_listed_projects(); + + if (isset($projects[$project_name])) { + $project = $projects[$project_name]; + project_browser_install_queue_add($project); + } + else { + drupal_set_message(t('Error: The project was not found.'), 'error'); + } + break; + + case 'remove': + project_browser_install_queue_remove($project_name); + break; + } + + switch ($method) { + case 'nojs': + // Redirect to the page it came from. + $redirect = (isset($_GET['destination'])) ? $_GET['destination'] : 'admin/modules/project-browser'; + + drupal_goto($redirect); + break; + case 'ajax': + $commands = array(); + + // Refresh the install queue. + $commands[] = ajax_command_replace('#project-browser-install-queue', project_browser_get_install_list()); + // Refresh the add to queue link. + $commands[] = ajax_command_replace('#add-to-queue-link-' . $project_name, project_browser_add_remove_queue_link($project_name)); + + return array('#type' => 'ajax', '#commands' => $commands); + break; + } +} + +/** + * Implements hook_menu_local_tasks_alter(). + */ +function project_browser_menu_local_tasks_alter(&$data, $router_item, $root_path) { + switch ($root_path) { + // This is used to put the 'Project Browser' action on the 'Modules' page. + case 'admin/modules': + // Unset the install theme page. + foreach ($data['actions']['output'] as $num => $item) { + if ($item['#link']['path'] == 'admin/modules/install') { + unset($data['actions']['output'][$num]); + } + } + $item = menu_get_item('admin/modules/project-browser/modules'); + if ($item['access']) { + $item['title'] = t('Install new modules'); + $data['actions']['output'][] = array( + '#theme' => 'menu_local_action', + '#link' => $item, + ); + } + break; + // This is used to include the old 'Install a module' link + case 'admin/modules/project-browser': + case 'admin/modules/project-browser/modules': + case 'admin/modules/project-browser/themes': + $item = menu_get_item('admin/modules/install'); + if ($item['access']) { + $item['title'] = t('Install manually'); + $data['actions']['output'][] = array( + '#theme' => 'menu_local_action', + '#link' => $item, + ); + } + break; + // This is used to put the 'Project Browser' action on the 'Appearance' + // page. + case 'admin/appearance': + // Unset the install theme page. + foreach ($data['actions']['output'] as $num => $item) { + if ($item['#link']['path'] == 'admin/appearance/install') { + unset($data['actions']['output'][$num]); + } + } + $item = menu_get_item('admin/modules/project-browser/themes'); + if ($item['access']) { + $item['title'] = t('Install new themes'); + $data['actions']['output'][] = array( + '#theme' => 'menu_local_action', + '#link' => $item, + ); + } + break; + } +} + +/** + * Implements hook_theme(). + */ +function project_browser_theme($existing, $type, $theme, $path) { + return array( + // Template for installation page. + 'project_browser_install' => array( + 'variables' => array('current_task' => NULL, 'main_content' => NULL), + 'path' => $path . '/theme', + 'template' => 'project-browser-install', + ), + // Template for list of projects. + 'project_browser_list' => array( + 'variables' => array('projects_list' => NULL, 'type' => NULL), + 'path' => $path . '/theme', + 'template' => 'project-browser-list', + ), + // Template for list of projects. + 'project_browser_block' => array( + 'render element' => 'element', + 'path' => $path . '/theme', + 'template' => 'project-browser-block', + ), + // Template for single project. + 'project_browser_project' => array( + 'variables' => array('project' => NULL, 'first' => NULL), + 'path' => $path . '/theme', + 'template' => 'project-browser-project', + ), + // Template for install queue item. + 'project_browser_install_queue' => array( + 'variables' => array('projects' => NULL), + 'path' => $path . '/theme', + 'template' => 'project-browser-install-queue', + ), + ); +} + +/** + * Implements hook_preprocess_HOOK() for project-browser-install.tpl.php. + * + * Adds some variables for the projects install theme. + * + * @param array $variables + * An associative array containing: + * - current_task : the current task. + * + * @ingroup themeable + */ +function project_browser_preprocess_project_browser_install(&$variables) { + module_load_include('inc', 'project_browser', 'project_browser.pages'); + // Add the themed list + $variables['task_list'] = project_browser_installation_task_list($variables['current_task']); +} + +/** + * Implements hook_preprocess_HOOK() for project-browser-install-queue.tpl.php. + * + * Adds some variables for the projects install queue theme. + * + * @param array $variables + * An associative array containing: + * - projects : an array of projects in the install queue. + * + * @ingroup themeable + */ +function project_browser_preprocess_project_browser_install_queue(&$variables) { + $build = array(); + if (empty($variables['projects'])) { + $build['empty_text'] = array( + '#markup' => t('Install queue is empty.'), + ); + } + else { + foreach ($variables['projects'] as $project) { + $build['queued-item-' . $project['name']] = array( + '#prefix' => "
", + '#markup' => project_browser_add_remove_queue_link($project['name'], $project['title'], 'remove-queue-link'), + '#suffix' => "
", + ); + } + $build['install-link'] = drupal_get_form('project_browser_install_button_form'); + } + + // Add the install button. + $variables['queue_html'] = drupal_render($build); +} + +/** + * Implements hook_preprocess_HOOK() for project-browser-block.tpl.php. + * + * Adds some variables for the project browser block theme. + * + * @param array $variables + * An associative array containing: + * - element['#title'] : the title of the block. + * - element['#content'] : the content of the block. + * + * @ingroup themeable + */ +function project_browser_preprocess_project_browser_block(&$variables) { + // Add the title and content variables. + $variables['title'] = $variables['element']['#title']; + $variables['content'] = $variables['element']['#content']; +} + +/** + * Implements hook_preprocess_HOOK() for project-browser-list.tpl.php. + * + * Adds some variables for the projects list theme. + * + * @param array $variables + * An associative array containing: + * - projects_list : array of all projects. + * + * @ingroup themeable + */ +function project_browser_preprocess_project_browser_list(&$variables) { + module_load_include('inc', 'project_browser', 'project_browser'); + drupal_add_library('project_browser', 'drupal.project_browser.css'); + + if (is_array($variables['projects_list']) AND !empty($variables['projects_list'])) { + $content = ''; + $first = TRUE; + // Theme each individual project and add to the list. + foreach ($variables['projects_list'] as $project) { + $content .= theme('project_browser_project', array('project' => $project, 'first' => $first)); + $first = FALSE; + } + } + else { + $content = t('No results found.'); + } + + switch ($variables['type']) { + case 'module': + $title = t('Modules'); + break; + case 'theme': + $title = t('Themes'); + break; + default: + $title = t('Projects'); + break; + } + + $main_content['project_browser_main_block'] = array( + '#theme' => 'project_browser_block', + '#title' => $title, + '#content' => $content, + '#contextual_links' => array( + 'project_browser' => array('admin/config/development/project_browser', array()), + ), + ); + $variables['main_content'] = render($main_content); + + // Add the pager. + $variables['pager'] = theme('pager', array('tags' => NULL)); + + // Add the filters. + $filters_form = drupal_get_form('project_browser_filters_form', $variables['type']); + $filters['project_browser_filters_block'] = array( + '#theme' => 'project_browser_block', + '#title' => t('Filters'), + '#content' => drupal_render($filters_form), + ); + $variables['filters'] = render($filters); + + // Add the install list. + $install_list['project_browser_filters_block'] = array( + '#theme' => 'project_browser_block', + '#title' => t('Install queue'), + '#content' => project_browser_get_install_list(), + ); + $variables['install_list'] = render($install_list); +} + +/** + * Implements hook_preprocess_HOOK() for project-browser-project.tpl.php. + * + * Adds some variables for the project theme. + * + * @param array $variables + * An associative array containing: + * - project : associative array of project variables. + * + * @ingroup themeable + */ +function project_browser_preprocess_project_browser_project(&$variables) { + module_load_include('inc', 'project_browser', 'project_browser'); + $project = $variables['project']; + + $variables['title'] = l($project['title'], check_url($project['project url']), + array('attributes' => array('target' => '_blank'), 'html' => TRUE)); + $variables['author'] = t('Author: @author', array('@author' => $project['author'])); + $variables['description'] = _filter_htmlcorrector(filter_xss($project['description'])); + $variables['image'] = $project['image']; + $variables['last_updated'] = ($project['last updated']) ? t('Last Updated: @date', array('@date' => format_date($project['last updated'], 'long'))) : ''; + + $extras = array(); + + if ($project['maintenance status']) { + $extras[] = check_plain($project['maintenance status']); + } + if ($project['development status']) { + // We are not showing this because it isn't a good indicator right now. + // $extras[] = check_plain($project['development status']); + } + if ($project['usage'] AND is_numeric($project['usage'])) { + $extras[] = format_plural($project['usage'], '1 Install', '@count Installs'); + } + if ($project['rating']) { + $extras[] = check_plain($project['rating']); + } + + $variables['extras'] = implode(' | ', $extras); + + // Check if the project is installed. + if (_project_browser_is_project_enabled($project['type'], $project['name'])) { + $variables['status'] = '
Already installed
'; + $variables['install'] = ''; + } + elseif (drupal_get_filename($project['type'], $project['name'])) { + $variables['status'] = '
Already downloaded
'; + $variables['install'] = ''; + } + else { + $variables['status'] = ''; + $variables['install'] = project_browser_add_remove_queue_link($project['name']); + } +} + +/** + * Builds the add/remove project to install queue link. + * + * @param string $project_name + * The short name of the project, such as 'views'. + * @param string|null $title + * (Optional) The title of the project. Defaults to NULL. + * @param string $id_prefix + * (Optional) The prefix that should be prepended to the id, to ensure unique + * id names. Defaults to 'add-to-queue-link'. + * + * @return string + * A themed link to remove or add an item from the install queue. + */ +function project_browser_add_remove_queue_link($project_name, $title = NULL, $id_prefix = 'add-to-queue-link') { + $queued_projects = project_browser_get_queued_projects(); + if (!$title) { + $title = isset($queued_projects[$project_name]) ? t('Remove from Install queue') : t('Add to Install queue'); + } + $op = isset($queued_projects[$project_name]) ? 'remove' : 'add'; + + $build['ajax_link'] = array( + '#type' => 'link', + '#title' => $title, + '#href' => 'project-browser/nojs/install-queue/' . $op . '/'. $project_name, + '#options' => array( + 'query' => drupal_get_destination(), + ), + '#id' => $id_prefix . '-' . $project_name, + '#ajax' => array( + 'effect' => 'fade', + 'speed' => 1000, + 'progress' => array( + 'type' => 'throbber', + 'message' => '', + ), + ), + ); + + return drupal_render($build); +} + +/** + * Implements hook_library_info(). + */ +function project_browser_library_info() { + // Adds the multiselect library for use with the categories widget. + $libraries['multiselect'] = array( + 'title' => 'jQuery MultiSelect', + 'website' => 'http://example.com/library-1', + 'version' => '1.10a', + 'js' => array( + drupal_get_path('module', 'project_browser') . '/js/jquery.multiselect.min.js' => array(), + ), + 'css' => array( + drupal_get_path('module', 'project_browser') . '/css/jquery.multiselect.css' => array( + 'type' => 'file', + 'media' => 'screen', + ), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'drupal'), + array('system', 'jquery.ui.widget'), + array('system', 'jquery.ui.dialog'), + array('system', 'jquery.ui.position'), + ), + ); + $libraries['drupal.project_browser'] = array( + 'title' => 'Project Browser', + 'version' => VERSION, + 'js' => array( + drupal_get_path('module', 'project_browser') . '/js/project_browser_more_link.js' => array(), + drupal_get_path('module', 'project_browser') . '/js/project_browser_multiselect.js' => array(), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'drupal'), + array('project_browser', 'multiselect'), + array('project_browser', 'drupal.project_browser.css'), + ), + ); + $libraries['drupal.project_browser.select_releases'] = array( + 'title' => 'Project Browser Select Releases', + 'version' => VERSION, + 'js' => array( + drupal_get_path('module', 'project_browser') . '/js/project_browser_select_releases.js' => array(), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'drupal'), + array('project_browser', 'drupal.project_browser.css'), + ), + ); + $libraries['drupal.project_browser.css'] = array( + 'title' => 'Project Browser CSS', + 'version' => VERSION, + 'css' => array( + drupal_get_path('module', 'project_browser') . '/css/project_browser.admin.css' => array( + 'type' => 'file', + 'media' => 'screen', + ), + ), + ); + return $libraries; +} diff --git a/core/modules/project_browser/project_browser.pages.inc b/core/modules/project_browser/project_browser.pages.inc new file mode 100644 index 0000000..b756199 --- /dev/null +++ b/core/modules/project_browser/project_browser.pages.inc @@ -0,0 +1,557 @@ + $drupal_version[0], + 'type' => $type, + ); + + // Add filters. + if (isset($_SESSION['project_browser_category_filter_' . $type])) { + $categories = array_filter($_SESSION['project_browser_category_filter_' . $type]); + if (!empty($categories)) { + $filters['categories'] = project_browser_prepare_categories($categories, $type); + } + } + if (isset($_SESSION['project_browser_text_filter_' . $type])) { + $filters['text'] = $_SESSION['project_browser_text_filter_' . $type]; + } + if (isset($_SESSION['project_browser_order_by_filter_' . $type])) { + $filters['order_by'] = $_SESSION['project_browser_order_by_filter_' . $type]; + } + if (isset($_SESSION['project_browser_sort_filter_' . $type])) { + $filters['sort'] = $_SESSION['project_browser_sort_filter_' . $type]; + } + if (isset($_SESSION['project_browser_server_filter'])) { + $filters['server'] = $_SESSION['project_browser_server_filter']; + } + else { + $filters['server'] = 0; + } + $filters['requested'] = 10; + $filters['page'] = isset($_GET['page']) ? $_GET['page'] : 0; + + // Get the projects to display here based on the filters. + $results = project_browser_fetch_results($filters); + + // Save the listed projects in the session so it can be used. + $_SESSION['project_browser_listed_projects'] = $results['projects']; + + $test = project_browser_get_listed_projects(); + + $list = array(); + foreach ($results['projects'] as $project) { + $list[] = $project; + } + + // Add the pager. + $total = $results['total']; + $num_per_page = 10; + $page = pager_default_initialize($total, $num_per_page); + $offset = $num_per_page * $page; + $start = ($total) ? $offset + 1 : 0; + $finish = $offset + $num_per_page; + if ($finish > $total) { + $finish = $total; + } + + $sort_options = project_browser_get_sort_options(); + $current_order_by = isset($_SESSION['project_browser_order_by_filter_' . $type]) ? $_SESSION['project_browser_order_by_filter_' . $type] : 'score'; + $current_sort = isset($_SESSION['project_browser_sort_filter_' . $type]) ? $_SESSION['project_browser_sort_filter_' . $type] : 'desc'; + + $build = array(); + $build['content'] = array( + 'project_browser_header' => array( + '#markup' => t('Showing @start to @finish of @total.', array( + '@start' => $start, '@finish' => $finish, '@total' => $total)), + '#weight' => 0, + ), + 'project_browser_sort_header' => array( + '#type' => 'item', + '#weight' => 2, + '#markup' => project_browser_get_sort_widget($sort_options, $current_order_by, $current_sort), + ), + 'project_browser_list' => array( + '#markup' => theme('project_browser_list', array('projects_list' => $list, 'type' => $type)), + '#weight' => 3, + ), + 'pager' => array( + '#theme' => 'pager', + '#weight' => 99, + ), + ); + + $servers = project_browser_get_servers(); + + if (count($servers) > 1) { + $build['content']['project_browser_server_header'] = array( + '#type' => 'item', + '#weight' => 1, + '#markup' => project_browser_get_server_widget($servers, $filters['server']), + ); + } + + $build['#attached']['library'][] = array('project_browser', 'drupal.project_browser'); + + return $build; +} + +/** + * Builds a page from the install process. + * + * @param string $op + * Operation to perform. + * + * @return string + * A themed page from the install process, depending on the $op. + */ +function project_browser_installation_page($op) { + drupal_add_library('project_browser', 'drupal.project_browser.css'); + + switch ($op) { + case 'select_versions': + drupal_set_title(t("Select versions")); + $content = project_browser_installation_select_versions_page(); + break; + case 'install_dependencies': + drupal_set_title(t("Install Dependencies")); + $content = project_browser_installation_install_dependencies_page(); + break; + case 'enable': + drupal_set_title(t("Enable modules")); + $content = project_browser_installation_enable_page(); + break; + } + return theme('project_browser_install', array('current_task' => $op, 'main_content' => drupal_render($content))); +} + +/** + * Page callback: Shows the Select versions installation task. + * + * Shows a form where the user can select which versions to install for each + * project. + * + * @return array + * The form to select the versions of the projects the user wants to install. + * + * @see project_browser_menu() + */ +function project_browser_installation_select_versions_page() { + module_load_include('inc', 'project_browser', 'project_browser'); + // Show a form that lets the user select which version of the projects to + // install. + $queued_projects = project_browser_get_queued_projects(); + unset($_SESSION['project_browser_installed_projects']); + + return drupal_get_form('project_browser_installation_select_versions_form', $queued_projects); +} + +/** + * Form constructor for the select versions form. + * + * @param array $projects + * An array of projects to get the releases for. + * + * @see project_browser_installation_select_versions_form_submit() + * @ingroup forms + */ +function project_browser_installation_select_versions_form($form, &$form_state, $projects) { + module_load_include('inc', 'project_browser', 'project_browser'); + drupal_add_library('project_browser', 'drupal.project_browser.select_releases'); + + $form = array(); + + // First unset any old data. + unset($_SESSION['project_browser_install_releases_list']); + + $form['#tree'] = TRUE; + + $form['releases-header'] = array( + '#type' => 'item', + '#markup' => t("You're about to install:"), + ); + + $form['releases'] = array(); + + foreach ($projects as $project) { + // Get the available releases for this project. + if (!$release_data = project_browser_get_project_release_data($project)) { + drupal_set_message(t('Could not fetch releases for project %project.', + array('%project' => $project['title'])), 'warning'); + watchdog('project_browser', 'Could not fetch releases for project %project.', + array('%project' => $project['title']), WATCHDOG_ERROR); + project_browser_install_queue_remove($project['name']); + continue; + } + + // We use the update module to calculate the recommended version. + $project_data = array( + 'existing_major' => 0, + 'existing_version' => 0, + 'install_type' => '', + ); + module_load_include('inc', 'update', 'update.compare'); + update_calculate_project_update_status($project_data, $release_data); + + $releases_list = array(); + + foreach ($release_data['releases'] as $version => $release) { + $release_title = t("@project @version - @date", array( + '@project' => $project['title'], + '@version' => $release['version'], + '@date' => format_date($release['date'], 'custom', 'M j, Y'), + )); + if (isset($release['terms']['Release type']) AND !empty($release['terms']['Release type'])) { + $release_title .= " (" . implode(', ', $release['terms']['Release type']) . ")"; + } + if (isset($release['release_link'])) { + $releases_list[$version] = l($release_title, $release['release_link']); + } + else { + $releases_list[$version] = $release_title; + } + } + + $form['releases'][$project['name']]['project'] = array( + '#type' => 'value', + '#value' => $project, + ); + + $form['releases'][$project['name']]['release_name'] = array( + '#type' => 'radios', + '#title' => t('Select release for @project', array('@project' => $project['title'])), + '#options' => $releases_list, + '#default_value' => key($releases_list), + '#prefix' => '
', + '#suffix' => '
', + '#attributes' => array( + 'class' => array('project-browser-releases-radios'), + ), + '#required' => TRUE, + ); + $form['releases'][$project['name']]['selected_text'] = array( + '#type' => 'item', + '#prefix' => '
', + '#suffix' => '
', + '#markup' => reset($releases_list), + ); + if (isset($project_data['recommended'])) { + // If there is a recommended release set, then only show it and show the + // jQuery link. + $recommended_releases = array(); + $recommended_releases[$project_data['recommended']] = $releases_list[$project_data['recommended']]; + $form['releases'][$project['name']]['release_name']['#default_value'] = $project_data['recommended']; + $form['releases'][$project['name']]['selected_text']['#markup'] = $releases_list[$project_data['recommended']]; + } + if (count($releases_list) > 1) { + $form['releases'][$project['name']]['selected_text']['#markup'] .= + " (" . t('change release') . ")"; + } + } + + // If there is nothing to install, go to the enable page. + if (empty($form['releases'])) { + drupal_set_message(t('No releases data found for any of the selected projects.'), 'warning'); + drupal_goto('admin/modules/project-browser/install/enable'); + } + + $form['backup_warning'] = array( + '#type' => 'markup', + '#markup' => t('Back up your database and site before you continue. !link.', + array('!link' => l(t('Learn how'), 'http://drupal.org/node/22281'))), + ); + $form['maintenance_mode'] = array( + '#type' => 'checkbox', + '#title' => t('Perform updates with site in maintenance mode (strongly recommended)'), + '#default_value' => TRUE, + ); + + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Install'), + ); + + return $form; +} + +/** + * Form submission handler for project_browser_installation_select_versions_form() + * + * This sets the batch to install the different selected releases one by one. + */ +function project_browser_installation_select_versions_form_submit($form, &$form_state) { + module_load_include('inc', 'project_browser', 'project_browser'); + // Store maintenance_mode setting so we can restore it when done. + $_SESSION['maintenance_mode'] = variable_get('maintenance_mode', FALSE); + if ($form_state['values']['maintenance_mode'] == TRUE) { + variable_set('maintenance_mode', TRUE); + } + + foreach ($form_state['values']['releases'] as $item) { + // Load the selected release. + if ($release = project_browser_get_release($item['release_name'], $item['project'])) { + // Add the release to a session variable. + $_SESSION['project_browser_install_releases_list'][$item['project']['name']] = array( + 'release_name' => $item['release_name'], + 'project' => $item['project'], + ); + } + } + + // Install the projects with batch. + module_load_include('inc', 'update', 'update.manager'); + + $queued_releases = project_browser_get_queued_releases(); + + $operations = array(); + foreach ($queued_releases as $short_name => $info) { + $operations[] = array('_project_browser_batch_install_release', array($info['release_name'], $info['project'])); + } + $batch = array( + 'operations' => $operations, + 'finished' => '_project_browser_batch_install_releases_finished', + 'title' => t('Installing projects'), + 'init_message' => t('Installing modules...'), + 'progress_message' => t('Installed @current out of @total.'), + 'error_message' => t('Installation has encountered an error.'), + 'file' => drupal_get_path('module', 'project_browser') . '/project_browser.inc', + ); + batch_set($batch); +} + +/** + * Page callback: Handles the Install Dependencies installation task. + * + * This shows a form which lets the user select which version of dependencies + * to install. This is only shown if there are missing dependencies. If there + * are no missing dependencies, then we redirect to the enable page. + * + * @return array + * The form array to let the user select the dependencies to install. + * + * @see project_browser_menu() + */ +function project_browser_installation_install_dependencies_page() { + module_load_include('inc', 'project_browser', 'project_browser'); + $projects = project_browser_get_installed_projects(); + $missing = project_browser_get_missing_dependencies($projects); + + if (count($missing) > 0) { + $missing_projects = array(); + // Add the project data in the array as best we can. + foreach ($missing as $project_shortname => $dependencies) { + foreach ($dependencies as $shortname) { + $missing_projects[$shortname] = array( + 'name' => $shortname, + // Missing dependencies only works for projects of type 'module' + 'type' => 'module', + 'title' => $shortname, + ); + } + } + + return drupal_get_form('project_browser_installation_select_versions_form', $missing_projects); + } + else { + drupal_goto('admin/modules/project-browser/install/enable'); + } +} + +/** + * Page callback: Shows the options for the Enable projects installation task. + * + * This shows a form which lets the user enable the newly installed projects. If + * there are unresolved dependencies, then the project is shown with a message + * about why it can't be enabled. This redirects to the project-browser page if + * there were no installed projects. + * + * @return array + * The form array to enable projects, or redirect to + * 'admin/modules/project-browser'. + * + * @see project_browser_menu() + */ +function project_browser_installation_enable_page() { + module_load_include('inc', 'project_browser', 'project_browser'); + $installed_projects = project_browser_get_installed_projects(); + + if (count($installed_projects) > 0) { + return drupal_get_form('project_browser_installation_enable_form', $installed_projects); + } + else { + drupal_goto('admin/modules/project-browser'); + } +} + +/** + * Form constructor for the enable projects form. + * + * @param array $projects + * An array of newly installed projects to enable. + * + * @see project_browser_installation_enable_form_submit() + * @ingroup forms + */ +function project_browser_installation_enable_form($form, &$form_state, $projects) { + $modules = system_rebuild_module_data(); + $form['instructions'] = array( + '#type' => 'item', + '#markup' => t('The projects you selected have been successfully installed. + If you installed any new modules, you may enable them using the form below + or on the main !link page.', array('!link' => l(t('Modules'), 'admin/modules'))), + ); + + $options = array(); + $missing = array(); + + foreach ($projects as $project) { + if ($project['type'] == 'module') { + $dependency_check = TRUE; + $dependencies = array(); + if (isset($modules[$project['name']])) { + foreach ($modules[$project['name']]->info['dependencies'] as $dependency) { + if (isset($modules[$dependency])) { + $dependencies[] = $modules[$dependency]->info['name'] . ' (' . t('Installed') . ')'; + } + else { + $dependency_check = FALSE; + $dependencies[] = $dependency . ' (' . t('Missing') . ')'; + } + } + if ($dependency_check) { + $options[$project['name']] = array( + array('data' => $modules[$project['name']]->info['name']), + array('data' => $modules[$project['name']]->info['version']), + array('data' => implode(', ', $dependencies)), + ); + } + else { + $missing[$project['name']] = array( + array('data' => $modules[$project['name']]->info['name']), + array('data' => $modules[$project['name']]->info['version']), + array('data' => implode(', ', $dependencies)), + ); + } + } + else { + drupal_set_message(t('There was an error getting information for @module', + array('@module' => $project['name'])), 'error'); + } + } + } + + $headers = array( + array('data' => t('Title')), + array('data' => t('Version')), + array('data' => t('Dependencies')), + ); + + if (!empty($options)) { + $form['modules'] = array( + '#type' => 'tableselect', + '#title' => t('Enable modules'), + '#description' => t('Select which modules you would like to enable.'), + '#header' => $headers, + '#options' => $options, + '#empty' => t('No new modules installed.'), + '#multiple' => TRUE, + '#js_select' => TRUE, + '#weight' => 1, + ); + + $form['submit'] = array( + '#type' => 'submit', + '#submit' => array('project_browser_installation_enable_form_submit'), + '#value' => t('Enable modules'), + '#weight' => 99, + ); + } + + if (!empty($missing)) { + $form['missing'] = array( + '#type' => 'item', + '#title' => t('Missing Dependencies'), + '#description' => t('These modules are missing one or more dependencies, + and so cannot be enabled.'), + '#markup' => theme('table', array('header' => $headers, 'rows' => $missing)), + '#weight' => 2, + ); + } + + return $form; +} + +/** + * Enables the selected projects from the enable projects form. + * + * After the selected projects are enabled, we flush all caches and then + * redirect to the modules page. + */ +function project_browser_installation_enable_form_submit($form, &$form_state) { + $enable_queue = array_filter($form_state['values']['modules']); + // Enable these all at once so that dependencies are handled properly. + module_enable($enable_queue); + + drupal_flush_all_caches(); + + drupal_goto('admin/modules'); +} + +/** + * Builds a task list to the sidebar area when installing projects. + * + * This will need to be called from every page of the install process. + * + * @param string $active + * (Optional) Set the active task by key. Defaults to NULL. + * + * @return array + * The themed task list for the install projects process. + */ +function project_browser_installation_task_list($active = NULL) { + // Default list of tasks. + $tasks = array( + 'select_versions' => t('Select versions'), + 'install_dependencies' => t('Install Dependencies'), + 'enable' => t('Enable projects'), + ); + + require_once DRUPAL_ROOT . '/core/includes/theme.maintenance.inc'; + + return theme_task_list(array('items' => $tasks, 'active' => $active)); +} -- 1.7.3.1.msysgit.0 From 67143ce11ebacaad3427c08fca7e33131161c154 Mon Sep 17 00:00:00 2001 From: Leighton Whiting Date: Sun, 18 Nov 2012 12:02:23 -0700 Subject: [PATCH 2/2] Lots of cleanup --- .../project_browser/css/jquery.multiselect.css | 23 + .../project_browser/css/project_browser.admin.css | 203 ++++ core/modules/project_browser/images/arrow-asc.png | 5 + core/modules/project_browser/images/arrow-desc.png | 4 + core/modules/project_browser/images/circle.png | 3 + core/modules/project_browser/images/red-x.png | 7 + .../project_browser/js/jquery.multiselect.min.js | 44 + .../js/project_browser_more_link.js | 42 + .../js/project_browser_multiselect.js | 23 + .../js/project_browser_select_releases.js | 23 + .../project_browser/Tests/ProjectBrowserTest.php | 95 ++ .../project_browser/project_browser.admin.inc | 36 + core/modules/project_browser/project_browser.inc | 987 ++++++++++++++++++++ core/modules/project_browser/project_browser.info | 6 + .../tests/project_browser_test.info | 5 + .../tests/project_browser_test.module | 329 +++++++ .../theme/project-browser-block.tpl.php | 21 + .../theme/project-browser-install-queue.tpl.php | 16 + .../theme/project-browser-install.tpl.php | 22 + .../theme/project-browser-list.tpl.php | 27 + .../theme/project-browser-project.tpl.php | 59 ++ 21 files changed, 1980 insertions(+), 0 deletions(-) create mode 100644 core/modules/project_browser/css/jquery.multiselect.css create mode 100644 core/modules/project_browser/css/project_browser.admin.css create mode 100644 core/modules/project_browser/images/arrow-asc.png create mode 100644 core/modules/project_browser/images/arrow-desc.png create mode 100644 core/modules/project_browser/images/circle.png create mode 100644 core/modules/project_browser/images/red-x.png create mode 100644 core/modules/project_browser/js/jquery.multiselect.min.js create mode 100644 core/modules/project_browser/js/project_browser_more_link.js create mode 100644 core/modules/project_browser/js/project_browser_multiselect.js create mode 100644 core/modules/project_browser/js/project_browser_select_releases.js create mode 100644 core/modules/project_browser/lib/Drupal/project_browser/Tests/ProjectBrowserTest.php create mode 100644 core/modules/project_browser/project_browser.admin.inc create mode 100644 core/modules/project_browser/project_browser.inc create mode 100644 core/modules/project_browser/project_browser.info create mode 100644 core/modules/project_browser/tests/project_browser_test.info create mode 100644 core/modules/project_browser/tests/project_browser_test.module create mode 100644 core/modules/project_browser/theme/project-browser-block.tpl.php create mode 100644 core/modules/project_browser/theme/project-browser-install-queue.tpl.php create mode 100644 core/modules/project_browser/theme/project-browser-install.tpl.php create mode 100644 core/modules/project_browser/theme/project-browser-list.tpl.php create mode 100644 core/modules/project_browser/theme/project-browser-project.tpl.php diff --git a/core/modules/project_browser/css/jquery.multiselect.css b/core/modules/project_browser/css/jquery.multiselect.css new file mode 100644 index 0000000..e8f8e00 --- /dev/null +++ b/core/modules/project_browser/css/jquery.multiselect.css @@ -0,0 +1,23 @@ +.ui-multiselect { padding:2px 0 2px 4px; text-align:left } +.ui-multiselect span.ui-icon { float:right } +.ui-multiselect-single .ui-multiselect-checkboxes input { position:absolute !important; top: auto !important; left:-9999px; } +.ui-multiselect-single .ui-multiselect-checkboxes label { padding:5px !important } + +.ui-multiselect-header { margin-bottom:3px; padding:3px 0 3px 4px } +.ui-multiselect-header ul { font-size:0.9em } +.ui-multiselect-header ul li { float:left; padding:0 10px 0 0; list-style: none; } +.ui-multiselect-header a { text-decoration:none } +.ui-multiselect-header a:hover { text-decoration:underline } +.ui-multiselect-header span.ui-icon { float:left } +.ui-multiselect-header li.ui-multiselect-close { float:right; text-align:right; padding-right:0 } + +.ui-multiselect-menu { display:none; padding:3px; position:absolute; z-index:10000; width: 500px; } +.ui-multiselect-checkboxes { position:relative /* fixes bug in IE6/7 */; overflow-y:scroll } +.ui-multiselect-checkboxes label { cursor:default; display:block; border:1px solid transparent; padding:3px 1px } +.ui-multiselect-checkboxes label input { position:relative; top:1px; margin-right: 5px; } +.ui-multiselect-checkboxes li { float:left; width: 225px; font-size:0.9em; padding-right:3px; list-style: none; } +.ui-multiselect-checkboxes li.ui-multiselect-optgroup-label { text-align:center; font-weight:bold; border-bottom:1px solid } +.ui-multiselect-checkboxes li.ui-multiselect-optgroup-label a { display:block; padding:3px; margin:1px 0; text-decoration:none } + +/* remove label borders in IE6 because IE6 does not support transparency */ +* html .ui-multiselect-checkboxes label { border:none } diff --git a/core/modules/project_browser/css/project_browser.admin.css b/core/modules/project_browser/css/project_browser.admin.css new file mode 100644 index 0000000..cef087e --- /dev/null +++ b/core/modules/project_browser/css/project_browser.admin.css @@ -0,0 +1,203 @@ +#project-browser-install-button-form { + clear: both; + margin: 10px 0 0; +} + +#project-browser-install-queue { + margin: 5px 0 0; +} + +.project-browser-install-queue-item { + margin: 5px 0; +} + +.project-browser-install-queue-items { + clear: both; + margin: 0 0 15px; +} + +.project-browser-install-link { + clear: both; +} + +.project-browser-install-queue-item a { + padding-left: 20px; + background: url("../images/red-x.png") no-repeat scroll left 2px transparent; +} + +.project-browser-selected-release { + display: none; +} + +.project-browser-show-releases-link { + cursor: pointer; +} + +div.item-list ul.project-browser-sort-list, +div.item-list ul.project-browser-servers-list { + margin: 0; +} + +div.item-list ul.project-browser-sort-list li, +div.item-list ul.project-browser-servers-list li { + display: inline; + list-style-image: none; + margin-right: 10px; +} + +div.item-list ul.project-browser-sort-list li a, +div.item-list ul.project-browser-servers-list li a { + color: #0074BD; +} + +div.item-list ul.project-browser-sort-list li.sort-active a, +div.item-list ul.project-browser-servers-list li.server-active a { + color: #000; +} + +div.item-list ul.project-browser-sort-list li.sort-header, +div.item-list ul.project-browser-servers-list li.server-header { + font-weight: bold; +} + +div.item-list ul.project-browser-sort-list li.sort-asc { + background: url("../images/arrow-asc.png") no-repeat scroll right 2px transparent; + padding-right: 15px; +} + +div.item-list ul.project-browser-sort-list li.sort-desc { + background: url("../images/arrow-desc.png") no-repeat scroll right 2px transparent; + padding-right: 15px; +} + +a.show-more { + float: right; +} + +.project-extra { + clear: both; + color: gray; + text-align: right; +} + +.project-author { + color: gray; + font-size: 0.9em; +} + +.project-updated { + color: gray; + font-size: 0.9em; +} + +.project-image { + float: left; + margin-right: 10px; +} + +.project-image img { + max-height: 150px; + max-width: 200px; +} + +#project-browser-main ul, #project-browser-main ol { + list-style-position: inside; +} + +.project-item { + border-top: 1px solid #E0E0D8; + clear: both; + padding: 10px; + position: relative; +} + +.project-item-first { + clear: both; + padding: 10px; + position: relative; +} + +div.project-status { + position: absolute; + right: 10px; + text-align: right; + top: 5px; +} + +div.project-information { + +} + +.project-browser-install-main { + padding: 10px; + float: right; + width: 76%; +} + +.install-disabled { + color: gray; +} + +.install-enabled { + color: green; +} + +.project-browser-install-sidebar-left { + float: left; + width: 19%; + padding: 10px; +} + +div.project-title { + font-size: 18px; +} + +fieldset#edit-category, +fieldset#edit-version { + border: none; +} + +div.install-item-prefix { + float:left; + margin-left: 50px; +} + +#project-browser-main div.form-item-install { + float: right; +} + +#project-browser div.project-browser-region { + min-height: 1px; +} + +#project-browser div#project-browser-main { + width: 75%; + float: left; + margin-right: 1%; +} + +#project-browser div#project-browser-sidebar-right { + width: 23%; + float: right; +} + +#project-browser div.project_browser_block { + margin-bottom: 20px; + border: 1px solid #CCCCCC; +} + +#project-browser .project-browser-region .project_browser_block { + clear: both; +} + +#project-browser div.project_browser_block h2 { + float: none; + font-size: 1em; + margin: 0; + padding: 3px 10px; + background: none repeat scroll 0 0 #E0E0D8; +} + +#project-browser div#project-browser-sidebar-right div.project_browser_block div.content { + padding: 5px 10px; +} diff --git a/core/modules/project_browser/images/arrow-asc.png b/core/modules/project_browser/images/arrow-asc.png new file mode 100644 index 0000000..a3ccabc --- /dev/null +++ b/core/modules/project_browser/images/arrow-asc.png @@ -0,0 +1,5 @@ +PNG + + IHDR H%v?PLTEkR%tRNS@fIDATc`@L L LL + +Li|q)IENDB` \ No newline at end of file diff --git a/core/modules/project_browser/images/arrow-desc.png b/core/modules/project_browser/images/arrow-desc.png new file mode 100644 index 0000000..2edbb17 --- /dev/null +++ b/core/modules/project_browser/images/arrow-desc.png @@ -0,0 +1,4 @@ +PNG + + IHDR H%v?PLTEkR%tRNS@fIDATxc`@0`o``g`` +KzHIENDB` \ No newline at end of file diff --git a/core/modules/project_browser/images/circle.png b/core/modules/project_browser/images/circle.png new file mode 100644 index 0000000..6b2d63f --- /dev/null +++ b/core/modules/project_browser/images/circle.png @@ -0,0 +1,3 @@ +PNG + + IHDR6|JPLTEE<tRNS@fIDAT[c`cfe ( $b IENDB` \ No newline at end of file diff --git a/core/modules/project_browser/images/red-x.png b/core/modules/project_browser/images/red-x.png new file mode 100644 index 0000000..486390c --- /dev/null +++ b/core/modules/project_browser/images/red-x.png @@ -0,0 +1,7 @@ +PNG + + IHDRaIDATxڥ?HBQƣr #p(2hAk +!GP2ISiъ +:W_O"sw^gH^3כT(Ppc"31!h>_af Epd!H ߯! LNj Mp7<ǃpl2LIlo#6; a!vp˅r4 +u +RpNE>yHnlG.RWR B?%a<a2#=4}DrOu_ TWXTPjmA(`%xZZjKjFUtNjX'';GWXCW83p~zo$B{;rtAjX) |!uwsֆhx-=2ةQ-riIMQ(r?6{L~_ͧGIENDB` \ No newline at end of file diff --git a/core/modules/project_browser/js/jquery.multiselect.min.js b/core/modules/project_browser/js/jquery.multiselect.min.js new file mode 100644 index 0000000..bac8630 --- /dev/null +++ b/core/modules/project_browser/js/jquery.multiselect.min.js @@ -0,0 +1,44 @@ +/* + * jQuery MultiSelect UI Widget 1.10a + * Modified by Leighton Whiting for Drupal's Project Browser + * Copyright (c) 2011 Eric Hynds + * + * http://www.erichynds.com/jquery/jquery-ui-multiselect-widget/ + * + * Depends: + * - jQuery 1.4.2+ + * - jQuery UI 1.8 widget factory + * + * Optional: + * - jQuery UI effects + * - jQuery UI position utility + * + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + * + */ + +(function($,undefined){var multiselectID=0;$.widget("ech.multiselect",{options:{header:true,height:175,minWidth:225,classes:'',checkAllText:'Check all',uncheckAllText:'Uncheck all',noneSelectedText:'Select options',selectedText:'# selected',selectedList:0,show:'',hide:'',autoOpen:false,multiple:true,position:{}},_create:function(){var el=this.element.hide(),o=this.options;this.speed=$.fx.speeds._default;this._isOpen=false;var +button=(this.button=$('')).addClass('ui-multiselect ui-widget ui-state-default ui-corner-all').addClass(o.classes).attr({'title':el.attr('title'),'aria-haspopup':true,'tabIndex':el.attr('tabIndex')}).insertAfter(el),buttonlabel=(this.buttonlabel=$('')).html(o.noneSelectedText).appendTo(button),menu=(this.menu=$('
')).addClass('ui-multiselect-menu ui-widget ui-widget-content ui-corner-all').addClass(o.classes).insertAfter(button),header=(this.header=$('
')).addClass('ui-widget-header ui-corner-all ui-multiselect-header ui-helper-clearfix').appendTo(menu),headerLinkContainer=(this.headerLinkContainer=$('
    ')).addClass('ui-helper-reset').html(function(){if(o.header===true){return'
  • '+o.checkAllText+'
  • '+o.uncheckAllText+'
  • ';}else if(typeof o.header==="string"){return'
  • '+o.header+'
  • ';}else{return'';}}).append('
  • ').appendTo(header),checkboxContainer=(this.checkboxContainer=$('
      ')).addClass('ui-multiselect-checkboxes ui-helper-reset').appendTo(menu);this._bindEvents();this.refresh(true);if(!o.multiple){menu.addClass('ui-multiselect-single');}},_init:function(){if(this.options.header===false){this.header.hide();} +if(!this.options.multiple){this.headerLinkContainer.find('.ui-multiselect-all, .ui-multiselect-none').hide();} +if(this.options.autoOpen){this.open();} +if(this.element.is(':disabled')){this.disable();}},refresh:function(init){var el=this.element,o=this.options,menu=this.menu,checkboxContainer=this.checkboxContainer,optgroups=[],html=[],id=el.attr('id')||multiselectID++;this.element.find('option').each(function(i){var $this=$(this),parent=this.parentNode,title=this.innerHTML,value=this.value,inputID=this.id||'ui-multiselect-'+id+'-option-'+i,isDisabled=this.disabled,isSelected=this.selected,labelClasses=['ui-corner-all'],optLabel;if(parent.tagName.toLowerCase()==='optgroup'){optLabel=parent.getAttribute('label');if($.inArray(optLabel,optgroups)===-1){html.push('
    • '+optLabel+'
    • ');optgroups.push(optLabel);}} +if(isDisabled){labelClasses.push('ui-state-disabled');} +if(isSelected&&!o.multiple){labelClasses.push('ui-state-active');} +html.push('
    • ');html.push('
    • ');});checkboxContainer.html(html.join(''));this.labels=menu.find('label');this._setButtonWidth();this._setMenuWidth();this.button[0].defaultValue=this.update();if(!init){this._trigger('refresh');}},update:function(){var o=this.options,$inputs=this.labels.find('input'),$checked=$inputs.filter(':checked'),numChecked=$checked.length,value;if(numChecked===0){value=o.noneSelectedText;}else{if($.isFunction(o.selectedText)){value=o.selectedText.call(this,numChecked,$inputs.length,$checked.get());}else if(/\d/.test(o.selectedList)&&o.selectedList>0&&numChecked<=o.selectedList){value=$checked.map(function(){return this.title;}).get().join(', ');}else{value=o.selectedText.replace('#',numChecked).replace('#',$inputs.length);}} +this.buttonlabel.html(value);return value;},_bindEvents:function(){var self=this,button=this.button;function clickHandler(){self[self._isOpen?'close':'open']();return false;} +button.find('span').bind('click.multiselect',clickHandler);button.bind({click:clickHandler,keypress:function(e){switch(e.which){case 27:case 38:case 37:self.close();break;case 39:case 40:self.open();break;}},mouseenter:function(){if(!button.hasClass('ui-state-disabled')){$(this).addClass('ui-state-hover');}},mouseleave:function(){$(this).removeClass('ui-state-hover');},focus:function(){if(!button.hasClass('ui-state-disabled')){$(this).addClass('ui-state-focus');}},blur:function(){$(this).removeClass('ui-state-focus');}});this.header.delegate('a','click.multiselect',function(e){if($(this).hasClass('ui-multiselect-close')){self.close();}else{self[$(this).hasClass('ui-multiselect-all')?'checkAll':'uncheckAll']();} +e.preventDefault();});this.menu.delegate('li.ui-multiselect-optgroup-label a','click.multiselect',function(e){e.preventDefault();var $this=$(this),$inputs=$this.parent().nextUntil('li.ui-multiselect-optgroup-label').find('input:visible:not(:disabled)'),nodes=$inputs.get(),label=$this.parent().text();if(self._trigger('beforeoptgrouptoggle',e,{inputs:nodes,label:label})===false){return;} +self._toggleChecked($inputs.filter(':checked').length!==$inputs.length,$inputs);self._trigger('optgrouptoggle',e,{inputs:nodes,label:label,checked:nodes[0].checked});}).delegate('label','mouseenter.multiselect',function(){if(!$(this).hasClass('ui-state-disabled')){self.labels.removeClass('ui-state-hover');$(this).addClass('ui-state-hover').find('input').focus();}}).delegate('label','keydown.multiselect',function(e){e.preventDefault();switch(e.which){case 9:case 27:self.close();break;case 38:case 40:case 37:case 39:self._traverse(e.which,this);break;case 13:$(this).find('input')[0].click();break;}}).delegate('input[type="checkbox"], input[type="radio"]','click.multiselect',function(e){var $this=$(this),val=this.value,checked=this.checked,tags=self.element.find('option');if(this.disabled||self._trigger('click',e,{value:val,text:this.title,checked:checked})===false){e.preventDefault();return;} +$this.attr('aria-selected',checked);tags.each(function(){if(this.value===val){this.selected=checked;}else if(!self.options.multiple){this.selected=false;}});if(!self.options.multiple){self.labels.removeClass('ui-state-active');$this.closest('label').toggleClass('ui-state-active',checked);self.close();} +setTimeout($.proxy(self.update,self),10);});$(document).bind('mousedown.multiselect',function(e){if(self._isOpen&&!$.contains(self.menu[0],e.target)&&!$.contains(self.button[0],e.target)&&e.target!==self.button[0]){self.close();}});$(this.element[0].form).bind('reset.multiselect',function(){setTimeout(function(){self.update();},10);});},_setButtonWidth:function(){var width=this.element.outerWidth(),o=this.options;this.button.width(width);},_setMenuWidth:function(){var m=this.menu,width=this.element.outerWidth(),o=this.options;if(/\d/.test(o.minWidth)&&width-1){self._toggleCheckbox('selected',flag).call(this);}});},_toggleDisabled:function(flag){this.button.attr({'disabled':flag,'aria-disabled':flag})[flag?'addClass':'removeClass']('ui-state-disabled');this.menu.find('input').attr({'disabled':flag,'aria-disabled':flag}).parent()[flag?'addClass':'removeClass']('ui-state-disabled');this.element.attr({'disabled':flag,'aria-disabled':flag});},open:function(e){var self=this,button=this.button,menu=this.menu,speed=this.speed,o=this.options;if(this._trigger('beforeopen')===false||button.hasClass('ui-state-disabled')||this._isOpen){return;} +var $container=menu.find('ul:last'),effect=o.show,pos=button.position();if($.isArray(o.show)){effect=o.show[0];speed=o.show[1]||self.speed;} +$container.scrollTop(0).height(o.height);if($.ui.position&&!$.isEmptyObject(o.position)){o.position.of=o.position.of||button;menu.show().position(o.position).hide().show(effect,speed);}else{menu.css({top:pos.top+button.outerHeight(),left:pos.left}).show(effect,speed);} +this.labels.eq(0).trigger('mouseover').trigger('mouseenter').find('input').trigger('focus');button.addClass('ui-state-active');this._isOpen=true;this._trigger('open');},close:function(){if(this._trigger('beforeclose')===false){return;} +var o=this.options,effect=o.hide,speed=this.speed;if($.isArray(o.hide)){effect=o.hide[0];speed=o.hide[1]||this.speed;} +this.menu.hide(effect,speed);this.button.removeClass('ui-state-active').trigger('blur').trigger('mouseleave');this._isOpen=false;this._trigger('close');},enable:function(){this._toggleDisabled(false);},disable:function(){this._toggleDisabled(true);},checkAll:function(e){this._toggleChecked(true);this._trigger('checkAll');},uncheckAll:function(){this._toggleChecked(false);this._trigger('uncheckAll');},getChecked:function(){return this.menu.find('input').filter(':checked');},destroy:function(){$.Widget.prototype.destroy.call(this);this.button.remove();this.menu.remove();this.element.show();return this;},isOpen:function(){return this._isOpen;},widget:function(){return this.menu;},_setOption:function(key,value){var menu=this.menu;switch(key){case'header':menu.find('div.ui-multiselect-header')[value?'show':'hide']();break;case'checkAllText':menu.find('a.ui-multiselect-all span').eq(-1).text(value);break;case'uncheckAllText':menu.find('a.ui-multiselect-none span').eq(-1).text(value);break;case'height':menu.find('ul:last').height(parseInt(value,10));break;case'minWidth':this.options[key]=parseInt(value,10);this._setButtonWidth();this._setMenuWidth();break;case'selectedText':case'selectedList':case'noneSelectedText':this.options[key]=value;this.update();break;case'classes':menu.add(this.button).removeClass(this.options.classes).addClass(value);break;} +$.Widget.prototype._setOption.apply(this,arguments);}});})(jQuery); diff --git a/core/modules/project_browser/js/project_browser_more_link.js b/core/modules/project_browser/js/project_browser_more_link.js new file mode 100644 index 0000000..b57f3f7 --- /dev/null +++ b/core/modules/project_browser/js/project_browser_more_link.js @@ -0,0 +1,42 @@ +/** + * @file + * Makes the 'More' button toggle the display of the full project texts. + * + * The project descriptions are by default trimmed to a certain height. When + * the user clicks the more link, then the full text is shown. + */ + +(function ($) { + +"use strict"; + +Drupal.behaviors.projectBrowserMoreLink = { + attach: function (context, settings) { + // The height of the content block when it's not expanded + var adjustheight = 80; + // The "more" link text + var moreText = Drupal.t('More'); + // The "less" link text + var lessText = Drupal.t('Less'); + + $(".project-information .project-description").each(function(index) { + if ($(this).height() > adjustheight) + { + $(this).css('height', adjustheight).css('overflow', 'hidden'); + $(this).parents(".project-information").append(''); + } + }); + + $("a.show-more").text(moreText); + + $(".show-more").toggle(function() { + $(this).parents("div:first").find(".project-description").css('height', 'auto').css('overflow', 'visible'); + $(this).text(lessText); + }, function() { + $(this).parents("div:first").find(".project-description").css('height', adjustheight).css('overflow', 'hidden'); + $(this).text(moreText); + }); + } +}; + +})(jQuery); diff --git a/core/modules/project_browser/js/project_browser_multiselect.js b/core/modules/project_browser/js/project_browser_multiselect.js new file mode 100644 index 0000000..ff55c8b --- /dev/null +++ b/core/modules/project_browser/js/project_browser_multiselect.js @@ -0,0 +1,23 @@ +/** + * @file + * This adds the widget to the multi-select element used for Categories + */ +(function ($) { + +"use strict"; + +Drupal.behaviors.projectBrowserMultiselect = { + attach: function (context, settings) { + $('#edit-categories').multiselect({ + noneSelectedText: Drupal.t("Choose"), + selectedList: 99, + minWidth: 500, + position: { + my: 'right top', + at: 'right bottom' + } + }); + } +}; + +})(jQuery); diff --git a/core/modules/project_browser/js/project_browser_select_releases.js b/core/modules/project_browser/js/project_browser_select_releases.js new file mode 100644 index 0000000..1a8cb18 --- /dev/null +++ b/core/modules/project_browser/js/project_browser_select_releases.js @@ -0,0 +1,23 @@ +/** + * @file + * This makes the Select Releases page show the default one and have the rest + * remain hidden until the 'Show All Releases' link is clicked + */ +(function ($) { + +"use strict"; + +Drupal.behaviors.projectBrowserSelectReleases = { + attach: function (context, settings) { + $('.project-browser-releases-wrapper').hide(); + $('.project-browser-selected-release').show(); + + $('.project-browser-show-releases-link').click(function() { + var target = $(this).attr('rel'); + $('.project-browser-release-' + target).show(); + $('.project-browser-selected-release-' + target).hide(); + }) + } +}; + +})(jQuery); diff --git a/core/modules/project_browser/lib/Drupal/project_browser/Tests/ProjectBrowserTest.php b/core/modules/project_browser/lib/Drupal/project_browser/Tests/ProjectBrowserTest.php new file mode 100644 index 0000000..aed6c38 --- /dev/null +++ b/core/modules/project_browser/lib/Drupal/project_browser/Tests/ProjectBrowserTest.php @@ -0,0 +1,95 @@ + 'Project Browser Install Project Test', + 'description' => 'Attempts to install a project.', + 'group' => 'Project Browser', + ); + } + + function setUp() { + parent::setUp(); + + // Set the default server variable. + $server_url = url('project_browser_test/query', array('absolute' => TRUE)); + variable_set('project_browser_default_server', array( + $server_url => array( + 'name' => 'Test Server', + 'method' => 'json', + ), + )); + + // Create and log in our privileged user. + $this->privileged_user = $this->drupalCreateUser(array( + 'use project browser', + )); + $this->drupalLogin($this->privileged_user); + } + + public function testProjectBrowserSearchViews() { + // Create node to edit. + $edit = array(); + $edit['search_text'] = 'views'; + $this->drupalPost('admin/modules/project-browser/modules', $edit, t('Filter')); + $this->assertText('Showing 1 to'); + } + + public function testProjectBrowserGetProjects() { + // Attempt to fetch the default projects. + $edit = array(); + $edit['search_text'] = ''; + $this->drupalPost('admin/modules/project-browser/modules', $edit, t('Filter')); + $this->assertText('Showing 1 to'); + } + + public function testProjectBrowserProjectEnabled() { + // Make sure project enabled detection works. + module_load_include('inc', 'project_browser', 'project_browser'); + $this->assertTrue(_project_browser_is_project_enabled('module', 'project_browser'), + t('Make sure project enabled detection works.')); + } + + public function testProjectBrowserAddRemoveQueue() { + // Refresh the page. + $this->drupalGet('admin/modules/project-browser/modules'); + + // Simulate adding a project to the install queue. + $this->drupalGet('project-browser/nojs/install-queue/add/views', + array('query' => array('destination' => 'admin/modules/project-browser'))); + $this->assertNoText('Install queue is empty.'); + $this->assertNoText('Error: The project was not found.'); + + // Simulate removing a project from the install queue. + $this->drupalGet('project-browser/nojs/install-queue/remove/views', + array('query' => array('destination' => 'admin/modules/project-browser'))); + $this->assertText('Install queue is empty.'); + $this->assertNoText('Error: The project was not found.'); + } +} +?> diff --git a/core/modules/project_browser/project_browser.admin.inc b/core/modules/project_browser/project_browser.admin.inc new file mode 100644 index 0000000..b492d06 --- /dev/null +++ b/core/modules/project_browser/project_browser.admin.inc @@ -0,0 +1,36 @@ + 'fieldset', + '#title' => t('Main settings'), + '#collapsible' => FALSE, + '#collapsed' => FALSE, + ); + // Because this is a pluggable system, there can be other repositories besides + // Drupal.org. + $form['main']['project_browser_servers'] = array( + '#type' => 'textarea', + '#title' => t('Repositories'), + '#default_value' => variable_get('project_browser_servers', ''), + '#description' => t("Add new repositories to use for the Project Browser, one per line, in + the 'url|method|Site Name' format. Drupal.org is added by default, and doesn't need to be + set here."), + '#required' => FALSE, + ); + + return system_settings_form($form); +} diff --git a/core/modules/project_browser/project_browser.inc b/core/modules/project_browser/project_browser.inc new file mode 100644 index 0000000..7fcb39e --- /dev/null +++ b/core/modules/project_browser/project_browser.inc @@ -0,0 +1,987 @@ + $queued_projects)); +} + +/** + * Form constructor for the install button for the Install Queue block. + * + * Since the selected projects are stored in the $_SESSION variable, + * no real processing is done, we just redirect to the install/select_versions + * page. + * + * @ingroup forms + */ +function project_browser_install_button_form($form, &$form_state) { + $form['#attributes']['id'] = 'project-browser-install-button-form'; + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Install', + ); + + $form['#action'] = url('admin/modules/project-browser/install/select_versions'); + + return $form; +} + +/** + * Form constructor for the filters form. + * + * This includes categories and the string search box, and the $type is stored. + * + * @param string $type + * The type of project (module or theme). + * + * @see project_browser_filters_form_submit() + * @ingroup forms + */ +function project_browser_filters_form($form, &$form_state, $type) { + $form['search_text'] = array( + '#type' => 'textfield', + '#size' => '25', + '#title' => t('Search String'), + '#default_value' => isset($_SESSION['project_browser_text_filter_' . $type]) ? $_SESSION['project_browser_text_filter_' . $type] : '', + ); + + // Add the categories filter if there are categories. + if ($categories = project_browser_get_categories($type)) { + $form['categories'] = array( + '#type' => 'select', + '#title' => t('Categories'), + '#multiple' => TRUE, + '#options' => $categories, + '#prefix' => '
      ', + '#suffix' => '
      ', + '#default_value' => isset($_SESSION['project_browser_category_filter_' . $type]) ? $_SESSION['project_browser_category_filter_' . $type] : array(), + ); + } + + $form['project_type'] = array( + '#type' => 'value', + '#value' => $type, + ); + + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Filter'), + ); + + return $form; +} + +/** + * Builds a themed sort widget for the results. + * + * These are links which can be clicked/toggled to select and change direction. + * + * @param array $sort_options + * An array of sort options. + * @param string $current_sort_option + * The currently selected sort option. + * @param string $current_sort_direction + * The currently selected sort direction. + * + * @return string + * A themed list of sort options. + */ +function project_browser_get_sort_widget($sort_options, $current_sort_option, $current_sort_direction) { + $sort_list = array(); + $sort_list[] = array('data' => t('Sort by:'), 'class' => array('sort-header')); + $current_path = drupal_get_path_alias(current_path()); + + foreach ($sort_options as $sort_option) { + $classes = array(); + $query = array( + 'order_by' => $sort_option['method'], + 'sort' => $sort_option['default_sort'], + ); + + // If the sort option is currently active, handle it differently. + if ($current_sort_option == $sort_option['method']) { + $classes[] = 'sort-active'; + $classes[] = 'sort-' . $current_sort_direction; + + // Set the direction of the sort link to the opposite of what it + // currently is. + if ($current_sort_direction == $query['sort']) { + if ($query['sort'] == 'desc') { + $query['sort'] = 'asc'; + } + else { + $query['sort'] = 'desc'; + } + } + } + else { + $classes[] = 'sort-inactive'; + } + + $sort_list[] = array( + 'data' => l($sort_option['name'], $current_path, array('query' => $query, 'class' => array())), + 'class' => $classes, + ); + } + + return theme('item_list', array( + 'items' => $sort_list, + 'type' => 'ul', + 'attributes' => array('class' => array('project-browser-sort-list')))); +} + +/** + * Builds a themed widget to select the server. + * + * This is only called if there are more than one server enabled in the + * settings. + * + * @param array $servers + * An array of servers that should be available as options. + * @param string $current_server + * The currently selected server. + * + * @return string + * A themed server select widget. + */ +function project_browser_get_server_widget($servers, $current_server) { + $list = array(); + $list[] = array('data' => t('Repository:'), 'class' => array('server-header')); + $current_path = drupal_get_path_alias($_GET['q']); + + $i = 0; + + foreach ($servers as $url => $server) { + $classes = array(); + $query = array( + 'repository' => $i, + ); + + // If the sort option is currently active, handle it differently. + if ($current_server == $i) { + $classes[] = 'server-active'; + } + else { + $classes[] = 'server-inactive'; + } + + $list[] = array( + 'data' => l($server['name'], $current_path, array('query' => $query, 'class' => array())), + 'class' => $classes, + ); + + $i += 1; + } + + return theme('item_list', array( + 'items' => $list, + 'type' => 'ul', + 'attributes' => array('class' => array('project-browser-servers-list')))); +} + + +/** + * Builds and returns an array of sort options, keyed by method. + * + * @param bool $full + * (Optional) Set this to TRUE if you want to get all of the supported sort + * methods. Defaults to FALSE. + * + * @return array + * An array of sort options, keyed by method. + */ +function project_browser_get_sort_options($full = FALSE) { + $sort_options = array( + 'score' => array('method' => 'score', 'name' => t('Relevancy'), 'default_sort' => 'desc'), + 'usage' => array('method' => 'usage', 'name' => t('Most installed'), 'default_sort' => 'desc'), + 'title' => array('method' => 'title', 'name' => t('Title'), 'default_sort' => 'asc'), + 'name' => array('method' => 'name', 'name' => t('Author'), 'default_sort' => 'asc'), + 'latest_release' => array('method' => 'latest_release', 'name' => t('Latest release'), 'default_sort' => 'desc'), + ); + + if ($full) { + $sort_options['type'] = array('method' => 'type', 'name' => t('Type'), 'default_sort' => 'asc'); + $sort_options['created'] = array('method' => 'created', 'name' => t('Date created'), 'default_sort' => 'asc'); + $sort_options['latest_activity'] = array('method' => 'latest_activity', 'name' => t('Latest build'), 'default_sort' => 'desc'); + } + + return $sort_options; +} + +/** + * Form submission handler for project_browser_filters_form(). + * + * All that we do here is store the selected categories and search string + * in the $_SESSION variable. + */ +function project_browser_filters_form_submit($form, &$form_state) { + $type = $form_state['values']['project_type']; + if (isset($form_state['values']['categories'])) { + $_SESSION['project_browser_category_filter_' . $type] = $form_state['values']['categories']; + } + else { + $_SESSION['project_browser_category_filter_' . $type] = array(); + } + $_SESSION['project_browser_text_filter_' . $type] = $form_state['values']['search_text']; +} + +/** + * Builds an array of all available categories for a project type. + * + * @param string $type + * The type of project to get the categories for. Example: 'module' or + * 'theme'. + * + * @return array|false + * Array containing all available categories or FALSE if no categories. + */ +function project_browser_get_categories($type) { + $categories = array(); + + // Get which server to use from $_SESSION. + if (isset($_SESSION['project_browser_server_filter'])) { + $use_server = $_SESSION['project_browser_server_filter']; + } + else { + $use_server = 0; + } + + $categories_raw = project_browser_fetch_categories($type, $use_server); + + if (is_array($categories_raw) AND !empty($categories_raw)) { + foreach ($categories_raw as $url => $cats) { + foreach ($cats as $key => $value) { + // Create a new key so that there are no duplicate categories from + // different sites. + $new_key = preg_replace('/[^a-z0-9_]+/', '_', strtolower($value)); + $categories[$new_key] = $value; + } + } + } + + if (is_array($categories) AND !empty($categories)) { + ksort($categories); + + return $categories; + } + return FALSE; +} + +/** + * Prepares the categories for sending to the servers as filters. + * + * @param array $raw_cats + * An array of categories from $form_state['values']. + * @param string $type + * The type of project to prepare the categories for, eg 'module' or 'theme'. + * + * @return array + * An array of server categories, keyed by server url. + */ +function project_browser_prepare_categories($raw_cats, $type) { + $categories = project_browser_fetch_categories($type); + + // Set the value of the categories to true if it is selected. + foreach ($categories as $url => $cats) { + foreach ($cats as $key => $value) { + $new_key = preg_replace('/[^a-z0-9_]+/', '_', strtolower($value)); + if (isset($raw_cats[$new_key]) AND $raw_cats[$new_key]) { + $categories[$url][$key] = TRUE; + } + else { + unset($categories[$url][$key]); + } + } + + // Unset the parent if there are no children. + if (empty($categories[$url])) { + unset($categories[$url]); + } + } + + return $categories; +} + +/** + * Checks if a project is enabled. + * + * @param string $type + * The type of project. Could be 'theme' or 'module'. + * @param string $name + * The short name of the project. + * + * @return bool + * TRUE if the project is enabled, FALSE otherwise. + */ +function _project_browser_is_project_enabled($type, $name) { + switch ($type) { + case 'module': + return module_exists($name); + break; + case 'theme': + $themes = list_themes(); + return isset($themes[$name]); + break; + } + return FALSE; +} + +/** + * Gets the currently listed projects from the session. + * + * @return array + * An array of listed projects from the $_SESSION variable. + */ +function project_browser_get_listed_projects() { + if (isset($_SESSION['project_browser_listed_projects'])) { + return $_SESSION['project_browser_listed_projects']; + } + + return array(); +} + +/** + * Gets the currently queued projects from the $_SESSION variable. + * + * @param string|null $type + * (Optional) The type of project (module or theme). Defaults to NULL, which + * will return projects of all types. + * + * @return array + * An array of projects that are queued for install. + */ +function project_browser_get_queued_projects($type = NULL) { + $projects = array(); + + if (isset($_SESSION['project_browser_install_list'])) { + foreach ($_SESSION['project_browser_install_list'] as $project) { + if (is_array($project) AND !empty($project)) { + if (isset($type) AND $type != $project['type']) { + continue; + } + else { + $projects[$project['name']] = $project; + } + } + } + } + + return $projects; +} + +/** + * Gets a release from a project and a release_name. + * + * @param string $release_name + * The name of the release, such as '7.x-1.2'. + * @param array $project + * The $project data array. + * + * @return array|false + * The release data array or FALSE if the release doesn't exist. + */ +function project_browser_get_release($release_name, $project) { + $release_data = project_browser_get_project_release_data($project); + + return isset($release_data['releases'][$release_name]) ? $release_data['releases'][$release_name] : FALSE; +} + +/** + * Gets the newly installed projects from the session. + * + * @return array + * An array of all of the newly installed projects. + */ +function project_browser_get_installed_projects() { + $projects = array(); + + if (isset($_SESSION['project_browser_installed_projects'])) { + foreach ($_SESSION['project_browser_installed_projects'] as $project) { + if (is_array($project) AND !empty($project)) { + $projects[$project['name']] = $project; + } + } + } + + return $projects; +} + +/** + * Adds a project to the install queue $_SESSION variable. + * + * @param array $project + * An array of $project data for a single project. + */ +function project_browser_install_queue_add($project) { + $_SESSION['project_browser_install_list'][$project['name']] = $project; +} + +/** + * Removes a project from the install queue $_SESSION variable. + * + * @param string $project_name + * The name of the project to remove, such as 'views'. + */ +function project_browser_install_queue_remove($project_name) { + if (isset($_SESSION['project_browser_install_list'][$project_name])) { + unset($_SESSION['project_browser_install_list'][$project_name]); + } +} + +/** + * Gets the currently queued releases from the $_SESSION variable. + * + * @return array + * An array of the currently selected releases. + */ +function project_browser_get_queued_releases() { + $releases = array(); + + if (isset($_SESSION['project_browser_install_releases_list'])) { + foreach ($_SESSION['project_browser_install_releases_list'] as $short_name => $info) { + if (is_array($info['project']) AND !empty($info['project'])) { + $releases[$short_name] = $info; + } + } + } + + return $releases; +} + +/** + * Fetches results from the servers based on the parameters passed in. + * + * @param array $filters + * An associative array of queries to use to filter results, containing: + * - 'version': The Major Version of Drupal that is running on the Client. + * Example: '7' or '8'. + * - 'text': The text that was entered as the search query, or '' if none. + * Example: 'Link field'. + * - 'categories': An array of categories that were selected, if any. + * - 'type': The type of project being searched for. Example: 'module' or + * 'theme'. + * - 'page': The zero-based page number. + * - 'requested': How many results are requested per page. + * + * @return array + * Returns an array of results formatted like: + * - 'total': The total number of results found for the filters. + * - 'projects': An array of projects returned for this page request, + * containing: + * - 'KEY': A project array keyed by the machine name: + * - 'type': The type of project this is. Can be 'module' or 'theme'. + * - 'title': The title of the project. + * - 'name': The machine name of the project. + * - 'author': The author's name. + * - 'description': Long project description text. + * - 'image': Absolute url to the image, if any. + * - 'usage': How many sites are using module. + * - 'project url': Absolute url to the project page, if any. + * - 'project status url': The absolute url of the update checker, + * formatted like how Drupal.org Update Status does it. + * - 'last updated': UNIX Timestamp of when the project was last updated. + * - 'maintenance status': Maintenance status. + * - 'development status': Development status. + * - 'rating': A rating on a scale of 1 to 10 of the project, if available + * - 'dependencies': An array of the dependencies of this module, by + * project shortname, if available. + */ +function project_browser_fetch_results($filters) { + $servers = project_browser_get_servers($filters['server']); + // Attempt to retrieve the cached version of this page. + $cid = md5(serialize(array_merge($filters, $servers))); + + if ($cache = cache()->get($cid)) { + return $cache->data; + } + + $results = array( + 'projects' => array(), + 'total' => 0, + ); + + unset($filters['server']); + + foreach ($servers as $url => $server) { + $local_filters = $filters; + + // We are not using this right now because we only expect to handle 1 + // server at a time currently. + // $local_filters['requested'] = floor($filters['requested'] / count($servers)); + + // Send only the relevant categories to the server. + if (isset($filters['categories'])) { + if (!isset($filters['categories'][$url])) { + // Don't call a server for results if categories are being used, and + // none of them belong to the server. + continue; + } + $local_filters['categories'] = $filters['categories'][$url]; + } + + // Use XMLRPC if it is set. + if ($server['method'] == 'xmlrpc') { + $results_raw = xmlrpc($url, array( + 'project_browser_server.fetch_results' => array($local_filters), + )); + + // Check for errors. + if ($error = xmlrpc_error() AND $error->is_error) { + drupal_set_message(t("Encountered an error when trying to fetch results from @name. Error @code : @message", + array('@name' => $server['name'], '@code' => $error->code, '@message' => $error->message))); + continue; + } + } + + // Use json if it is set. + if ($server['method'] == 'json') { + $local_filters['method'] = 'query'; + if (isset($local_filters['categories'])) { + $local_filters['categories'] = serialize($local_filters['categories']); + } + + $query_url = $url . '/query/' . $local_filters['type'] . '/8?' . http_build_query($local_filters, FALSE, '&'); + $response = drupal_http_request($query_url); + if ($response->code == '200') { + $results_raw = drupal_json_decode($response->data); + } + else { + drupal_set_message(t("Encountered an error when trying to fetch results from @name. Error @code : @message", + array('@name' => $server['name'], '@code' => $response->code, '@message' => $response->error))); + continue; + } + } + + if (isset($results_raw['total'])) { + $results['total'] += $results_raw['total']; + } + + if (isset($results_raw['projects']) AND !empty($results_raw['projects'])) { + // Merge the results. + $results['projects'] = array_merge($results['projects'], $results_raw['projects']); + } + } + + // Set the cached version of the results. + cache()->set($cid, $results, strtotime("+24 hours")); + + return $results; +} + +/** + * Fetches categories from the servers based on the type of project. + * + * @param string $type + * The type of project we are getting categories for. Can be 'module' or + * 'theme' + * @param string $use_server + * (Optional) The server to use. Defaults to 'all'. + * + * @return array + * Returns an array of the categories. + */ +function project_browser_fetch_categories($type, $use_server = 'all') { + $servers = project_browser_get_servers($use_server); + + // Attempt to retrieve the cached version of this page. + $cid = md5('categories_' . $type . serialize($servers)); + + if ($cache = cache()->get($cid)) { + return $cache->data; + } + else { + $categories = array(); + + foreach ($servers as $url => $server) { + // Use xmlrpc if it is set. + if ($server['method'] == 'xmlrpc') { + $categories_raw = xmlrpc($url, array( + 'project_browser_server.fetch_categories' => array($type), + )); + + // Check for errors. + if ($error = xmlrpc_error() AND $error->is_error) { + drupal_set_message(t("Encountered an error when trying to fetch categories from @name. Error @code : @message", + array('@name' => $server['name'], '@code' => $error->code, '@message' => $error->message))); + continue; + } + } + + // Use json if it is set. + if ($server['method'] == 'json') { + $params = array( + 'method' => 'categories', + 'type' => $type, + ); + $response = drupal_http_request($url . '/categories/' . $type . '?' . http_build_query($params, FALSE, '&')); + if ($response->code == '200') { + $categories_raw = drupal_json_decode($response->data); + } + else { + drupal_set_message(t("Encountered an error when trying to fetch categories from @name. Error @code : @message", + array('@name' => $server['name'], '@code' => $response->code, '@message' => $response->error))); + continue; + } + } + + if (is_array($categories_raw) AND !empty($categories_raw)) { + $categories[$url] = $categories_raw; + } + } + + // Cache this for 24 hours. + cache()->set($cid, $categories, strtotime("+24 hours")); + } + + return $categories; +} + +/** + * Gets an array of servers to use for fetching results. + * + * @param string $use_server + * (Optional) The server to use. Defaults to 'all'. + * + * @return array + * Returns an associative array of servers, populated from the + * project_browser_servers variable, in 'url => name' format. + */ +function project_browser_get_servers($use_server = 'all') { + // @todo - Change the link once drupal.org is ready. + $servers = variable_get('project_browser_default_server', array( + 'http://drupal:drupal@pbs-drupal_7.redesign.devdrupal.org/project_browser/server' => array( + 'name' => 'Drupal.org', + 'method' => 'json', + ), + )); + + if ($servers_raw = variable_get('project_browser_servers', '')) { + // Process the variable and add the servers to the list. + $custom_servers = array(); + + $list = explode("\n", $servers_raw); + $list = array_map('trim', $list); + $list = array_filter($list, 'strlen'); + + foreach ($list as $position => $text) { + $method = $name = $url = FALSE; + + $matches = array(); + if (preg_match('/(.*)\|(.*)\|(.*)/', $text, $matches)) { + $url = $matches[1]; + $method = $matches[2]; + $name = $matches[3]; + $custom_servers[$url] = array('name' => $name, 'method' => $method); + } + } + + $servers = array_merge($servers, $custom_servers); + } + + // Filter out servers if necessary. + if ($use_server !== 'all') { + $i = 0; + foreach ($servers as $url => $server) { + if ($use_server != $i) { + unset($servers[$url]); + } + $i += 1; + } + } + + return $servers; +} + +/** + * Uses the project status url to get the available releases for a project. + * + * @param array $project + * The project to get the releases for. + * + * @return array|false + * An array of releases for this project, or FALSE if it can't be found. + */ +function project_browser_get_project_release_data($project) { + $releases = array(); + $project['project_type'] = $project['type']; + $project['includes'] = array(); + + // Build the releases cache for this project. + module_load_include('inc', 'update', 'update.fetch'); + if (_update_process_fetch_task($project)) { + $data = _update_cache_get('available_releases::' . $project['name']); + if (isset($data->data) AND isset($data->data['releases']) AND is_array($data->data['releases'])) { + return $data->data; + } + } + + return FALSE; +} + +/** + * Uses the update module to download a project. + * + * This code is mostly copied and pasted from modules/update/update.manager.inc + * because there were no suitable functions that could be used besides + * drupal_form_submit('update_manager_install_form', $form_state, $project['type']); + * and it wouldn't work because this is being run from a Batch function. + * + * @todo - Ideally, this should be in the update module as a standalone + * function, to reduce coupling and duplication. + * + * @param string $url + * The url of the release download. + * + * @return array + * An array indicating whether or not this was successful, and an error + * message if applicable. + */ +function project_browser_download_project($url) { + module_load_include('inc', 'update', 'update.manager'); + // Download the file. + $local_cache = update_manager_file_get($url); + if (!$local_cache) { + return array( + 'success' => FALSE, + 'message' => t('Unable to retrieve Drupal project from %url.', array('%url' => $url)), + ); + } + + // Try to extract it. + $directory = _update_manager_extract_directory(); + try { + $archive = update_manager_archive_extract($local_cache, $directory); + } + catch (Exception $e) { + return array( + 'success' => FALSE, + 'message' => $e->getMessage(), + ); + } + $files = $archive->listContents(); + if (!$files) { + return array( + 'success' => FALSE, + 'message' => t('Provided archive contains no files.'), + ); + } + + $project = strtok($files[0], '/\\'); + + $archive_errors = update_manager_archive_verify($project, $local_cache, $directory); + if (!empty($archive_errors)) { + if (!empty($archive_errors)) { + foreach ($archive_errors as $error) { + drupal_set_message(check_plain($error), 'error'); + } + } + return array( + 'success' => FALSE, + 'message' => array_shift($archive_errors), + ); + } + + // Make sure the Updater registry is loaded. + drupal_get_updaters(); + + $project_location = $directory . '/' . $project; + try { + $updater = Updater::factory($project_location); + } + catch (Exception $e) { + return array( + 'success' => FALSE, + 'message' => $e->getMessage(), + ); + } + + try { + $project_title = Updater::getProjectTitle($project_location); + } + catch (Exception $e) { + return array( + 'success' => FALSE, + 'message' => $e->getMessage(), + ); + } + + if ($updater->isInstalled()) { + return array( + 'success' => FALSE, + 'message' => t('%project is already installed.', array('%project' => $project_title)), + ); + } + + $project_real_location = drupal_realpath($project_location); + $updater_name = get_class($updater); + + if (fileowner($project_real_location) == fileowner(conf_path())) { + module_load_include('inc', 'update', 'update.authorize'); + $filetransfer = new Local(DRUPAL_ROOT); + + // Initialize some variables in the Batch API $context array. + $updater = new $updater_name($project_real_location); + + try { + if ($updater->isInstalled()) { + // This is an update. + $tasks = $updater->update($filetransfer); + } + else { + $tasks = $updater->install($filetransfer); + } + } + catch (UpdaterException $e) { + return array( + 'success' => FALSE, + 'message' => t('Error installing / updating. Error: @error', array('@error' => $e->getMessage())), + ); + } + } + else { + return array( + 'success' => FALSE, + 'message' => t('Permissions are not set up properly.'), + ); + } + + return array( + 'success' => TRUE, + ); +} + +/** + * Installs a single release of a project during batch, for example. + * + * @param string $release_name + * The name of the release, such as '7.x-1.2'. + * @param array $project + * The project data array. + * @param array &$context + * The context of the batch so that the results can be reported. + */ +function _project_browser_batch_install_release($release_name, $project, &$context) { + module_load_include('inc', 'project_browser', 'project_browser.pages'); + $release = project_browser_get_release($release_name, $project); + + $result = project_browser_download_project($release['download_link']); + + if ($result['success']) { + $context['results']['successes'][] = t('Successfully installed %project.', array('%project' => $project['title'])); + $context['message'] = t('Installed %project...', array('%project' => $project['title'])); + + // Add this to the session variable and remove it from the install_queue + // variable. + $_SESSION['project_browser_installed_projects'][$project['name']] = $project; + unset($_SESSION['project_browser_install_list'][$project['name']]); + } + else { + watchdog('project_browser', 'There was an error while installing %project. + !message', + array('%project' => $project['title'], '!message' => $result['message']), WATCHDOG_ERROR); + $context['results']['failures'][] = t('Error installing %project. Errors have been logged.', + array('%project' => $project['title'])); + $context['message'] = t('Error installing %project. !message', + array('%project' => $project['title'], '!message' => $result['message'])); + } +} + +/** + * Shows a message and finishes up the batch. + * + * If there were any errors, they are reported here with drupal_set_message(). + * The user is then redirected to the select versions page if there were errors, + * the install dependencies page if there were any detected missing + * dependencies, or the enable modules page if there were no errors. + * + * @param bool $success + * Whether or not the whole operation was successful. + * @param array $results + * An array of messages about any failures. + * @param array $operations + * An array of operations that need to be performed. + */ +function _project_browser_batch_install_releases_finished($success, $results, $operations) { + drupal_get_messages(); + + // Restore the maintenance mode to what it was at the start. + variable_set('maintenance_mode', $_SESSION['maintenance_mode']); + unset($_SESSION['maintenance_mode']); + + unset($_SESSION['project_browser_install_releases_list']); + if ($success) { + if (!empty($results)) { + if (!empty($results['failures'])) { + drupal_set_message(format_plural(count($results['failures']), 'Failed to install one project.', 'Failed to install @count projects.'), 'error'); + } + } + } + else { + drupal_set_message(t('Error installing projects.'), 'error'); + drupal_goto('admin/modules/project-browser/install/select_versions'); + } + + $projects = project_browser_get_installed_projects(); + $missing = project_browser_get_missing_dependencies($projects); + // If there are missing dependencies, go to install dependencies. + if (count($missing) > 0) { + drupal_goto('admin/modules/project-browser/install/install_dependencies'); + } + else { + drupal_goto('admin/modules/project-browser/install/enable'); + } +} + +/** + * Gets the dependencies for installed projects. + * + * @param array $projects + * An array of projects to get the missing dependencies for. + * + * @return array + * An array of missing dependencies, if any were detected. + */ +function project_browser_get_missing_dependencies($projects) { + $modules = system_rebuild_module_data(); + + $missing = array(); + + foreach ($projects as $project) { + if ($project['type'] == 'module') { + $dependency_check = TRUE; + $dependencies = array(); + if (isset($modules[$project['name']])) { + foreach ($modules[$project['name']]->info['dependencies'] as $dependency) { + if (!isset($modules[$dependency])) { + $dependencies[] = $dependency; + } + } + if (count($dependencies) > 0) { + $missing[$project['name']] = $dependencies; + } + } + else { + drupal_set_message(t('There was an error getting information for @module', + array('@module' => $project['name'])), 'error'); + } + } + } + + return $missing; +} diff --git a/core/modules/project_browser/project_browser.info b/core/modules/project_browser/project_browser.info new file mode 100644 index 0000000..5d42fc2 --- /dev/null +++ b/core/modules/project_browser/project_browser.info @@ -0,0 +1,6 @@ +name = Project Browser +description = A Project Browser that allows users to browse for and install modules and themes from their Drupal site admin area +dependencies[] = update +package = Core +version = VERSION +core = 8.x \ No newline at end of file diff --git a/core/modules/project_browser/tests/project_browser_test.info b/core/modules/project_browser/tests/project_browser_test.info new file mode 100644 index 0000000..c5237dc --- /dev/null +++ b/core/modules/project_browser/tests/project_browser_test.info @@ -0,0 +1,5 @@ +name = "Project Browser module tests" +description = "Support module for Project Browser related testing." +package = Testing +core = 8.x +hidden = TRUE \ No newline at end of file diff --git a/core/modules/project_browser/tests/project_browser_test.module b/core/modules/project_browser/tests/project_browser_test.module new file mode 100644 index 0000000..c309244 --- /dev/null +++ b/core/modules/project_browser/tests/project_browser_test.module @@ -0,0 +1,329 @@ + 'Test Query page', + 'description' => "Tests the ability to fetch and display projects, and filter them appropriately.", + 'page callback' => 'project_browser_test_query', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + return $items; +} + +/** + * Page callback. Generates json based on the input filters + */ +function project_browser_test_query() { + if (!isset($_GET['method'])) { + print drupal_json_encode(t('You must specify a method.')); + exit(); + } + switch ($_GET['method']) { + case 'categories': + if (!isset($_GET['type'])) { + print drupal_json_encode(t('You must specify a project type.')); + exit(); + } + + $categories['project_browser_test'] = project_browser_test_get_categories($_GET['type']); + + print drupal_json_encode($categories); + exit(); + + case 'query': + // Check that we have valid data + if (!isset($_GET['drupal_version'])) { + print drupal_json_encode(t('You must specify a drupal version.')); + exit(); + } + if (!isset($_GET['type'])) { + print drupal_json_encode(t('You must specify a project type.')); + exit(); + } + + // Get the filters + $filters = array( + 'drupal_version' => $_GET['drupal_version'], + 'type' => $_GET['type'], + 'text' => (isset($_GET['text']) AND $_GET['text']) ? $_GET['text'] : '', + 'sort_method' => isset($_GET['sort_method']) ? $_GET['sort_method'] : 'usage', + 'sort_direction' => isset($_GET['sort_direction']) ? $_GET['sort_direction'] : 'desc', + 'requested' => isset($_GET['requested']) ? (int) $_GET['requested'] : 12, + 'page' => isset($_GET['page']) ? (int) $_GET['page'] : 0, + ); + + if (isset($_GET['categories'])) { + $categories = unserialize($_GET['categories']); + if (is_array($categories) AND !empty($categories)) { + $filters['categories'] = $categories; + } + } + + // Pass them off to the project_browser_server_get_results() function + $results = project_browser_test_get_results($filters); + + print drupal_json_encode($results); + + exit(); + } +} + +/** + * Returns some static categories + */ +function project_browser_test_get_categories($type) { + $categories = array(); + + switch ($type) { + case 'module': + $categories = array( + 'admin' => "Administrative", + 'search' => "Search", + 'user_management' => "User Management", + ); + break; + + case 'theme': + $categories = array( + 'dark' => "Dark", + 'light' => "Light", + ); + } + + return $categories; +} + +/** + * Returns projects based on the filters + */ +function project_browser_test_get_results($filters) { + $projects = project_browser_test_projects(); + + $results = array( + 'total' => count($projects), + 'projects' => array(), + ); + + // Filter out projects based on type + if (isset($filters['type']) AND $type = $filters['type']) { + foreach ($projects as $name => $project) { + if ($type != $project['type']) { + unset($projects[$name]); + } + } + } + + // Filter out projects based on drupal version number + if (isset($filters['drupal_version']) AND $version = $filters['drupal_version']) { + foreach ($projects as $name => $project) { + if ($version != $project['drupal version']) { + unset($projects[$name]); + } + } + } + + // Filter out projects based on categories number + if (isset($filters['categories']) AND is_array($filters['categories']) AND !empty($filters['categories'])) { + $filtered = array(); + foreach ($projects as $name => $project) { + foreach ($project['categories'] as $category) { + if (in_array($category, $filters['categories'])) { + $filtered[$name] = $project; + } + } + } + $projects = $filtered; + } + + // Filter out projects based on the text query + if (isset($filters['text']) AND $text = $filters['text']) { + foreach ($projects as $name => $project) { + if (!stristr($project['title'], $text) AND !stristr($project['description'], $text)) { + unset($projects[$name]); + } + } + } + + $results['total'] = count($projects); + + // Only send back the requested amount + $start = $filters['page'] * $filters['requested']; + $end = $start + $filters['requested']; + + $results['projects'] = $projects; + + return $results; +} + +/** + * Returns some static projects + */ +function project_browser_test_projects() { + $projects = array(); + + $projects['views'] = array( + 'type' => 'module', + 'title' => 'Views', + 'name' => 'views', + 'drupal version' => 7, + 'author' => 'merlinofchaos', + 'description' => "The Views module provides a flexible method for Drupal site + designers to control how lists and tables of content (nodes in Views 1, almost + anything in Views 2) are presented. Traditionally, Drupal has hard-coded most of + this, particularly in how taxonomy and tracker lists are formatted. ", + 'drupal_versions' => array(6, 7), + 'categories' => array('admin', 'search'), + 'image' => 'http://learnbythedrop.com/system/files/images/View-Edit_0.png', + 'usage' => '542312', + 'project url' => 'http://www.drupal.org/projects/views', + 'project status url' => 'http://updates.drupal.org/release-history/views/7.x', + 'last updated' => '12342523', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '9.6', + 'dependencies' => array( + 'ctools', + ), + ); + + $projects['ctools_test'] = array( + 'type' => 'module', + 'title' => 'CTools Test', + 'name' => 'ctools_test', + 'drupal version' => 7, + 'author' => 'merlinofchaos', + 'description' => "This suite is primarily a set of APIs and tools to improve + the developer experience. It also contains a module called the Page Manager + whose job is to manage pages. In particular it manages panel pages, but as + it grows it will be able to manage far more than just Panels.", + 'drupal_versions' => array(6, 7), + 'categories' => array(), + 'image' => '', + 'usage' => '4312', + 'project url' => 'http://www.drupal.org/projects/ctools', + 'project status url' => 'http://updates.drupal.org/release-history/ctools/7.x', + 'last updated' => '12354634', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '7.6', + 'dependencies' => array(), + ); + + $projects['ctools'] = array( + 'type' => 'module', + 'title' => 'Chaos Tool Suite', + 'name' => 'ctools', + 'drupal version' => 7, + 'author' => 'merlinofchaos', + 'description' => "This suite is primarily a set of APIs and tools to improve + the developer experience. It also contains a module called the Page Manager + whose job is to manage pages. In particular it manages panel pages, but as + it grows it will be able to manage far more than just Panels.", + 'drupal_versions' => array(6, 7), + 'categories' => array(), + 'image' => '', + 'usage' => '4312', + 'project url' => 'http://www.drupal.org/projects/ctools', + 'project status url' => 'http://updates.drupal.org/release-history/ctools/7.x', + 'last updated' => '12354634', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '7.6', + 'dependencies' => array(), + ); + + $projects['token'] = array( + 'type' => 'module', + 'title' => 'Token', + 'name' => 'token', + 'drupal version' => 7, + 'author' => 'eaton', + 'description' => "Tokens are small bits of text that can be placed into larger + documents via simple placeholders, like %site-name or [user]. The Token module + provides a central API for modules to use these tokens, and expose their own token values.", + 'categories' => array('admin'), + 'image' => 'http://drupal.org/files/images/token_08.thumbnail.png', + 'usage' => '4563', + 'project url' => 'http://www.drupal.org/projects/token', + 'project status url' => 'http://updates.drupal.org/release-history/token/7.x', + 'last updated' => '12357351', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '8.1', + 'dependencies' => array(), + ); + + $projects['zen'] = array( + 'type' => 'theme', + 'title' => 'Zen', + 'name' => 'zen', + 'drupal version' => 7, + 'author' => 'johnAlbin', + 'description' => "Zen is the ultimate starting theme for Drupal. If you are + building your own standards-compliant theme, you will find it much easier to + start with Zen than to start with Garland or Bluemarine. This theme has fantastic + online documentation and tons of code comments for both the PHP (template.php) + and HTML (page.tpl.php, node.tpl.php).", + 'categories' => array('light', 'dark'), + 'image' => 'http://drupal.org/files/images/zen-logo.thumbnail.png', + 'usage' => '4563', + 'project url' => 'http://www.drupal.org/project/zen', + 'project status url' => 'http://updates.drupal.org/release-history/zen/7.x', + 'last updated' => '12343634', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '7.1', + 'dependencies' => array(), + ); + + $projects['acquia_marina'] = array( + 'type' => 'theme', + 'title' => 'Acquia Marina', + 'name' => 'acquia_marina', + 'drupal version' => 7, + 'author' => 'stephthegeek', + 'description' => "The Fusion base theme and Skinr are required. Skinr for Drupal 7 + (dev release) is usable now but it is recommended that you proceed with caution + and do some of your own testing.", + 'categories' => array('light'), + 'image' => 'http://drupal.org/files/images/acquia_marina.thumbnail.png', + 'usage' => '14563', + 'project url' => 'http://www.drupal.org/project/acquia_marina', + 'project status url' => 'http://updates.drupal.org/release-history/acquia_marina/7.x', + 'last updated' => '12346574', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '7.8', + 'dependencies' => array( + 'fusion' + ), + ); + + $projects['fusion'] = array( + 'type' => 'theme', + 'title' => 'Fusion', + 'name' => 'fusion', + 'drupal version' => 7, + 'author' => 'stephthegeek', + 'description' => "Fusion is a powerful base theme, with layout and style configuration + options built in that you can control through Drupal's UI. It's based on a simplified + 960px or fluid 12/16-column grid. It's designed to be used with the Skinr module, + with numerous useful block styles included.", + 'categories' => array('light'), + 'image' => 'http://drupal.org/files/images/fusion-powering-small-banner.thumbnail.png', + 'usage' => '14563', + 'project url' => 'http://www.drupal.org/project/fusion', + 'project status url' => 'http://updates.drupal.org/release-history/fusion/7.x', + 'last updated' => '12342643', + 'maintenance status' => 'Actively maintained', + 'development status' => 'Under active development', + 'rating' => '', + 'dependencies' => array(), + ); + + return $projects; +} diff --git a/core/modules/project_browser/theme/project-browser-block.tpl.php b/core/modules/project_browser/theme/project-browser-block.tpl.php new file mode 100644 index 0000000..e5286dd --- /dev/null +++ b/core/modules/project_browser/theme/project-browser-block.tpl.php @@ -0,0 +1,21 @@ + +
      + +

      + +
      + +
      +
      diff --git a/core/modules/project_browser/theme/project-browser-install-queue.tpl.php b/core/modules/project_browser/theme/project-browser-install-queue.tpl.php new file mode 100644 index 0000000..12ae031 --- /dev/null +++ b/core/modules/project_browser/theme/project-browser-install-queue.tpl.php @@ -0,0 +1,16 @@ + +
      + +
      diff --git a/core/modules/project_browser/theme/project-browser-install.tpl.php b/core/modules/project_browser/theme/project-browser-install.tpl.php new file mode 100644 index 0000000..6b2fdbe --- /dev/null +++ b/core/modules/project_browser/theme/project-browser-install.tpl.php @@ -0,0 +1,22 @@ + + +
      + +
      +
      + +
      diff --git a/core/modules/project_browser/theme/project-browser-list.tpl.php b/core/modules/project_browser/theme/project-browser-list.tpl.php new file mode 100644 index 0000000..f068d2c --- /dev/null +++ b/core/modules/project_browser/theme/project-browser-list.tpl.php @@ -0,0 +1,27 @@ + +
      +
      + +
      + +
      + + +
      + +
      diff --git a/core/modules/project_browser/theme/project-browser-project.tpl.php b/core/modules/project_browser/theme/project-browser-project.tpl.php new file mode 100644 index 0000000..14ac7d2 --- /dev/null +++ b/core/modules/project_browser/theme/project-browser-project.tpl.php @@ -0,0 +1,59 @@ + +
      + +
      + +
      + + +
      +
      + +
      + +
      + +
      + +
      + +
      + +
      + +
      +
      + +
      + +
      + +
      + + +
      +
      +
      -- 1.7.3.1.msysgit.0