123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- <?php
- /**
- * @file
- * Contains the SearchApiHighlight class.
- */
- /**
- * Processor for highlighting search results.
- */
- class SearchApiHighlight extends SearchApiAbstractProcessor {
- /**
- * PREG regular expression for a word boundary.
- *
- * We highlight around non-indexable or CJK characters.
- *
- * @var string
- */
- protected static $boundary;
- /**
- * PREG regular expression for splitting words.
- *
- * We highlight around non-indexable or CJK characters.
- *
- * @var string
- */
- protected static $split;
- /**
- * {@inheritdoc}
- */
- public function __construct(SearchApiIndex $index, array $options = array()) {
- parent::__construct($index, $options);
- $cjk = '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
- '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
- '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
- '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
- '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
- self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']))';
- self::$split = '/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']+/iu';
- }
- /**
- * {@inheritdoc}
- */
- public function configurationForm() {
- $this->options += array(
- 'prefix' => '<strong>',
- 'suffix' => '</strong>',
- '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(self::$split, $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);
- }
- }
|