diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 6e8d250..63c417e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,21 @@ Search API 1.x, dev (xx/xx/xxxx): --------------------------------- +- #1414078 by drunken monkey, jaxxed: Fixed revert of exportables. +- #2011396 by drunken monkey: Fixed support for several facets on a single + field. +- #2050117 by izus, drunken monkey: Updated README.txt to reflect removed + sub-modules. +- #2041365 by drunken monkey: Fixed error reporting for the MLT contextual + filter. +- #2044711 by stBorchert, drunken monkey: Fixed facet adapter's + getCurrentSearch() method to not cache failed attempts. +- #1411712 by Krasnyj, drunken monkey: Fixed notices in Views with groups. +- #1959506 by jantoine, drunken monkey: Fixed "search id" for Views facets + block display. +- #1902168 by rbruhn, drunken monkey, mpv: Fixed fatal error during Features + import. +- #2040111 by arpieb: Fixed Views URL argument handler to allow multiple values. +- #1064520 by drunken monkey: Added a processor for highlighting. Search API 1.7 (07/01/2013): ---------------------------- diff --git a/README.txt b/README.txt index 5f652b7..9ea75ed 100644 --- a/README.txt +++ b/README.txt @@ -105,8 +105,10 @@ IMPORTANT: Access checks specific search types, if available. As stated above, you will need at least one other module to use the Search API, -namely one that defines a service class (e.g. search_api_db ("Database search"), -provided with this module). +namely one that defines a service class (e.g., search_api_db ("Database search") +which can be found at [3]). + +[3] http://drupal.org/project/search_api_db - Creating a server (Configuration > Search API > Add server) @@ -227,9 +229,9 @@ Information for developers | For custom field types to be available for indexing, provide a | "property_type" key in hook_field_info(), and optionally a callback at the | "property_callbacks" key. - | Both processes are explained in [1]. + | Both processes are explained in [4]. | - | [1] http://drupal.org/node/1021466 + | [4] http://drupal.org/node/1021466 Apart from improving the module itself, developers can extend search capabilities provided by the Search API by providing implementations for one (or @@ -263,7 +265,9 @@ service class. The central methods here are the indexItems() and the search() methods, which always have to be overridden manually. The configurationForm() method allows services to provide custom settings for the user. -See the SearchApiDbService class for an example implementation. +See the SearchApiDbService class provided by [5] for an example implementation. + +[5] http://drupal.org/project/search_api_db - Query class Interface: SearchApiQueryInterface @@ -342,15 +346,6 @@ See the processors in includes/processor.inc for examples. Included components ------------------- -- Service classes - - * Database search - A search server implementation that uses the normal database for indexing - data. It isn't very fast and the results might also be less accurate than - with third-party solutions like Solr, but it's very easy to set up and good - for smaller applications or testing. - See contrib/search_api_db/README.txt for details. - - Data alterations * URL field @@ -399,14 +394,12 @@ Included components - Additional modules - * Search pages - This module lets you create simple search pages for indexes. * Search views - This integrates the Search API with the Views module [1], enabling the user + This integrates the Search API with the Views module [6], enabling the user to create views which display search results from any Search API index. * Search facets For service classes supporting this feature (e.g. Solr search), this module automatically provides configurable facet blocks on pages that execute a search query. -[1] http://drupal.org/project/views +[6] http://drupal.org/project/views diff --git a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc index 102c10b..b99f7dc 100644 --- a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc +++ b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc @@ -128,8 +128,10 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter { * search_api_current_search(). Or NULL, if no match was found. */ public function getCurrentSearch() { + // Even if this fails once, there might be a search query later in the page + // request. We therefore don't store anything in $this->current_search in + // case of failure, but just try again if the method is called again. 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 @@ -143,7 +145,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter { } } } - return $this->current_search ? $this->current_search : NULL; + return $this->current_search; } /** diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc index 7277b56..ab3002c 100644 --- a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc @@ -85,8 +85,8 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue // 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']]; + if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) { + $values = $results['search_api_facets'][$this->facet['name']]; foreach ($values as $value) { if ($value['count']) { $filter = $value['filter']; diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc index 1b2037d..1d0e8eb 100644 --- a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc @@ -120,8 +120,8 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy $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']]; + if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) { + $values = $results['search_api_facets'][$this->facet['name']]; foreach ($values as $value) { $filter = $value['filter']; // As Facet API isn't really suited for our native facet filter diff --git a/contrib/search_api_views/README.txt b/contrib/search_api_views/README.txt index e92f3ef..bae140f 100644 --- a/contrib/search_api_views/README.txt +++ b/contrib/search_api_views/README.txt @@ -54,8 +54,7 @@ linked to for the filter to have an effect. 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. +search that you specified for the display. 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. diff --git a/contrib/search_api_views/includes/display_facet_block.inc b/contrib/search_api_views/includes/display_facet_block.inc index c7b1e3a..39d256b 100644 --- a/contrib/search_api_views/includes/display_facet_block.inc +++ b/contrib/search_api_views/includes/display_facet_block.inc @@ -177,7 +177,6 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block { '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); diff --git a/contrib/search_api_views/includes/handler_argument_more_like_this.inc b/contrib/search_api_views/includes/handler_argument_more_like_this.inc index 15cc3ef..12b3598 100644 --- a/contrib/search_api_views/includes/handler_argument_more_like_this.inc +++ b/contrib/search_api_views/includes/handler_argument_more_like_this.inc @@ -60,8 +60,9 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg $server = $this->query->getIndex()->server(); if (!$server->supportsFeature('search_api_mlt')) { $class = search_api_get_service_info($server->class); - throw new SearchApiException(t('The search service "@class" does not offer "More like this" functionality.', - array('@class' => $class['name']))); + watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.', + array('@class' => $class['name']), WATCHDOG_ERROR); + $this->query->abort(); return; } $fields = $this->options['fields'] ? $this->options['fields'] : array(); diff --git a/contrib/search_api_views/includes/handler_field_vbo_operations.inc b/contrib/search_api_views/includes/handler_field_vbo_operations.inc new file mode 100644 index 0000000..e123ab5 --- /dev/null +++ b/contrib/search_api_views/includes/handler_field_vbo_operations.inc @@ -0,0 +1,21 @@ +definition['item_type']; + } + + /** + * Overridden to try to fish out the id. + */ + public function get_value($values, $field = NULL) { + // I'm not sure this is the best source for this but the name seemed consistent. + return $values->_entity_properties['search_api_item_id']; + } +} diff --git a/contrib/search_api_views/includes/query.inc b/contrib/search_api_views/includes/query.inc index c79807e..ca76819 100644 --- a/contrib/search_api_views/includes/query.inc +++ b/contrib/search_api_views/includes/query.inc @@ -51,6 +51,13 @@ class SearchApiViewsQuery extends views_plugin_query { protected $errors; /** + * Whether to abort the search instead of executing it. + * + * @var bool + */ + protected $abort = FALSE; + + /** * The names of all fields whose value is required by a handler. * * The format follows the same as Search API field identifiers (parent:child). @@ -124,6 +131,32 @@ class SearchApiViewsQuery extends views_plugin_query { } /** + * Add a field to the query table, possibly with an alias. This will + * automatically call ensure_table to make sure the required table + * exists, *unless* $table is unset. + * + * @param $table + * The table this field is attached to. If NULL, it is assumed this will + * be a formula; otherwise, ensure_table is used to make sure the + * table exists. + * @param $field + * The name of the field to add. This may be a real field or a formula. + * @param $alias + * The alias to create. If not specified, the alias will be $table_$field + * unless $table is NULL. When adding formulae, it is recommended that an + * alias be used. + * @param $params + * An array of parameters additional to the field that will control items + * such as aggregation functions and DISTINCT. + * + * @return $name + * The name that this field can be referred to as. Usually this is the alias. + */ + function add_field($table, $field, $alias = '', $params = array()) { + return $this->addField($field); + } + + /** * Defines the options used by this query plugin. * * Adds some access options. @@ -209,6 +242,7 @@ class SearchApiViewsQuery extends views_plugin_query { // Add a nested filter for each filter group, with its set conjunction. foreach ($this->where as $group_id => $group) { if (!empty($group['conditions']) || !empty($group['filters'])) { + $group += array('type' => 'AND'); // For filters without a group, we want to always add them directly to // the query. $filter = ($group_id === '') ? $this->query : $this->query->createFilter($group['type']); @@ -264,7 +298,7 @@ class SearchApiViewsQuery extends views_plugin_query { * $view->pager['current_page']. */ public function execute(&$view) { - if ($this->errors) { + if ($this->errors || $this->abort) { if (error_displayable()) { foreach ($this->errors as $msg) { drupal_set_message(check_plain($msg), 'error'); @@ -305,6 +339,16 @@ class SearchApiViewsQuery extends views_plugin_query { } /** + * Aborts this search query. + * + * Used by handlers to flag a fatal error which shouldn't be displayed but + * still lead to the view returning empty and the search not being executed. + */ + public function abort() { + $this->abort = TRUE; + } + + /** * Helper function for adding results to a view in the format expected by the * view. */ diff --git a/contrib/search_api_views/search_api_views.info b/contrib/search_api_views/search_api_views.info index 735ccfa..403d5d9 100644 --- a/contrib/search_api_views/search_api_views.info +++ b/contrib/search_api_views/search_api_views.info @@ -13,6 +13,7 @@ files[] = includes/handler_argument_fulltext.inc files[] = includes/handler_argument_more_like_this.inc files[] = includes/handler_argument_string.inc files[] = includes/handler_argument_taxonomy_term.inc +files[] = includes/handler_field_vbo_operations.inc files[] = includes/handler_filter.inc files[] = includes/handler_filter_boolean.inc files[] = includes/handler_filter_date.inc diff --git a/contrib/search_api_views/search_api_views.views.inc b/contrib/search_api_views/search_api_views.views.inc index da2936a..cfb753d 100644 --- a/contrib/search_api_views/search_api_views.views.inc +++ b/contrib/search_api_views/search_api_views.views.inc @@ -25,6 +25,20 @@ function search_api_views_views_data() { 'entity type' => $index->getEntityType(), 'skip entity load' => TRUE, ); + + if (module_exists('views_bulk_operations')) { + // Enable views bulk operations on entities. + $table['views_bulk_operations'] = array( + 'title' => t('Bulk operations'), + 'help' => t('Provide a new checkbox to select the row for bulk operations.'), + 'real field' => 'id', + 'field' => array( + 'handler' => 'SearchApiViewsHandlerFieldOperations', + 'item_type' => $index->item_type, + 'click sortable' => FALSE, + ), + ); + } } $wrapper = $index->entityWrapper(NULL, TRUE); @@ -179,7 +193,7 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter'; } - if ($inner_type == 'string') { + if ($inner_type == 'string' || $inner_type == 'uri') { $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentString'; } else { diff --git a/includes/datasource.inc b/includes/datasource.inc index cc460ba..ba0d2ba 100644 --- a/includes/datasource.inc +++ b/includes/datasource.inc @@ -12,6 +12,13 @@ * They are used for loading items, extracting item data, keeping track of the * item status, etc. * + * Modules providing implementations of this interface that use a different way + * (either different table or different method altogether) of keeping track of + * indexed/dirty items than SearchApiAbstractDataSourceController should be + * aware that indexes' numerical IDs can change due to feature reverts. It is + * therefore recommended to use search_api_index_update_datasource(), or similar + * code, in a hook_search_api_index_update() implementation. + * * All methods of the data source may throw exceptions of type * SearchApiDataSourceException if any exception or error state is encountered. */ diff --git a/includes/processor.inc b/includes/processor.inc index 09311a3..1774bf1 100644 --- a/includes/processor.inc +++ b/includes/processor.inc @@ -187,7 +187,7 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface public function configurationFormValidate(array $form, array &$values, array &$form_state) { $fields = array_filter($values['fields']); if ($fields) { - $fields = array_combine($fields, array_fill(0, count($fields), TRUE)); + $fields = array_fill_keys($fields, TRUE); } $values['fields'] = $fields; } diff --git a/includes/processor_highlight.inc b/includes/processor_highlight.inc new file mode 100644 index 0000000..f5d463d --- /dev/null +++ b/includes/processor_highlight.inc @@ -0,0 +1,386 @@ +options += array( + 'prefix' => '', + 'suffix' => '', + 'excerpt' => TRUE, + 'excerpt_length' => 256, + 'highlight' => 'always', + ); + + $form['prefix'] = array( + '#type' => 'textfield', + '#title' => t('Highlighting prefix'), + '#description' => t('Text/HTML that will be prepended to all occurrences of search keywords in highlighted text.'), + '#default_value' => $this->options['prefix'], + ); + $form['suffix'] = array( + '#type' => 'textfield', + '#title' => t('Highlighting suffix'), + '#description' => t('Text/HTML that will be appended to all occurrences of search keywords in highlighted text.'), + '#default_value' => $this->options['suffix'], + ); + $form['excerpt'] = array( + '#type' => 'checkbox', + '#title' => t('Create excerpt'), + '#description' => t('When enabled, an excerpt will be created for searches with keywords, containing all occurrences of keywords in a fulltext field.'), + '#default_value' => $this->options['excerpt'], + ); + $form['excerpt_length'] = array( + '#type' => 'textfield', + '#title' => t('Excerpt length'), + '#description' => t('The requested length of the excerpt, in characters.'), + '#default_value' => $this->options['excerpt_length'], + '#element_validate' => array('element_validate_integer_positive'), + '#states' => array( + 'visible' => array( + '#edit-processors-search-api-highlighting-settings-excerpt' => array( + 'checked' => TRUE, + ), + ), + ), + ); + $form['highlight'] = array( + '#type' => 'select', + '#title' => t('Highlight returned field data'), + '#description' => t('Select whether returned fields should be highlighted.'), + '#options' => array( + 'always' => t('Always'), + 'server' => t('If the server returns fields'), + 'never' => t('Never'), + ), + '#default_value' => $this->options['highlight'], + ); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function configurationFormValidate(array $form, array &$values, array &$form_state) { + // Overridden so $form['fields'] is not checked. + } + + /** + * {@inheritdoc} + */ + public function postprocessSearchResults(array &$response, SearchApiQuery $query) { + if (!$response['result count'] || !($keys = $this->getKeywords($query))) { + return; + } + + foreach ($response['results'] as $id => &$result) { + if ($this->options['excerpt']) { + $text = array(); + $fields = $this->getFulltextFields($response['results'], $id); + foreach ($fields as $data) { + if (is_array($data)) { + $text = array_merge($text, $data); + } + else { + $text[] = $data; + } + } + $result['excerpt'] = $this->createExcerpt(implode("\n\n", $text), $keys); + } + if ($this->options['highlight'] != 'never') { + $fields = $this->getFulltextFields($response['results'], $id, $this->options['highlight'] == 'always'); + foreach ($fields as $field => $data) { + if (is_array($data)) { + foreach ($data as $i => $text) { + $result['fields'][$field][$i] = $this->highlightField($text, $keys); + } + } + else { + $result['fields'][$field] = $this->highlightField($data, $keys); + } + } + } + } + } + + /** + * Retrieves the fulltext data of a result. + * + * @param array $result + * All results returned in the search. + * @param int|string $i + * The index in the results array of the result whose data should be + * returned. + * @param bool $load + * TRUE if the item should be loaded if necessary, FALSE if only fields + * already returned in the results should be used. + * + * @return array + * An array containing fulltext field names mapped to the text data + * contained in them for the given result. + */ + protected function getFulltextFields(array &$results, $i, $load = TRUE) { + $data = array(); + // Act as if $load is TRUE if we have a loaded item. + $load |= !empty($result['entity']); + + $result = &$results[$i]; + $result += array('fields' => array()); + $fulltext_fields = $this->index->getFulltextFields(); + // We only need detailed fields data if $load is TRUE. + $fields = $load ? $this->index->getFields() : array(); + $needs_extraction = array(); + foreach ($fulltext_fields as $field) { + if (array_key_exists($field, $result['fields'])) { + $data[$field] = $result['fields'][$field]; + } + elseif ($load) { + $needs_extraction[$field] = $fields[$field]; + } + } + + if (!$needs_extraction) { + return $data; + } + + if (empty($result['entity'])) { + $items = $this->index->loadItems(array_keys($results)); + foreach ($items as $id => $item) { + $results[$id]['entity'] = $item; + } + } + // If we still don't have a loaded item, we should stop trying. + if (empty($result['entity'])) { + return $data; + } + $wrapper = $this->index->entityWrapper($result['entity'], FALSE); + $extracted = search_api_extract_fields($wrapper, $needs_extraction); + + foreach ($extracted as $field => $info) { + if (isset($info['value'])) { + $data[$field] = $info['value']; + } + } + + return $data; + } + + /** + * Extracts the positive keywords used in a search query. + * + * @param SearchApiQuery $query + * The query from which to extract the keywords. + * + * @return array + * An array of all unique positive keywords used in the query. + */ + protected function getKeywords(SearchApiQuery $query) { + $keys = $query->getKeys(); + if (!$keys) { + return array(); + } + if (is_array($keys)) { + return $this->flattenKeysArray($keys); + } + + $keywords = preg_split('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . ']+/iu', $keys); + // Assure there are no duplicates. (This is actually faster than + // array_unique() by a factor of 3 to 4.) + $keywords = drupal_map_assoc(array_filter($keywords)); + // Remove quotes from keywords. + foreach ($keywords as $key) { + $keywords[$key] = trim($key, "'\""); + } + return drupal_map_assoc(array_filter($keywords)); + } + + /** + * Extracts the positive keywords from a keys array. + * + * @param array $keys + * A search keys array, as specified by SearchApiQueryInterface::getKeys(). + * + * @return array + * An array of all unique positive keywords contained in the keys. + */ + protected function flattenKeysArray(array $keys) { + if (!empty($keys['#negation'])) { + return array(); + } + + $keywords = array(); + foreach ($keys as $i => $key) { + if (!element_child($i)) { + continue; + } + if (is_array($key)) { + $keywords += $this->flattenKeysArray($key); + } + else { + $keywords[$key] = $key; + } + } + + return $keywords; + } + + /** + * Returns snippets from a piece of text, with certain keywords highlighted. + * + * Largely copied from search_excerpt(). + * + * @param string $text + * The text to extract fragments from. + * @param array $keys + * Search keywords entered by the user. + * + * @return string + * A string containing HTML for the excerpt. + */ + protected function createExcerpt($text, array $keys) { + // Prepare text by stripping HTML tags and decoding HTML entities. + $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text)); + $text = ' ' . decode_entities($text); + + // Extract fragments around keywords. + // First we collect ranges of text around each keyword, starting/ending + // at spaces, trying to get to the requested length. + // If the sum of all fragments is too short, we look for second occurrences. + $ranges = array(); + $included = array(); + $foundkeys = array(); + $length = 0; + $workkeys = $keys; + while ($length < $this->options['excerpt_length'] && count($workkeys)) { + foreach ($workkeys as $k => $key) { + if ($length >= $this->options['excerpt_length']) { + break; + } + // Remember occurrence of key so we can skip over it if more occurrences + // are desired. + if (!isset($included[$key])) { + $included[$key] = 0; + } + // Locate a keyword (position $p, always >0 because $text starts with a + // space). + $p = 0; + if (preg_match('/' . self::$boundary . $key . self::$boundary . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) { + $p = $match[0][1]; + } + // Now locate a space in front (position $q) and behind it (position $s), + // leaving about 60 characters extra before and after for context. + // Note that a space was added to the front and end of $text above. + if ($p) { + if (($q = strpos(' ' . $text, ' ', max(0, $p - 61))) !== FALSE) { + $end = substr($text . ' ', $p, 80); + if (($s = strrpos($end, ' ')) !== FALSE) { + // Account for the added spaces. + $q = max($q - 1, 0); + $s = min($s, strlen($end) - 1); + $ranges[$q] = $p + $s; + $length += $p + $s - $q; + $included[$key] = $p + 1; + } + else { + unset($workkeys[$k]); + } + } + else { + unset($workkeys[$k]); + } + } + else { + unset($workkeys[$k]); + } + } + } + + if (count($ranges) == 0) { + // We didn't find any keyword matches, so just return NULL. + return NULL; + } + + // Sort the text ranges by starting position. + ksort($ranges); + + // Now we collapse overlapping text ranges into one. The sorting makes it O(n). + $newranges = array(); + foreach ($ranges as $from2 => $to2) { + if (!isset($from1)) { + $from1 = $from2; + $to1 = $to2; + continue; + } + if ($from2 <= $to1) { + $to1 = max($to1, $to2); + } + else { + $newranges[$from1] = $to1; + $from1 = $from2; + $to1 = $to2; + } + } + $newranges[$from1] = $to1; + + // Fetch text + $out = array(); + foreach ($newranges as $from => $to) { + $out[] = substr($text, $from, $to - $from); + } + + // Let translators have the ... separator text as one chunk. + $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...')); + + $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2]; + $text = check_plain($text); + + return $this->highlightField($text, $keys); + } + + /** + * Marks occurrences of the search keywords in a text field. + * + * @param string $text + * The text of the field. + * @param array $keys + * Search keywords entered by the user. + * + * @return string + * The field's text with all occurrences of search keywords highlighted. + */ + protected function highlightField($text, array $keys) { + $replace = $this->options['prefix'] . '\0' . $this->options['suffix']; + $text = preg_replace('/' . self::$boundary . '(' . implode('|', $keys) . ')' . self::$boundary . '/iu', $replace, ' ' . $text); + return substr($text, 1); + } + +} diff --git a/search_api.info b/search_api.info index bcb88cd..c03de9d 100644 --- a/search_api.info +++ b/search_api.info @@ -21,6 +21,7 @@ files[] = includes/datasource_external.inc files[] = includes/exception.inc files[] = includes/index_entity.inc files[] = includes/processor.inc +files[] = includes/processor_highlight.inc files[] = includes/processor_html_filter.inc files[] = includes/processor_ignore_case.inc files[] = includes/processor_stopwords.inc diff --git a/search_api.module b/search_api.module index 5209368..0c2e024 100644 --- a/search_api.module +++ b/search_api.module @@ -455,6 +455,15 @@ function search_api_entity_property_info() { * Calls the postCreate() method for the server. */ function search_api_search_api_server_insert(SearchApiServer $server) { + // Check whether this is actually part of a revert. + $reverts = &drupal_static('search_api_search_api_server_delete', array()); + if (isset($reverts[$server->machine_name])) { + $server->original = $reverts[$server->machine_name]; + unset($reverts[$server->machine_name]); + search_api_search_api_server_update($server); + unset($server->original); + return; + } $server->postCreate(); } @@ -470,7 +479,7 @@ function search_api_search_api_server_update(SearchApiServer $server) { $index->reindex(); } } - if ($server->enabled != $server->original->enabled) { + if (!empty($server->original) && $server->enabled != $server->original->enabled) { if ($server->enabled) { // Were there any changes in the server's indexes while it was disabled? $tasks = variable_get('search_api_tasks', array()); @@ -486,7 +495,8 @@ function search_api_search_api_server_update(SearchApiServer $server) { $server->deleteItems('all', $index); break; case 'clear all': - // Would normally be used with a fake index ID of "", since it doesn't matter. + // Would normally be used with a fake index ID of "", since it + // doesn't matter. $server->deleteItems('all'); break; case 'fields': @@ -526,14 +536,16 @@ function search_api_search_api_server_update(SearchApiServer $server) { * Calls the preDelete() method for the server. */ function search_api_search_api_server_delete(SearchApiServer $server) { - $server->preDelete(); - - // Only react on real delete, not revert. - if (!$server->hasStatus(ENTITY_IN_CODE)) { - foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) { - $index->update(array('server' => NULL, 'enabled' => FALSE)); - } + if ($server->hasStatus(ENTITY_IN_CODE)) { + $reverts = &drupal_static(__FUNCTION__, array()); + $reverts[$server->machine_name] = $server; + return; + } + + $server->preDelete(); + foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) { + $index->update(array('server' => NULL, 'enabled' => FALSE)); } $tasks = variable_get('search_api_tasks', array()); @@ -548,6 +560,16 @@ function search_api_search_api_server_delete(SearchApiServer $server) { * the index is enabled). */ function search_api_search_api_index_insert(SearchApiIndex $index) { + // Check whether this is actually part of a revert. + $reverts = &drupal_static('search_api_search_api_index_delete', array()); + if (isset($reverts[$index->machine_name])) { + $index->original = $reverts[$index->machine_name]; + unset($reverts[$index->machine_name]); + search_api_search_api_index_update($index); + unset($index->original); + return; + } + $index->postCreate(); } @@ -555,6 +577,9 @@ function search_api_search_api_index_insert(SearchApiIndex $index) { * Implements hook_search_api_index_update(). */ function search_api_search_api_index_update(SearchApiIndex $index) { + // Call the datasource update function with the table this module provides. + search_api_index_update_datasource($index, 'search_api_item'); + // If the server was changed, we have to call the appropriate service class // hook methods. if ($index->server != $index->original->server) { @@ -652,6 +677,12 @@ function search_api_search_api_index_update(SearchApiIndex $index) { * Removes all data for indexes not available any more. */ function search_api_search_api_index_delete(SearchApiIndex $index) { + // Only react on real delete, not revert. + if ($index->hasStatus(ENTITY_IN_CODE)) { + $reverts = &drupal_static(__FUNCTION__, array()); + $reverts[$index->machine_name] = $index; + return; + } cache_clear_all($index->getCacheId(''), 'cache', TRUE); $index->postDelete(); } @@ -890,7 +921,7 @@ function search_api_search_api_alter_callback_info() { function search_api_search_api_processor_info() { $processors['search_api_case_ignore'] = array( 'name' => t('Ignore case'), - 'description' => t('This processor will make searches case-insensitive for all fulltext fields (and, optionally, also for filters on string fields).'), + 'description' => t('This processor will make searches case-insensitive for fulltext or string fields.'), 'class' => 'SearchApiIgnoreCase', ); $processors['search_api_html_filter'] = array( @@ -924,6 +955,12 @@ function search_api_search_api_processor_info() { 'class' => 'SearchApiStopWords', 'weight' => 30, ); + $processors['search_api_highlighting'] = array( + 'name' => t('Highlighting'), + 'description' => t('Adds highlighting for search results.'), + 'class' => 'SearchApiHighlight', + 'weight' => 35, + ); return $processors; } @@ -1205,7 +1242,21 @@ function search_api_index_specific_items(SearchApiIndex $index, array $ids) { // Clone items because data alterations may alter them. $cloned_items = array(); foreach ($items as $id => $item) { - $cloned_items[$id] = clone $item; + if (is_object($item)) { + $cloned_items[$id] = clone $item; + } + else { + // Normally, items that can't be loaded shouldn't be returned by + // entity_load (and other loadItems() implementations). Therefore, this is + // an extremely rare case, which seems to happen during installation for + // some specific setups. + $type = search_api_get_item_type_info($index->item_type); + $type = $type ? $type['name'] : $index->item_type; + watchdog('search_api', + "Error during indexing: invalid item loaded for @type with ID @id.", + array('@id' => $id, '@type' => $type), + WATCHDOG_WARNING); + } } $indexed = $items ? $index->index($cloned_items) : array(); if ($indexed) { @@ -1723,6 +1774,38 @@ function search_api_extract_inner_type($type) { } /** + * Helper function for reacting to index updates with regards to the datasource. + * + * When an overridden index is reverted, its numerical ID will sometimes change. + * Since the default datasource implementation uses that for referencing + * indexes, the index ID in the items table must be updated accordingly. This is + * implemented in this function. + * + * Modules implementing other datasource controllers, that use a table other + * than {search_api_item}, can use this function, too. It should be called + * uncoditionally in a hook_search_api_index_update() implementation. If this + * function isn't used, similar code should be added there. + * + * However, note that this is only necessary (and this function should only be + * called) if the indexes are referenced by numerical ID in the items table. + * + * @param SearchApiIndex $index + * The index that was changed. + * @param string $table + * The table containing items information, analogous to {search_api_item}. + * @param string $column + * The column in $table that holds the index's numerical ID. + */ +function search_api_index_update_datasource(SearchApiIndex $index, $table, $column = 'index_id') { + if ($index->id != $index->original->id) { + db_update($table) + ->fields(array($column => $index->id)) + ->condition($column, $index->original->id) + ->execute(); + } +} + +/** * Utility function for extracting specific fields from an EntityMetadataWrapper * object. *