From 0c06b9e83470c1a5d2bba0fe43c20fb598fbe2d1 Mon Sep 17 00:00:00 2001 From: Katherine Bailey Date: Fri, 17 Jun 2011 14:22:12 -0700 Subject: [PATCH] Issue #1182614: Facetapi integration --- plugins/facetapi/adapter.inc | 188 ++++++++++++++++++++++++++++++++ plugins/facetapi/query_type_date.inc | 157 +++++++++++++++++++++++++++ plugins/facetapi/query_type_term.inc | 96 ++++++++++++++++ search_api.admin.inc | 17 +++ search_api.info | 3 + search_api.module | 198 ++++++++++++++++++++++++++++++++++ 6 files changed, 659 insertions(+), 0 deletions(-) create mode 100644 plugins/facetapi/adapter.inc create mode 100644 plugins/facetapi/query_type_date.inc create mode 100644 plugins/facetapi/query_type_term.inc diff --git plugins/facetapi/adapter.inc plugins/facetapi/adapter.inc new file mode 100644 index 0000000..bc149b5 --- /dev/null +++ plugins/facetapi/adapter.inc @@ -0,0 +1,188 @@ +info['instance']; + return $base_path . '/index/'. $index .'/facets/' . $realm_name; + } + + /** + * Allows the backend to initialize its query object before adding the facet + * filters. + * + * @param mixed $query + * The backend's native object. + */ + function initActiveFilters($query) { + $search_id = $query->getOption('search id'); + $facets = facetapi_get_enabled_facets($this->info['name']); + $fields = array(); + + // We statically store the current search per facet so that we can correctly + // assign it when building the facets. See the build() method in the query + // type plugin classes. + $active = &drupal_static('search_api_facetapi_active_facets', array()); + foreach ($facets as $facet) { + $options = $this->getFacet($facet)->getSettings()->settings; + // The 'default_true' option is a choice between "show on all but the + // selected searches" (TRUE) and "show for only the selected searches" + $default_true = isset($options['default_true']) ? $options['default_true'] : TRUE; + // The 'facet_search_ids' option is the list of selected searches that will + // either be excluded or for which the facet will exclusively be displayed. + $facet_search_ids = isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array(); + + if (array_search($search_id, $facet_search_ids) === FALSE) { + $search_ids = variable_get('search_api_facets_search_ids', array()); + if (empty($search_ids[$search_id])) { + // Remember this search ID. + $search_ids[$search_id] = $search_id; + variable_set('search_api_facets_search_ids', $search_ids); + } + if (!$default_true) { + continue; // We are only to show facets for explicitly named search ids. + } + } + elseif ($default_true) { + continue; // The 'facet_search_ids' in the settings are to be excluded. + } + $active[$facet['name']] = $search_id; + $fields[$facet['name']] = array( + 'field' => $facet['field'], + // These were copied from the default options specified in the constructor + // for SearchApiFacet in search_api_facet.entity.inc. + // TODO: I suspect some of them are now irrelevant and should be removed. + 'limit' => 10, + 'display_more_link' => FALSE, + 'more_limit' => 10, + 'min_count' => 1, + 'sort' => 'count', + 'missing' => $facet['facet missing allowed'], + 'show_active' => TRUE, + 'default_true' => TRUE, + 'ids_list' => array(), + 'type' => '', + ); + } + + if ($fields) { + $old = $query->setOption('search_api_facets', $fields); + if ($old) { // This will only happen if other modules add facets of their own. + $query->setOption('search_api_facets', $fields + $old); + } + } + } + + /** + * Returns a boolean flagging whether $this->_searcher executed a search. + */ + public function searchExecuted() { + // See the comment in the getSearchKeys() method - we shouldn't just be + // checking if search api executed a search, but whether it executed a search + // for the particular search that we want a current search block and bread- + // crumbs for. However, there is currently no way of mapping this. + $searches = search_api_current_search(); + return (bool)count($searches); + } + + public function suppressOutput($realm_name) { + // Not sure under what circumstances the output will need to be suppressed, + // hand off to the search backend module...? + //return module_invoke($this->info['module'], 'search_api_suppress_output'); + return FALSE; + } + + /** + * Returns the search keys. + */ + public function getSearchKeys() { + + $searches = search_api_current_search(); + // There is currently no way to configure the "current search" block to show + // on a per-searcher basis as we do with the facets. Therefore we cannot + // match it up to the correct "current search" + // I suspect that http://drupal.org/node/593658 would help. + // For now, just taking the first current search :-/ + $first = key($searches); + $query = $searches[$first][0]; + $keys = $query->getOriginalKeys(); + if (is_array($keys)) { + $keys = implode(' ', $keys); + } + return $keys; + } + + /** + * Returns the number of total results found for the current search. + */ + public function getResultCount() { + + // See above for why we're just using the first current search. + $searches = search_api_current_search(); + $first = key($searches); + // Each search is an array with the query as the first element and the results + // array as the second. + if (isset($searches[$first][1])) { + return $searches[$first][1]['result count']; + } + return 0; + } + + /** + * Allows for backend specific overrides to the settings form. + */ + public function settingsForm(&$form, &$form_state) { + $search_ids = variable_get('search_api_facets_search_ids', array()); + $facet = $form['#facetapi']['facet']; + $realm = $form['#facetapi']['realm']; + $facet_settings = $this->getFacet($facet)->getSettings(); + $options = $facet_settings->settings; + $search_ids = variable_get('search_api_facets_search_ids', array()); + if (count($search_ids) > 1) { + $form['global']['default_true'] = array( + '#type' => 'select', + '#title' => t('Display for searches'), + '#options' => array( + TRUE => t('For all except the selected'), + FALSE => t('Only for the selected'), + ), + '#default_value' => isset($options['default_true']) ? $options['default_true'] : TRUE, + ); + $form['global']['facet_search_ids'] = array( + '#type' => 'select', + '#title' => t('Search IDs'), + '#options' => $search_ids, + '#size' => min(4, count($search_ids)), + '#multiple' => TRUE, + '#default_value' => isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array(), + ); + } + else { + $form['global']['default_true'] = array( + '#type' => 'value', + '#value' => TRUE, + ); + $form['global']['facet_search_ids'] = array( + '#type' => 'value', + '#value' => array(), + ); + } + } +} diff --git plugins/facetapi/query_type_date.inc plugins/facetapi/query_type_date.inc new file mode 100644 index 0000000..9c1b3ff --- /dev/null +++ plugins/facetapi/query_type_date.inc @@ -0,0 +1,157 @@ +adapter->getActiveItems($this->facet)) { + $item = end($active); + $field = $this->facet['field']; + $query->condition($field, strtotime($item['start']), '>='); + $query->condition($field, strtotime($item['end']), '<'); + } + } + + /** + * Adds the filter to the query object. + * + * @param $query + * An object containing the query in the backend's native API. + */ + public function execute($query) { + $this->addFacetFilters($query); + } + + + /** + * Initializes the facet's build array. + * + * @return array + * The initialized render array. + */ + public function build() { + + $facet = $this->adapter->getFacet($this->facet); + $search_ids = drupal_static('search_api_facetapi_active_facets', array()); + if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) { + return array(); + } + $search_id = $search_ids[$facet['name']]; + $build = array(); + $search = search_api_current_search($search_id); + $results = $search[1]; + if (!$results['result count']) { + return array(); + } + // Gets total number of documents matched in search. + $total = $results['result count']; + + // Most of the code below is copied from search_facetapi's implementation of + // this method. + + // Executes query, iterates over results. + if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) { + $values = $results['search_api_facets'][$this->facet['field']]; + foreach ($values as $value) { + if ($value['count']) { + $filter = substr($value['filter'], 1, -1); + $raw_values[$filter] = $value['count']; + } + } + } + + // Gets active facets, starts building hierarchy. + $parent = $gap = NULL; + foreach ($this->adapter->getActiveItems($this->facet) as $value => $item) { + // If the item is active, the count is the result set count. + $build[$value] = array('#count' => $total); + + // Gets next "gap" increment, mintue being the lowest be can go. + $date_gap = facetapi_get_date_gap($item['start'], $item['end']); + $gap = facetapi_get_next_date_gap($date_gap, FACETAPI_DATE_MINUTE); + + // If there is a previous item, there is a parent, uses a reference so the + // arrays are populated when they are updated. + if (NULL !== $parent) { + $build[$parent]['#item_children'][$value] = &$build[$value]; + $build[$value]['#item_parents'][$parent] = $parent; + } + + // Stores the last value iterated over. + $parent = $value; + } + if (empty($raw_values)) { + return $build; + } + ksort($raw_values); + + // Mind the gap! Calculates gap from min and max timestamps. + $timestamps = array_keys($raw_values); + if (NULL === $parent) { + if (count($raw_values) > 1) { + $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps)); + } + else { + $gap = FACETAPI_DATE_HOUR; + } + } + + // Converts all timestamps to dates in ISO 8601 format. + $dates = array(); + foreach ($timestamps as $timestamp) { + $dates[$timestamp] = facetapi_isodate($timestamp, $gap); + } + + // Treat each date as the range start and next date as the range end. + $range_end = array(); + $previous = NULL; + foreach (array_unique($dates) as $date) { + if (NULL !== $previous) { + $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap); + } + $previous = $date; + } + $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap); + + // Groups dates by the range they belong to, builds the $build array + // with the facet counts and formatted range values. + foreach ($raw_values as $value => $count) { + $new_value = '[' . $dates[$value] . ' TO ' . $range_end[$dates[$value]] . ']'; + if (!isset($build[$new_value])) { + $build[$new_value] = array('#count' => $count); + } + else { + $build[$new_value]['#count'] += $count; + } + + // Adds parent information if not already set. + if (NULL !== $parent) { + $build[$parent]['#item_children'][$new_value] = &$build[$new_value]; + $build[$new_value]['#item_parents'][$parent] = $parent; + } + } + + return $build; + + } +} diff --git plugins/facetapi/query_type_term.inc plugins/facetapi/query_type_term.inc new file mode 100644 index 0000000..c2521e0 --- /dev/null +++ plugins/facetapi/query_type_term.inc @@ -0,0 +1,96 @@ +adapter->getFacet($this->facet)->getSettings(); + // Adds the operator parameter. + $operator = $settings->settings['operator']; + + // Add active facet filters. + $active = $this->adapter->getActiveItems($this->facet); + if (empty($active)) { + return; + } + + // Adds filter based on the operator. + if (FACETAPI_OPERATOR_OR != $operator) { + foreach ($active as $filter => $filter_array) { + if ($filter_array['value'] == '!') { + $query->condition($filter_array['field alias'], NULL); + } + else { + $query->condition($filter_array['field alias'], $filter_array['value']); + } + } + } + else { + // OR facet. + $facet_filter = $query->createFilter('OR'); + foreach ($active as $filter => $filter_array) { + if ($filter_array['value'] == '!') { + $facet_filter->condition($filter_array['field alias'], NULL); + } + else { + $facet_filter->condition($filter_array['field alias'], $filter_array['value']); + } + } + $query->filter($facet_filter); + } + } + + /** + * Initializes the facet's build array. + * + * @return array + * The initialized render array. + */ + public function build() { + $facet = $this->adapter->getFacet($this->facet); + // The current search per facet is stored in a static variable (during + // initActiveFilters) so that we can retrieve it here and get the correct + // current search for this facet. + $search_ids = drupal_static('search_api_facetapi_active_facets', array()); + if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) { + return array(); + } + $search_id = $search_ids[$facet['name']]; + $build = array(); + $search = search_api_current_search($search_id); + $results = $search[1]; + if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) { + $values = $results['search_api_facets'][$this->facet['field']]; + foreach ($values as $value) { + if ($value['count']) { + $filter = trim($value['filter'], '"'); + $build[$filter] = array('#count' => $value['count']); + } + } + } + return $build; + } +} diff --git search_api.admin.inc search_api.admin.inc index 0d298a2..ff99ba0 100644 --- search_api.admin.inc +++ search_api.admin.inc @@ -1970,3 +1970,20 @@ function search_api_admin_confirm_submit(array $form, array &$form_state) { ? "admin/config/search/search_api" : "admin/config/search/search_api/$type/$id"; } + +/** + * Menu callback for facet settings page. + */ +function search_api_facet_settings($realm_name, SearchApiIndex $index) { + if (!$index->enabled) { + return array('#markup' => t('Since this index is at the moment disabled, no facets can be activated.')); + } + if (!$index->server()->supportsFeature('search_api_facets')) { + return array('#markup' => t('This index uses a server that does not support facet functionality.')); + } + $searcher_name = 'search_api@' . $index->machine_name; + module_load_include('inc', 'facetapi', 'facetapi.admin'); + return array( + 'settings_form' => drupal_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name), + ); +} diff --git search_api.info search_api.info index 440825b..d501b7a 100644 --- search_api.info +++ search_api.info @@ -21,5 +21,8 @@ files[] = includes/processor_tokenizer.inc files[] = includes/query.inc files[] = includes/server_entity.inc files[] = includes/service.inc +files[] = plugins/facetapi/adapter.inc +files[] = plugins/facetapi/query_type_term.inc +files[] = plugins/facetapi/query_type_date.inc configure = admin/config/search/search_api diff --git search_api.module search_api.module index ab96b60..a272017 100644 --- search_api.module +++ search_api.module @@ -149,6 +149,42 @@ function search_api_menu() { 'file' => 'search_api.admin.inc', 'type' => MENU_LOCAL_TASK, ); + if (module_exists('facetapi')) { + // We need to handle our own menu paths for facets because we need a facet + // configuration page per index. + $first = TRUE; + foreach (facetapi_get_realm_info() as $realm_name => $realm) { + if ($first) { + $first = FALSE; + $items[$pre . '/index/%search_api_index/facets'] = array( + 'title' => 'Facets', + 'page callback' => 'search_api_facet_settings', + 'page arguments' => array($realm_name, 5), + 'weight' => -7, + 'access arguments' => array('administer search'), + 'file' => 'search_api.admin.inc', + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + ); + $items[$pre . '/index/%/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => $realm['weight'], + ); + } + else { + $items[$pre . '/index/%search_api_index/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'page callback' => 'search_api_facet_settings', + 'page arguments' => array($realm_name, 5), + 'access arguments' => array('administer search'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'file' => 'search_api.admin.inc', + ); + } + } + } return $items; } @@ -707,6 +743,138 @@ function search_api_search_api_processor_info() { } /** + * Implements hook_facetapi_searcher_info(). + */ +function search_api_facetapi_searcher_info() { + $info = array(); + $indexes = search_api_index_load_multiple(FALSE); + foreach ($indexes as $index) { + if ($index->enabled) { + $searcher_name = 'search_api@' . $index->machine_name; + $info[$searcher_name] = array( + 'label' => t('Search service: @name', array('@name' => $index->name)), + 'adapter' => 'search_api', + 'instance' => $index->machine_name, + 'type' => $index->entity_type, + 'path' => '', + ); + } + } + return $info; +} + +/** + * Implements hook_facetapi_facet_info(). + */ +function search_api_facetapi_facet_info($searcher_info) { + $facet_info = array(); + if ('search_api' == $searcher_info['adapter']) { + // We store our facet info statically so that we can then make sure that + // only the facets created here are available as facets for this index. + // See search_api_facetapi_facet_info_alter() below. + $facets = &drupal_static(__FUNCTION__, array()); + if (!empty($facets[$searcher_info['instance']])) { + return $facets[$searcher_info['instance']]; + } + $index = search_api_index_load($searcher_info['instance']); + if (!empty($index->options['fields'])) { + $types = search_api_field_types(); + // We use facetapi's 'map callback' option to swap out the indexed value + // of a field (which might be an integer, e.g. a tid) for a proper facet + // label (e.g. the term name). This only affects fields with an 'entity_type' + // property, and for the most part we'll use a generic callback for all + // entity types. This array lists the callbacks used for special entity + // types, e.g. 'taxonomy_term', for which facetapi has already provded a + // map callback, as well as the generic callback for everything else. + $field_map_callbacks = array( + 'taxonomy_term' => 'facetapi_map_taxonomy_terms', + 'entity' => 'search_api_map_entities', + ); + foreach ($index->options['fields'] as $key => $field) { + if (!$field['indexed']) { + continue; + } + $map_type = $map_options = NULL; + if (isset($field['entity_type'])) { + $map_type = $field['entity_type']; + if (!isset($field_map_callbacks[$field['entity_type']])) { + $map_type = 'entity'; + // Our entity map callback will need to know more about the field, + // in particular, what entity_type it is. + $map_options = $field; + } + } + $facet_info[$key] = array( + 'label' => t('!field', array('!field' => $field['name'])), + 'field api name' => $field['name'], + 'description' => t('Filter by @type.', array('@type' => $field['name'])), + 'map callback' => isset($map_type) ? $field_map_callbacks[$map_type] : '', + 'map options' => isset($map_options) ? $map_options : array(), + 'allowed operators' => array(FACETAPI_OPERATOR_AND => TRUE), + ); + if ($field['type'] == 'date') { + $facet_info[$key] = array_merge($facet_info[$key], array( + 'map callback' => 'facetapi_map_date', + 'query type' => 'date', + )); + } + } + } + $facets[$searcher_info['instance']] = $facet_info; + } + return $facet_info; +} + +/** + * Implements hook_facetapi_facet_info_alter(). + */ +function search_api_facetapi_facet_info_alter(&$facet_info, $searcher) { + if ($searcher['adapter'] == 'search_api') { + $facets = drupal_static('search_api_facetapi_facet_info'); + // Unset any facets that were not created by Search API because they will + // not be in the index. + foreach ($facet_info as $name => $info) { + if (!isset($facets[$searcher['instance']][$name])) { + unset($facet_info[$name]); + } + } + } +} + +/** + * Implements hook_facetapi_adapters(). + */ +function search_api_facetapi_adapters() { + return array( + 'search_api' => array( + 'handler' => array( + 'class' => 'SearchApiFacetapiAdapter', + ), + ), + ); +} + +/** + * Implements hook_facetapi_query_types(). + */ +function search_api_facetapi_query_types() { + return array( + 'search_api_term' => array( + 'handler' => array( + 'class' => 'SearchApiFacetapiTerm', + 'adapter' => 'search_api', + ), + ), + 'search_api_date' => array( + 'handler' => array( + 'class' => 'SearchApiFacetapiDate', + 'adapter' => 'search_api', + ), + ), + ); +} + +/** * Mark the entities with the specified IDs as "dirty", i.e., as needing to be reindexed. * * For indexes for which items should be indexed immediately, the items are @@ -899,6 +1067,22 @@ function search_api_query($id, array $options = array()) { } /** + * Implements hook_search_api_query_alter(). + */ +function search_api_search_api_query_alter($query) { + if (module_exists('facetapi')) { + $index = $query->getIndex(); + // This is the main point of communication between the facet system and the + // search back-end - it makes the query respond to active facets. + $searcher = 'search_api@' . $index->machine_name; + $adapter = facetapi_adapter_load($searcher); + if ($adapter) { + $adapter->addActiveFilters($query); + } + } +} + +/** * Static store for the searches executed on the current page. Can either be * used to store an executed search, or to retrieve a previously stored * search. @@ -1757,3 +1941,17 @@ function search_api_index_delete($id) { $index->delete(); return TRUE; } + +/** + * Mapping callback for entity-based facets. Replaces the indexed value of an + * entity (its id) with its label for facet display. + */ +function search_api_map_entities($values, $options) { + $entities = entity_load($options['entity_type'], $values); + $map = array(); + foreach($entities as $eid => $entity) { + $label = entity_label($options['entity_type'], $entity); + $map[$eid] = $label; + } + return $map; +} -- 1.7.4.1