diff --git a/contrib/search_api_facetapi/README.txt b/contrib/search_api_facetapi/README.txt new file mode 100644 index 0000000..2585c99 --- /dev/null +++ b/contrib/search_api_facetapi/README.txt @@ -0,0 +1,125 @@ +Search facets +------------- + +This module allows you to create facetted searches for any search executed via +the Search API, no matter if executed by a search page, a view or any other +module. The only thing you'll need is a search service class that supports the +"search_api_facets" feature. Currently, the "Database search" and "Solr search" +modules supports this. + +This module is built on the Facet API [1], which is needed for this module to +work. + +[1] http://drupal.org/project/facetapi + + +Information for site builders +----------------------------- + +For creating a facetted search, you first need a search. Create or find some +page displaying Search API search results, either via a search page, a view or +by any other means. Now go to the configuration page for the index on which +this search is executed. +If the index lies on a server supporting facets (and if this module is enabled), +you'll notice a "Facets" tab. Click it and it will take you to the index' facet +configuration page. You'll see a table containing all indexed fields and options +for enabling and configuring facets for them. +For a detailled explanation of the available options, please refer to the Facet +API documentation. + +- Creating facets via the URL + +Facets can be added to a search (for which facets are activated) by passing +appropriate GET parameters in the URL. Assuming you have an indexed field with +the machine name "field_price", you can filter on it in the following ways: + +- Filter for a specific value. For finding only results that have a price of + exactly 100, pass the following $options to url() or l(): + + $options['query']['f'][] = 'field_price:100'; + + Or manually append the following GET parameter to a URL: + + ?f[0]=field_price:100 + +- Search for values in a specified range. The following example will only return + items that have a price greater than or equal to 100 and lower than 500. + + Code: $options['query']['f'][] = 'field_price:[100 TO 500]'; + URL: ?f[0]=field_price%3A%5B100%20TO%20500%5D + +- Search for values above a value. The next example will find results which have + a price greater than or equal to 100. The asterisk (*) stands for "unlimited", + meaning that there is no upper limit. Filtering for values lower than a + certain value works equivalently. + + Code: $options['query']['f'][] = 'field_price:[100 TO *]'; + URL: ?f[0]=field_price%3A%5B100%20TO%20%2A%5D + +- Search for missing values. This example will filter out all items which have + any value at all in the price field, and will therefore only list items on + which this field was omitted. (This naturally only makes sense for fields + that aren't required.) + + Code: $options['query']['f'][] = 'field_price:!'; + URL: ?f[0]=field_price%3A%21 + +- Search for present values. The following example will only return items which + have the price field set (regardless of the actual value). You can see that it + is actually just a range filter with unlimited lower and upper bound. + + Code: $options['query']['f'][] = 'field_price:[* TO *]'; + URL: ?f[0]=field_price%3A%5B%2A%20TO%20%2A%5D + +Note: When filtering a field whose machine name contains a colon (e.g., +"author:roles"), you'll have to additionally URL-encode the field name in these +filter values: + Code: $options['query']['f'][] = rawurlencode('author:roles') . ':100'; + URL: ?f[0]=author%253Aroles%3A100 + +- Issues + +If you find any bugs or shortcomings while using this module, please file an +issue in the project's issue queue [1], using the "Facets" component. + +[1] http://drupal.org/project/issues/search_api + + +Information for developers +-------------------------- + +- Features + +If you are the developer of a SearchApiServiceInterface implementation and want +to support facets with your service class, too, you'll have to support the +"search_api_facets" feature. You can find details about the necessary additions +to your class in the example_servive.php file. In short, you'll just, when +executing a query, have to return facet terms and counts according to the +query's "search_api_facets" option, if present. +In order for the module to be able to tell that your server supports facets, +you will also have to change your service's supportsFeature() method to +something like the following: + public function supportsFeature($feature) { + return $feature == 'search_api_facets'; + } + +There is also a second feature defined by this module, namely +"search_api_facets_operator_or", for supporting "OR" facets. The requirements +for this feature are also explained in the example_servive.php file. + +- Query option + +The facets created follow the "search_api_base_path" option on the search query. +If set, this path will be used as the base path from which facet links will be +created. This can be used to show facets on pages without searches – e.g., as a +landing page. + +- Hidden variable + +The module uses one hidden variable, "search_api_facets_search_ids", to keep +track of the search IDs of searches executed for a given index. It is only +updated when a facet is displayed for the respective search, so isn't really a +reliable measure for this. +In any case, if you e.g. did some test searches and now don't want them to show +up in the block configuration forever after, just clear the variable: + variable_del("search_api_facets_search_ids") diff --git a/contrib/search_api_facetapi/example_service.php b/contrib/search_api_facetapi/example_service.php new file mode 100644 index 0000000..be3760d --- /dev/null +++ b/contrib/search_api_facetapi/example_service.php @@ -0,0 +1,209 @@ + TRUE, + 'search_api_facets_operator_or' => TRUE, + ); + return isset($supported[$feature]); + } + + /** + * Executes a search on the server represented by this object. + * + * If the service class supports facets, it should check for an additional + * option on the query object: + * - search_api_facets: An array of facets to return along with the results + * for this query. The array is keyed by an arbitrary string which should + * serve as the facet's unique identifier for this search. The values are + * arrays with the following keys: + * - field: The field to construct facets for. + * - limit: The maximum number of facet terms to return. 0 or an empty + * value means no limit. + * - min_count: The minimum number of results a facet value has to have in + * order to be returned. + * - missing: If TRUE, a facet for all items with no value for this field + * should be returned (if it conforms to limit and min_count). + * - operator: (optional) If the service supports "OR" facets and this key + * contains the string "or", the returned facets should be "OR" facets. If + * the server doesn't support "OR" facets, this key can be ignored. + * + * The basic principle of facets is explained quite well in the + * @link http://en.wikipedia.org/wiki/Faceted_search Wikipedia article on + * "Faceted search" @endlink. Basically, you should return for each field + * filter values which would yield some results when used with the search. + * E.g., if you return for a field $field the term $term with $count results, + * the given $query along with + * $query->condition($field, $term) + * should yield exactly (or about) $count results. + * + * For "OR" facets, all existing filters on the facetted field should be + * ignored for computing the facets. + * + * @param $query + * The SearchApiQueryInterface object to execute. + * + * @return array + * An associative array containing the search results, as required by + * SearchApiQueryInterface::execute(). + * In addition, if the "search_api_facets" option is present on the query, + * the results should contain an array of facets in the "search_api_facets" + * key, as specified by the option. The facets array should be keyed by the + * facets' unique identifiers, and contain a numeric array of facet terms, + * sorted descending by result count. A term is represented by an array with + * the following keys: + * - count: Number of results for this term. + * - filter: The filter to apply when selecting this facet term. A filter is + * a string of one of the following forms: + * - "VALUE": Filter by the literal value VALUE (always include the + * quotes, not only for strings). + * - [VALUE1 VALUE2]: Filter for a value between VALUE1 and VALUE2. Use + * parantheses for excluding the border values and square brackets for + * including them. An asterisk (*) can be used as a wildcard. E.g., + * (* 0) or [* 0) would be a filter for all negative values. + * - !: Filter for items without a value for this field (i.e., the + * "missing" facet). + * + * @throws SearchApiException + * If an error prevented the search from completing. + */ + public function search(SearchApiQueryInterface $query) { + // We assume here that we have an AI search which understands English + // commands. + + // First, create the normal search query, without facets. + $search = new SuperCoolAiSearch($query->getIndex()); + $search->cmd('create basic search for the following query', $query); + $ret = $search->cmd('return search results in Search API format'); + + // Then, let's see if we should return any facets. + if ($facets = $query->getOption('search_api_facets')) { + // For the facets, we need all results, not only those in the specified + // range. + $results = $search->cmd('return unlimited search results as a set'); + foreach ($facets as $id => $facet) { + $field = $facet['field']; + $limit = empty($facet['limit']) ? 'all' : $facet['limit']; + $min_count = $facet['min_count']; + $missing = $facet['missing']; + $or = isset($facet['operator']) && $facet['operator'] == 'or'; + + // If this is an "OR" facet, existing filters on the field should be + // ignored for computing the facets. + // You can ignore this if your service class doesn't support the + // "search_api_facets_operator_or" feature. + if ($or) { + // We have to execute another query (in the case of this hypothetical + // search backend, at least) to get the right result set to facet. + $tmp_search = new SuperCoolAiSearch($query->getIndex()); + $tmp_search->cmd('create basic search for the following query', $query); + $tmp_search->cmd("remove all conditions for field $field"); + $tmp_results = $tmp_search->cmd('return unlimited search results as a set'); + } + else { + // Otherwise, we can just use the normal results. + $tmp_results = $results; + } + + $filters = array(); + if ($search->cmd("$field is a date or numeric field")) { + // For date, integer or float fields, range facets are more useful. + $ranges = $search->cmd("list $limit ranges of field $field in the following set", $tmp_results); + foreach ($ranges as $range) { + if ($range->getCount() >= $min_count) { + // Get the lower and upper bound of the range. * means unlimited. + $lower = $range->getLowerBound(); + $lower = ($lower == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $lower; + $upper = $range->getUpperBound(); + $upper = ($upper == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $upper; + // Then, see whether the bounds are included in the range. These + // can be specified independently for the lower and upper bound. + // Parentheses are used for exclusive bounds, square brackets are + // used for inclusive bounds. + $lowChar = $range->isLowerBoundInclusive() ? '[' : '('; + $upChar = $range->isUpperBoundInclusive() ? ']' : ')'; + // Create the filter, which separates the bounds with a single + // space. + $filter = "$lowChar$lower $upper$upChar"; + $filters[$filter] = $range->getCount(); + } + } + } + else { + // Otherwise, we use normal single-valued facets. + $terms = $search->cmd("list $limit values of field $field in the following set", $tmp_results); + foreach ($terms as $term) { + if ($term->getCount() >= $min_count) { + // For single-valued terms, we just need to wrap them in quotes. + $filter = '"' . $term->getValue() . '"'; + $filters[$filter] = $term->getCount(); + } + } + } + + // If we should also return a "missing" facet, compute that as the + // number of results without a value for the facet field. + if ($missing) { + $count = $search->cmd("return number of results without field $field in the following set", $tmp_results); + if ($count >= $min_count) { + $filters['!'] = $count; + } + } + + // Sort the facets descending by result count. + arsort($filters); + + // With the "missing" facet, we might have too many facet terms (unless + // $limit was empty and is therefore now set to "all"). If this is the + // case, remove those with the lowest number of results. + while (is_numeric($limit) && count($filters) > $limit) { + array_pop($filters); + } + + // Now add the facet terms to the return value, as specified in the doc + // comment for this method. + foreach ($filters as $filter => $count) { + $ret['search_api_facets'][$id][] = array( + 'count' => $count, + 'filter' => $filter, + ); + } + } + } + + // Return the results, which now also includes the facet information. + return $ret; + } + +} diff --git a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc new file mode 100644 index 0000000..f438465 --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc @@ -0,0 +1,231 @@ +info['instance']; + return $base_path . '/index/' . $index_id . '/facets/' . $realm_name; + } + + /** + * Overrides FacetapiAdapter::getSearchPath(). + */ + public function getSearchPath() { + $search = $this->getCurrentSearch(); + if ($search && $search[0]->getOption('search_api_base_path')) { + return $search[0]->getOption('search_api_base_path'); + } + return $_GET['q']; + } + + /** + * 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'); + $index_id = $this->info['instance']; + $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[$index_id][$search_id])) { + // Remember this search ID. + $search_ids[$index_id][$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'], + 'limit' => $options['hard_limit'], + 'operator' => $options['operator'], + 'min_count' => $options['facet_mincount'], + 'missing' => $options['facet_missing'], + ); + } + + 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() { + return (bool) $this->getCurrentSearch(); + } + + /** + * Helper method for getting a current search for this searcher. + * + * @return array + * The first matching current search, in the form specified by + * search_api_current_search(). Or NULL, if no match was found. + */ + protected function getCurrentSearch() { + if (!isset($this->current_search)) { + $this->current_search = FALSE; + $index_id = $this->info['instance']; + // 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 for this index. :-/ + foreach (search_api_current_search() as $search) { + list($query, $results) = $search; + if ($query->getIndex()->machine_name == $index_id) { + $this->current_search = $search; + } + } + } + return $this->current_search ? $this->current_search : NULL; + } + + /** + * Returns a boolean flagging whether facets in a realm shoud be displayed. + * + * Useful, for example, for suppressing sidebar blocks in some cases. + * + * @return + * A boolean flagging whether to display a given realm. + */ + public function suppressOutput($realm_name) { + // Not sure under what circumstances the output will need to be suppressed? + return FALSE; + } + + /** + * Returns the search keys. + */ + public function getSearchKeys() { + $search = $this->getCurrentSearch(); + $keys = $search[0]->getOriginalKeys(); + if (is_array($keys)) { + // This will happen nearly never when displaying the search keys to the + // user, so go with a simple work-around. + // If someone complains, we can easily add a method for printing them + // properly. + $keys = '[' . t('complex query') . ']'; + } + else if (!$keys) { + // If a base path other than the current one is set, we assume that we + // shouldn't report on the current search. Highly hack-y, of course. + if ($search[0]->getOption('search_api_base_path', $_GET['q']) !== $_GET['q']) { + return NULL; + } + // Work-around since Facet API won't show the "Current search" block + // without keys. + $keys = '[' . t('all items') . ']'; + } + return $keys; + } + + /** + * Returns the number of total results found for the current search. + */ + public function getResultCount() { + $search = $this->getCurrentSearch(); + // Each search is an array with the query as the first element and the results + // array as the second. + if (isset($search[1])) { + return $search[1]['result count']; + } + return 0; + } + + /** + * Allows for backend specific overrides to the settings form. + */ + public function settingsForm(&$form, &$form_state) { + $facet = $form['#facetapi']['facet']; + $realm = $form['#facetapi']['realm']; + $facet_settings = $this->getFacet($facet)->getSettings($realm); + $options = $facet_settings->settings; + $search_ids = variable_get('search_api_facets_search_ids', array()); + $search_ids = $search_ids[$this->info['instance']]; + 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 a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc new file mode 100644 index 0000000..c6622b3 --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc @@ -0,0 +1,188 @@ +adapter->getActiveItems($this->facet)) { + $item = end($active); + $field = $this->facet['field']; + $regex = str_replace(array('^', '$'), '', FACETAPI_REGEX_DATE); + $filter = preg_replace_callback($regex, array($this, 'replaceDateString'), $item['value']); + $this->addFacetFilter($query, $field, $filter); + } + } + + /** + * Replacement callback for replacing ISO dates with timestamps. + */ + public function replaceDateString($matches) { + return strtotime($matches[0]); + } + + /** + * 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 = $value['filter']; + // We only process single values further. The "missing" filter and + // range filters will be passed on unchanged. + if ($filter == '!') { + $build[$filter]['#count'] = $value['count']; + } + elseif ($filter[0] == '"') { + $filter = substr($value['filter'], 1, -1); + if ($filter) { + $raw_values[$filter] = $value['count']; + } + } + else { + $filter = substr($value['filter'], 1, -1); + $pos = strpos($filter, ' '); + if ($pos !== FALSE) { + $lower = facetapi_isodate(substr($filter, 0, $pos), FACETAPI_DATE_DAY); + $upper = facetapi_isodate(substr($filter, $pos + 1), FACETAPI_DATE_DAY); + $filter = '[' . $lower . ' TO ' . $upper . ']'; + } + $build[$filter]['#count'] = $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, minute being the lowest we can go. + if ($value[0] != '[' || $value[strlen($value) - 1] != ']' || !($pos = strpos($value, ' TO '))) { + continue; + } + $start = substr($value, 1, $pos); + $end = substr($value, $pos + 4, -1); + $date_gap = facetapi_get_date_gap($start, $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 && $parent != $new_value) { + $build[$parent]['#item_children'][$new_value] = &$build[$new_value]; + $build[$new_value]['#item_parents'][$parent] = $parent; + } + } + + return $build; + } +} diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc new file mode 100644 index 0000000..82798ef --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc @@ -0,0 +1,137 @@ +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; + } + + if (FACETAPI_OPERATOR_OR == $operator) { + // If we're dealing with an OR facet, we need to use a nested filter. + $facet_filter = $query->createFilter('OR'); + } + else { + // Otherwise we set the conditions directly on the query. + $facet_filter = $query; + } + + foreach ($active as $filter => $filter_array) { + $field = $this->facet['field']; + $this->addFacetFilter($facet_filter, $field, $filter); + } + + // For OR facets, we now have to add the filter to the query. + if (FACETAPI_OPERATOR_OR == $operator) { + $query->filter($facet_filter); + } + } + + /** + * Helper method for setting a facet filter on a query or query filter object. + */ + protected function addFacetFilter($query_filter, $field, $filter) { + // Integer (or other nun-string) filters might mess up some of the following + // comparison expressions. + $filter = (string) $filter; + if ($filter == '!') { + $query_filter->condition($field, NULL); + } + elseif ($filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) { + $lower = trim(substr($filter, 1, $pos)); + $upper = trim(substr($filter, $pos + 4, -1)); + if ($lower == '*' && $upper == '*') { + $query_filter->condition($field, NULL, '<>'); + } + else { + if ($lower != '*') { + $query_filter->condition($field, $lower, '>='); + } + if ($upper != '*') { + $query_filter->condition($field, $upper, '<'); + } + } + } + else { + $query_filter->condition($field, $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']]; + $search = search_api_current_search($search_id); + $build = array(); + $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 = $value['filter']; + // As Facet API isn't really suited for our native facet filter + // representations, convert the format here. (The missing facet can + // stay the same. + if ($filter[0] == '"') { + $filter = substr($filter, 1, -1); + } + elseif ($filter != '!') { + // This is a range filter. + $filter = substr($filter, 1, -1); + $pos = strpos($filter, ' '); + if ($pos !== FALSE) { + $filter = '[' . substr($filter, 0, $pos) . ' TO ' . substr($filter, $pos + 1) . ']'; + } + } + $build[$filter] = array( + '#count' => $value['count'], + ); + } + } + } + return $build; + } + +} diff --git a/contrib/search_api_facetapi/search_api_facetapi.info b/contrib/search_api_facetapi/search_api_facetapi.info new file mode 100644 index 0000000..64b6294 --- /dev/null +++ b/contrib/search_api_facetapi/search_api_facetapi.info @@ -0,0 +1,10 @@ +name = Search facets +description = "Integrate the Search API with the Facet API to provide facetted searches." +dependencies[] = search_api +dependencies[] = facetapi +core = 7.x +package = Search + +files[] = plugins/facetapi/adapter.inc +files[] = plugins/facetapi/query_type_term.inc +files[] = plugins/facetapi/query_type_date.inc diff --git a/contrib/search_api_facetapi/search_api_facetapi.module b/contrib/search_api_facetapi/search_api_facetapi.module new file mode 100644 index 0000000..db52816 --- /dev/null +++ b/contrib/search_api_facetapi/search_api_facetapi.module @@ -0,0 +1,371 @@ + $realm) { + if ($first) { + $first = FALSE; + $items['admin/config/search/search_api/index/%search_api_index/facets'] = array( + 'title' => 'Facets', + 'page callback' => 'search_api_facetapi_settings', + 'page arguments' => array($realm_name, 5), + 'weight' => -1, + 'access arguments' => array('administer search_api'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + ); + $items['admin/config/search/search_api/index/%search_api_index/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => $realm['weight'], + ); + } + else { + $items['admin/config/search/search_api/index/%search_api_index/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'page callback' => 'search_api_facetapi_settings', + 'page arguments' => array($realm_name, 5), + 'access arguments' => array('administer search_api'), + 'type' => MENU_LOCAL_TASK, + 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE, + 'weight' => $realm['weight'], + ); + } + } + + return $items; +} + +/** + * Implements hook_facetapi_searcher_info(). + */ +function search_api_facetapi_facetapi_searcher_info() { + $info = array(); + $indexes = search_api_index_load_multiple(FALSE); + foreach ($indexes as $index) { + if ($index->enabled && $index->server()->supportsFeature('search_api_facets')) { + $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, + 'types' => array($index->item_type), + 'path' => '', + 'supports facet missing' => TRUE, + 'supports facet mincount' => TRUE, + ); + } + } + return $info; +} + +/** + * Implements hook_facetapi_facet_info(). + */ +function search_api_facetapi_facetapi_facet_info(array $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'])) { + $wrapper = $index->entityWrapper(); + + // Some type-specific settings. Allowing to set some additional callbacks + // (and other settings) in the map options allows for easier overriding by + // other modules. + $type_settings = array( + 'taxonomy_term' => array( + 'hierarchy callback' => 'facetapi_get_taxonomy_hierarchy', + ), + 'date' => array( + 'query type' => 'date', + 'map options' => array( + 'map callback' => 'facetapi_map_date', + ), + ), + ); + + // Iterate through the indexed fields to set the facetapi settings for + // each one. + foreach ($index->options['fields'] as $key => $field) { + if (!$field['indexed']) { + continue; + } + + $field['key'] = $key; + // Determine which, if any, of the field type-specific options will be + // used for this field. + $type = isset($field['entity_type']) ? $field['entity_type'] : $field['type']; + $type_settings += array($type => array()); + + $facet_info[$key] = $type_settings[$type] + array( + 'label' => $field['name'], + 'description' => t('Filter by @type.', array('@type' => $field['name'])), + 'allowed operators' => array( + FACETAPI_OPERATOR_AND => TRUE, + FACETAPI_OPERATOR_OR => $index->server()->supportsFeature('search_api_facets_operator_or'), + ), + 'dependency plugins' => array('role'), + 'facet missing allowed' => TRUE, + 'facet mincount allowed' => TRUE, + 'map callback' => 'search_api_facetapi_facet_map_callback', + 'map options' => array(), + 'field type' => $type, + ); + $facet_info[$key]['map options'] += array( + 'field' => $field, + 'index id' => $index->machine_name, + 'value callback' => '_search_api_facetapi_facet_create_label', + ); + // Find out whether this property is a Field API field. + if (strpos($key, ':') === FALSE) { + if (isset($wrapper->$key)) { + $property_info = $wrapper->$key->info(); + if (!empty($property_info['field'])) { + $facet_info[$key]['field api name'] = $key; + } + } + } + } + } + $facets[$searcher_info['instance']] = $facet_info; + } + return $facet_info; +} + +/** + * Implements hook_facetapi_facet_info_alter(). + */ +function search_api_facetapi_facetapi_facet_info_alter(array &$facet_info, array $searcher) { + if ($searcher['adapter'] == 'search_api') { + $facets = drupal_static('search_api_facetapi_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_facetapi_adapters() { + return array( + 'search_api' => array( + 'handler' => array( + 'class' => 'SearchApiFacetapiAdapter', + ), + ), + ); +} + +/** + * Implements hook_facetapi_query_types(). + */ +function search_api_facetapi_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', + ), + ), + ); +} + +/** + * Implements hook_search_api_query_alter(). + * + * Adds Facet API support to the query. + */ +function search_api_facetapi_search_api_query_alter($query) { + $index = $query->getIndex(); + if ($index->server()->supportsFeature('search_api_facets')) { + // 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); + } + } +} + +/** + * Menu callback for the facet settings page. + */ +function search_api_facetapi_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 drupal_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name); +} + +/** + * Map callback for all search_api facet fields. + * + * @param array $values + * The values to map. + * @param array $options + * An associative array containing: + * - field: Field information, as stored in the index, but with an additional + * "key" property set to the field's internal name. + * - index id: The machine name of the index for this facet. + * - map callback: (optional) A callback that will be called at the beginning, + * which allows initial mapping of filters. Only values not mapped by that + * callback will be processed by this method. + * - value callback: A callback used to map single values and the limits of + * ranges. The signature is the same as for this function, but all values + * will be single values. + * - missing label: (optional) The label used for the "missing" facet. + * + * @return array + * An array mapping raw filter values to their labels. + */ +function search_api_facetapi_facet_map_callback(array $values, array $options = array()) { + $map = array(); + // See if we have an additional map callback. + if (isset($options['map callback']) && is_callable($options['map callback'])) { + $map = call_user_func($options['map callback'], $values, $options); + } + + // Then look at all unmapped values and save information for them. + $mappable_values = array(); + $ranges = array(); + foreach ($values as $value) { + if (isset($map[$value])) { + continue; + } + if ($value == '!') { + // The "missing" filter is usually always the same, but we allow an easy + // override via the "missing label" map option. + $map['!'] = isset($options['missing label']) ? $options['missing label'] : '(' . t('none') . ')'; + continue; + } + if ($value[0] == '[' && $value[strlen($value) - 1] == ']' && ($pos = strpos($value, ' TO '))) { + // This is a range filter. + $lower = trim(substr($value, 1, $pos)); + $upper = trim(substr($value, $pos + 4, -1)); + if ($lower != '*') { + $mappable_values[$lower] = TRUE; + } + if ($upper != '*') { + $mappable_values[$upper] = TRUE; + } + $ranges[$value] = array( + 'lower' => $lower, + 'upper' => $upper, + ); + } + else { + // A normal, single-value filter. + $mappable_values[$value] = TRUE; + } + } + + if ($mappable_values) { + $map += call_user_func($options['value callback'], array_keys($mappable_values), $options); + } + + foreach ($ranges as $value => $range) { + $lower = isset($map[$range['lower']]) ? $map[$range['lower']] : $range['lower']; + $upper = isset($map[$range['upper']]) ? $map[$range['upper']] : $range['upper']; + if ($lower == '*' && $upper == '*') { + $map[$value] = t('any'); + } + elseif ($lower == '*') { + $map[$value] = "< $upper"; + } + elseif ($upper == '*') { + $map[$value] = "> $lower"; + } + else { + $map[$value] = "$lower – $upper"; + } + } + + return $map; +} + +/** + * Creates a human-readable label for single facet filter values. + */ +function _search_api_facetapi_facet_create_label(array $values, array $options) { + $field = $options['field']; + // For entities, we can simply use the entity labels. + if (isset($field['entity_type'])) { + $type = $field['entity_type']; + $entities = entity_load($type, $values); + $map = array(); + foreach ($entities as $id => $entity) { + $label = entity_label($type, $entity); + if ($label) { + $map[$id] = $label; + } + } + return $map; + } + // Then, we check whether there is an options list for the field. + $index = search_api_index_load($options['index id']); + $wrapper = $index->entityWrapper(); + foreach (explode(':', $field['key']) as $part) { + if (!isset($wrapper->$part)) { + $wrapper = NULL; + break; + } + $wrapper = $wrapper->$part; + while (($info = $wrapper->info()) && search_api_is_list_type($info['type'])) { + $wrapper = $wrapper[0]; + } + } + if ($wrapper && ($options = $wrapper->optionsList('view'))) { + return $options; + } + // As a "last resort" we try to create a label based on the field type. + $map = array(); + foreach ($values as $value) { + switch ($field['type']) { + case 'boolean': + $map[$value] = $value ? t('true') : t('false'); + break; + case 'date': + $v = is_numeric($value) ? $value : strtotime($value); + $map[$value] = format_date($v, 'short'); + break; + case 'duration': + $map[$value] = format_interval($value); + break; + } + } + return $map; +} diff --git a/contrib/search_api_facets/README.txt b/contrib/search_api_facets/README.txt index 4e3c43d..c22a679 100644 --- a/contrib/search_api_facets/README.txt +++ b/contrib/search_api_facets/README.txt @@ -1,4 +1,3 @@ - Search facets ------------- @@ -8,6 +7,16 @@ module. The only thing you'll need is a search service class that supports the "search_api_facets" feature. Currently, the "Database search" and "Solr search" modules supports this. +IMPORTANT: This module has been deprecated in favor of the search_api_facetapi +module (also contained in this project). It won't receive any further updates +and will most likely be removed in the future, before a stable release of the +Search API module is created. Please move to the other module as soon as +possible. + +To ease migration, there is a simple form for moving facets to the new settings +on the "Old facets" tab of indexes for which there are saved facets. +You can also use the search_api_facets_export_to_facetapi() function directly. + Information for site builders diff --git a/contrib/search_api_facets/search_api_facets.admin.inc b/contrib/search_api_facets/search_api_facets.admin.inc index 5c9fd58..9d30b3d 100644 --- a/contrib/search_api_facets/search_api_facets.admin.inc +++ b/contrib/search_api_facets/search_api_facets.admin.inc @@ -11,12 +11,89 @@ function search_api_facets_index_select(array $form, array &$form_state, SearchA module_load_include('admin.inc', 'search_api'); drupal_set_title(search_api_admin_item_title($index)); $form_state['index'] = $index; + $index_facets = search_api_facet_load_multiple(FALSE, array('index_id' => $index->machine_name)); + + if ($index_facets && module_exists('search_api_facetapi')) { + $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css'; + $form['export'] = array( + '#type' => 'fieldset', + '#title' => t('Export settings to new module'), + '#description' => t('

This way of defining facets has been deprecated. ' . + 'You can use this form to export your existing facet definitions to the new settings.

' . + '

Once you have migrated the settings for all indexes, you should disable the "Old search facets" module.

' . + '

Note: In case of collisions, the new settings will be overwritten with ' . + 'settings defined here (and in the accompanying block configurations)!

', + array('@url' => url('admin/config/search/search_api/index/' . $index->machine_name . '/facets'))), + '#tree' => TRUE, + '#collapsible' => TRUE, + '#collapsed' => TRUE, + ); + $form['export']['method'] = array( + '#type' => 'select', + '#title' => t('Facets to export'), + '#options' => array( + 'all' => t('All saved facets'), + 'enabled' => t('All enabled facets'), + 'custom' => t('Manual'), + ), + '#default_value' => 'all', + ); + $form['export']['facets'] = array( + '#type' => 'checkboxes', + '#title' => t('Select facets to export'), + '#description' => t('If you select "Manual" above, you can manually select the facets to export for this index here.'), + '#options' => array(), + '#attributes' => array('class' => array('search-api-checkboxes-list')), + ); + $form['export']['facets']['#states']['visible'][':input[name="export[method]"]']['value'] = 'custom'; + foreach ($index_facets as $facet) { + if (!empty($fields[$facet->field])) { + $form['export']['#description'] .= '

' . t('Warning: ' . + 'Multiple facets for the same field are currently not supported by the Facet API. ' . + 'Therefore, they cannot be exported. If having multiple facets for the same field is crucial for your site, ' . + 'please ask for help in the issue queue.', + array('@url' => 'http://drupal.org/project/issues/search_api')) . '

'; + } + $form['export']['facets']['#options'][$facet->delta] = $facet->name; + $fields[$facet->field] = TRUE; + } + $form['export']['realms'] = array( + '#type' => 'checkboxes', + '#title' => t('Export to realms'), + '#description' => t('Select the realms to which realm-specific settings will be exported. ' . + 'Global settings will be exported in any case.'), + '#options' => array(), + '#default_value' => array(), + '#attributes' => array('class' => array('search-api-checkboxes-list')), + ); + foreach (facetapi_get_realm_info() as $key => $realm) { + $form['export']['realms']['#options'][$key] = $realm['label']; + $form['export']['realms']['#default_value'][$key] = $key; + } + + $form['export']['submit'] = array( + '#type' => 'submit', + '#value' => t('Export settings'), + '#submit' => array('_search_api_facets_index_export_submit'), + ); + } + elseif (!module_exists('search_api_facetapi')) { + $msg = '

' . t('This module has been deprecated in favor of the new "Search facets" module. To switch:') . '

'; + $msg .= ''; + drupal_set_message($msg, 'warning'); + } if (!$index->server() || !$index->server()->supportsFeature('search_api_facets')) { // We can't offer any facets, but maybe the user can delete old facet data. drupal_set_message(t("The server this index currently lies on doesn't support facets. " . 'To use facets, you will have to move this index to a server supporting this feature.'), 'error'); - if (search_api_facet_load_multiple(FALSE, array('index_id' => $index->machine_name))) { + if ($index_facets) { $form['description'] = array( '#type' => 'item', '#title' => t('Delete facet settings'), @@ -63,7 +140,7 @@ function search_api_facets_index_select(array $form, array &$form_state, SearchA drupal_set_message('Since this index is at the moment disabled, no facet blocks can be activated.', 'warning'); } $show_status = FALSE; - foreach (search_api_facet_load_multiple(FALSE, array('index_id' => $index->machine_name)) as $facet) { + foreach ($index_facets as $facet) { $facets[$facet->field][] = $facet; if ($facet->hasStatus(ENTITY_IN_CODE)) { $show_status = TRUE; @@ -262,6 +339,49 @@ function search_api_facets_index_select_submit_delete(array $form, array &$form_ } /** + * Submit callback for search_api_facets_index_select(), for exporting settings. + */ +function _search_api_facets_index_export_submit(array $form, array &$form_state) { + $index = $form_state['index']; + $method = $form_state['values']['export']['method']; + + // Load appropriate facets. + $ids = ($method == 'custom') ? array_filter($form_state['values']['export']['facets']) : NULL; + $conditions = ($method == 'enabled') ? array('enabled' => 1) : array(); + $conditions['index_id'] = $index->machine_name; + $facets = $facets = search_api_facet_load_multiple(FALSE, $method == 'all' ? array() : array('enabled' => 1)); + + // Don't include two facets for the same field. + foreach ($facets as $delta => $facet) { + if (!empty($fields[$facet->field])) { + unset($facets[$delta]); + } + else { + $fields[$facet->field] = TRUE; + } + } + + // Load realms. + $realms = array(); + foreach (array_filter($form_state['values']['export']['realms']) as $realm) { + $realms[$realm] = facetapi_realm_load($realm); + } + $realms = array_filter($realms); + + // Export settings. + search_api_facets_export_to_facetapi($index, $facets, $realms); + + // Show success message. + $items = array(); + foreach ($facets as $facet) { + $items[] = $facet->name; + } + drupal_set_message('

' . t('Successfully exported the settings of the following facets for this index:') . '

' . + theme('item_list', array('items' => $items))); + +} + +/** * Theming function for rendering a form as a table. * * @param array $variables diff --git a/contrib/search_api_facets/search_api_facets.info b/contrib/search_api_facets/search_api_facets.info index 0c90543..c95d6f6 100644 --- a/contrib/search_api_facets/search_api_facets.info +++ b/contrib/search_api_facets/search_api_facets.info @@ -1,6 +1,6 @@ -name = Search facets -description = "Create facets for search queries executed via the Search API." +name = Old search facets (deprecated) +description = "Use the new 'Search facets' module integrating with the Facet API instead." dependencies[] = search_api dependencies[] = block core = 7.x diff --git a/contrib/search_api_facets/search_api_facets.install b/contrib/search_api_facets/search_api_facets.install index 5cbc956..dc36760 100644 --- a/contrib/search_api_facets/search_api_facets.install +++ b/contrib/search_api_facets/search_api_facets.install @@ -1,6 +1,24 @@ Facet API instead. ' . + 'Easy migration functionality is available on the "Old facets" tab of indexes when both modules are enabled.', + array('@url' => url('http://drupal.org/project/facetapi'))); + $ret['search_api_facets']['severity'] = REQUIREMENT_WARNING; + + return $ret; +} + +/** * Implements hook_schema(). */ function search_api_facets_schema() { diff --git a/contrib/search_api_facets/search_api_facets.module b/contrib/search_api_facets/search_api_facets.module index 29bb8ca..49360ed 100644 --- a/contrib/search_api_facets/search_api_facets.module +++ b/contrib/search_api_facets/search_api_facets.module @@ -15,8 +15,8 @@ function search_api_facets_help($path, array $arg) { * Implements hook_menu(). */ function search_api_facets_menu() { - $items['admin/config/search/search_api/index/%search_api_index/facets'] = array( - 'title' => 'Facets', + $items['admin/config/search/search_api/index/%search_api_index/old_facets'] = array( + 'title' => 'Old facets', 'description' => 'Select the facet blocks to display.', 'page callback' => 'drupal_get_form', 'page arguments' => array('search_api_facets_index_select', 5), @@ -1021,3 +1021,71 @@ function _search_api_create_value_name($value, $type, array $values = array()) { function _search_api_facets_compare_iname(stdClass $term1, stdClass $term2) { return strcmp($term1->iname, $term2->iname); } + +/** + * Helper function for exporting facets to the Facet API. + * + * Writes the settings for the given facets (or all facets of the index) into + * the global Facet API settings. + * + * CAUTION: Existing colliding settings in the Facet API will be overwritten! + * + * @param SearchApiIndex $index + * The index for which the exported facets are defined. + * @param $facets + * The facets that should be exported, or NULL to export all saved facets of + * the index. + * @param + * An array of realms to which realm-specific settings should be written; or + * NULL to write to all available realms. + */ +function search_api_facets_export_to_facetapi(SearchApiIndex $index, $facets = NULL, $realms = NULL) { + if (!isset($facets)) { + $facets = search_api_facet_load_multiple(FALSE, array('index_id' => $index->machine_name)); + } + if (!isset($realms)) { + $realms = facetapi_get_realm_info(); + } + $searcher = 'search_api@' . $index->machine_name; + $adapter = facetapi_adapter_load($searcher); + foreach ($facets as $facet) { + $options = $facet->options; + $f = facetapi_facet_load($facet->field, $searcher); + if ($f) { + $f = $adapter->getFacet($f); + // Export to global Facet API facet settings. + $settings = $f->getSettings(); + $settings->settings['hard_limit'] = max($options['limit'], $options['more_limit']); + $settings->settings['facet_mincount'] = $options['min_count']; + $settings->settings['facet_missing'] = $options['missing'] ? 1 : 0; + $settings->settings['default_true'] = $options['default_true']; + $settings->settings['facet_search_ids'] = $options['ids_list']; + ctools_export_crud_save('facetapi', $settings); + + // Export to realm-specific Facet API facet settings. + foreach ($realms as $realm) { + $settings = $f->getSettings($realm); + $settings->enabled = $facet->enabled ? 1 : 0; + $settings->settings['active_sorts'] = drupal_map_assoc(array('active', 'count', 'display')); + $settings->settings['sort_weight'] = array( + 'active' => -50, + 'count' => -49, + 'display' => -48, + 'indexed' => -47, + ); + $settings->settings['sort_weight'] = array( + 'active' => SORT_DESC, + 'count' => SORT_DESC, + 'display' => SORT_ASC, + 'indexed' => SORT_ASC, + ); + if ($options['sort'] != 'count') { + unset($settings->settings['active_sorts']['count']); + } + $settings->settings['soft_limit'] = $options['limit']; + $settings->settings['filters']['active_items']['status'] = !$options['show_active']; + ctools_export_crud_save('facetapi', $settings); + } + } + } +} diff --git a/contrib/search_api_views/README.txt b/contrib/search_api_views/README.txt index 4833616..f46b548 100644 --- a/contrib/search_api_views/README.txt +++ b/contrib/search_api_views/README.txt @@ -11,7 +11,7 @@ on any search index. ------------------------ This module defines the "More like this" feature (feature key: "search_api_mlt") that search service classes can implement. With a server supporting this, you -can use the "More like this" contextual filter to display a list of items +can use the „More like this“ contextual filter to display a list of items related to a given item (usually, nodes similar to the node currently viewed). For developers: @@ -28,7 +28,7 @@ should all work normally. ---------------------- Most features should be clear to users of Views. However, the module also provides a new display type, "Facets block", that might need some explanation. -This display type is only available, if the Search facets module is also +This display type is only available, if the „Search facets“ module is also enabled. The basic use of the block is to provide a list of links to the most popular @@ -40,22 +40,28 @@ Please note that, due to limitations in Views, this display mode is shown for views of all base tables, even though it only works for views based on Search API indexes. For views of other base tables, this will just print an error message. -The display will also always ignore the view's "Style" setting. +The display will also always ignore the view's "Style" setting, selected fields +and sorts, etc. To use the display, specify the base path of the search you want to link to (this enables you to also link to searches that aren't based on Views) and the facet field to use (any indexed field can be used here, there needn't be a facet defined for it). You'll then have the block available in the blocks administration and can enable and move it at leisure. +Note, however, that the facet in question has to be enabled for the search page +linked to for the filter to have an effect. -You should note two things, though: First, if you want to display the block not -only on a few pages, you should in any case take care that it isn't displayed -on the search page, since that might confuse users. -Also, since the block executes a search query to retrieve the facets, its -display will potentially trigger other facet blocks to be displayed for that -search. To prevent this, set the other facet blocks to either not display on the -pages where the Views facet block is shown, or to ignore the search executed by -the block (recognizable by the "-facet_block" suffix). +Since the block will trigger a search on pages where it is set to appear, you +can also enable additional „normal“ facet blocks for that search, via the +„Facets“ tab for the index. They will automatically also point to the same +search that you specified for the display. The Search ID of the „Facets blocks“ +display can easily be recognized by the "-facet_block" suffix. +If you want to use only the normal facets and not display anything at all in +the Views block, just activate the display's „Hide block“ option. + +Note: If you want to display the block not only on a few pages, you should in +any case take care that it isn't displayed on the search page, since that might +confuse users. Make fields in greater depths available --------------------------------------- diff --git a/contrib/search_api_views/includes/display_facet_block.inc b/contrib/search_api_views/includes/display_facet_block.inc index cecd95f..683e4ee 100644 --- a/contrib/search_api_views/includes/display_facet_block.inc +++ b/contrib/search_api_views/includes/display_facet_block.inc @@ -21,6 +21,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { $options['linked_path'] = array('default' => ''); $options['facet_field'] = ''; + $options['hide_block'] = FALSE; return $options; } @@ -56,6 +57,15 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { '#value' => $this->get_option('use_more_always'), ); break; + case 'hide_block': + $form['hide_block'] = array( + '#type' => 'checkbox', + '#title' => t('Hide block'), + '#description' => t('Hide this block, but still execute the search. ' . + 'Can be used to show native Facet API facet blocks linking to the search page specified above.'), + '#default_value' => $this->get_option('hide_block'), + ); + break; } } @@ -75,6 +85,9 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { case 'facet_field': $this->set_option('facet_field', $form_state['values']['facet_field']); break; + case 'hide_block': + $this->set_option('hide_block', $form_state['values']['hide_block']); + break; } } @@ -92,6 +105,11 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { 'title' => t('Facet field'), 'value' => $this->get_option('facet_field') ? $field_options[$this->get_option('facet_field')] : t('None'), ); + $options['hide_block'] = array( + 'category' => 'block', + 'title' => t('Hide block'), + 'value' => $this->get_option('hide_block') ? t('Yes') : t('No'), + ); } protected $field_options = NULL; @@ -152,16 +170,25 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { $this->view->build(); $limit = empty($this->view->query->pager->options['items_per_page']) ? 10 : $this->view->query->pager->options['items_per_page']; $query_options = &$this->view->query->getOptions(); - $query_options['search_api_facets']['search_api_views_facets_block'] = array( - 'field' => $facet_field, - 'limit' => $limit, - 'missing' => FALSE, - 'min_count' => 1, - ); + if (!$this->get_option('hide_block')) { + // If we hide the block, we don't need this extra facet. + $query_options['search_api_facets']['search_api_views_facets_block'] = array( + 'field' => $facet_field, + 'limit' => $limit, + 'missing' => FALSE, + 'min_count' => 1, + ); + } $query_options['search id'] = 'search_api_views:' . $this->view->name . '-facets_block'; + $query_options['search_api_base_path'] = $base_path; $this->view->query->range(0, 0); $this->view->execute(); + + if ($this->get_option('hide_block')) { + return NULL; + } + $results = $this->view->query->getSearchApiResults(); if (empty($results['search_api_facets']['search_api_views_facets_block'])) { @@ -169,56 +196,66 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { } $terms = $results['search_api_facets']['search_api_views_facets_block']; - $facet = entity_create('search_api_facet', array( - 'index_id' => substr($this->view->base_table, 17), - 'field' => $facet_field, - 'options' => array(), - )); - _search_api_facets_refresh_type($facet, $facet->options); - - $values = isset($facet->options['options']) ? $facet->options['options'] : array(); - if (!$values && isset($facet->options['entity_type']) && entity_get_info($facet->options['entity_type'])) { - $ids = array(); - foreach ($terms as $term) { - if ($term['filter'][0] == '"') { - $ids[] = substr($term['filter'], 1, -1); - } + $filters = array(); + foreach ($terms as $term) { + $filter = $term['filter']; + if ($filter[0] == '"') { + $filter = substr($filter, 1, -1); } - if ($ids) { - $entities = entity_load($facet->options['entity_type'], $ids); - if ($entities) { - $values = array(); - foreach ($entities as $id => $entity) { - $label = entity_label($facet->options['entity_type'], $entity); - if ($label) { - $values[$id] = $label; - } - } - $facet->options['options'] = $values; + elseif ($filter != '!') { + // This is a range filter. + $filter = substr($filter, 1, -1); + $pos = strpos($filter, ' '); + if ($pos !== FALSE) { + $filter = '[' . substr($filter, 0, $pos) . ' TO ' . substr($filter, $pos + 1) . ']'; } } + $filters[$term['filter']] = $filter; } - $theme_suffix = ''; - $theme_suffix .= '__' . preg_replace('/\W+/', '_', $this->view->query->getIndex()->item_type); - $theme_suffix .= '__' . preg_replace('/\W+/', '_', $facet_field); - $theme_suffix .= '__search_api_views_facets_block'; - $info['content']['facets'] = array( - '#theme' => 'search_api_facets_list' . $theme_suffix, - '#terms' => array(), - ); + $index = $this->view->query->getIndex(); + $options['field'] = $index->options['fields'][$facet_field]; + $options['field']['key'] = $facet_field; + $options['index id'] = $index->machine_name; + $options['value callback'] = '_search_api_facetapi_facet_create_label'; + $map = search_api_facetapi_facet_map_callback($filters, $options); + + $facets = array(); + $prefix = rawurlencode($facet_field) . ':'; foreach ($terms as $term) { - $query = array(); - $query['filter'][$facet_field][0] = $term['filter']; - $info['content']['facets']['#terms'][] = array( - '#theme' => 'search_api_facets_facet' . $theme_suffix, - '#name' => _search_api_create_filter_name($term['filter'], $facet->options['type'], $values), - '#count' => $term['count'], - '#path' => $base_path, - '#options' => array('query' => $query), - '#active' => FALSE, + $name = $filter = $filters[$term['filter']]; + if (isset($map[$filter])) { + $name = $map[$filter]; + } + $query['f'][0] = $prefix . $filter; + + // Initializes variables passed to theme hook. + $variables = array( + 'text' => $name, + 'path' => $base_path, + 'count' => $term['count'], + 'options' => array( + 'attributes' => array('class' => 'facetapi-inactive'), + 'html' => FALSE, + 'query' => $query, + ), + ); + + // Themes the link, adds row to facets. + $facets[] = array( + 'class' => array('leaf'), + 'data' => theme('facetapi_link_inactive', $variables), ); } + + if (!$facets) { + return NULL; + } + + $info['content']['facets'] = array( + '#theme' => 'item_list', + '#items' => $facets, + ); $info['content']['more'] = $this->render_more_link(); $info['subject'] = filter_xss_admin($this->view->get_title()); return $info; diff --git a/contrib/search_api_views/search_api_views.views.inc b/contrib/search_api_views/search_api_views.views.inc index f537f6b..4977df6 100644 --- a/contrib/search_api_views/search_api_views.views.inc +++ b/contrib/search_api_views/search_api_views.views.inc @@ -286,7 +286,7 @@ function search_api_views_views_plugins() { ), ); - if (module_exists('search_api_facets')) { + if (module_exists('search_api_facetapi')) { $ret['display']['search_api_views_facets_block'] = array( 'title' => t('Facets block'), 'help' => t('Display facets for this search as a block anywhere on the site.'), diff --git a/includes/service.inc b/includes/service.inc index 9736d07..29fc637 100644 --- a/includes/service.inc +++ b/includes/service.inc @@ -60,8 +60,14 @@ interface SearchApiServiceInterface { * Determines whether this service class implementation supports a given * feature. Features are optional extensions to Search API functionality and * usually defined and used by third-party modules. - * Currently, the only feature specified directly in the search_api project is - * "search_api_facets", defined by the module of the same name. + * + * There are currently three features defined directly in the Search API + * project: + * - "search_api_facets", by the search_api_facetapi module. + * - "search_api_facets_operator_or", also by the search_api_facetapi module. + * - "search_api_mlt", by the search_api_views module. + * Other contrib modules might define additional features. These should always + * be properly documented in the module by which they are defined. * * @param string $feature * The name of the optional feature. @@ -167,11 +173,11 @@ interface SearchApiServiceInterface { * - value: The word that the token represents. * - score: A score for the importance of that word. * - * @throws SearchApiException - * If indexing was prevented by a fundamental configuration error. - * * @return array * An array of the ids of all items that were successfully indexed. + * + * @throws SearchApiException + * If indexing was prevented by a fundamental configuration error. */ public function indexItems(SearchApiIndex $index, array $items); @@ -201,11 +207,11 @@ interface SearchApiServiceInterface { * Associative array of options configuring this query. See * SearchApiQueryInterface::__construct(). * - * @throws SearchApiException - * If the server is currently disabled. - * * @return SearchApiQueryInterface * An object for searching the given index. + * + * @throws SearchApiException + * If the server is currently disabled. */ public function query(SearchApiIndex $index, $options = array()); @@ -215,12 +221,12 @@ interface SearchApiServiceInterface { * @param $query * The SearchApiQueryInterface object to execute. * - * @throws SearchApiException - * If an error prevented the search from completing. - * * @return array * An associative array containing the search results, as required by * SearchApiQueryInterface::execute(). + * + * @throws SearchApiException + * If an error prevented the search from completing. */ public function search(SearchApiQueryInterface $query); @@ -310,8 +316,14 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface { * Determines whether this service class implementation supports a given * feature. Features are optional extensions to Search API functionality and * usually defined and used by third-party modules. - * Currently, the only feature specified directly in the search_api project is - * "search_api_facets", defined by the module of the same name. + * + * There are currently three features defined directly in the Search API + * project: + * - "search_api_facets", by the search_api_facetapi module. + * - "search_api_facets_operator_or", also by the search_api_facetapi module. + * - "search_api_mlt", by the search_api_views module. + * Other contrib modules might define additional features. These should always + * be properly documented in the module by which they are defined. * * @param string $feature * The name of the optional feature. @@ -444,11 +456,11 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface { * Associative array of options configuring this query. See * SearchApiQueryInterface::__construct(). * - * @throws SearchApiException - * If the server is currently disabled. - * * @return SearchApiQueryInterface * An object for searching the given index. + * + * @throws SearchApiException + * If the server is currently disabled. */ public function query(SearchApiIndex $index, $options = array()) { return new SearchApiQuery($index, $options);