You are here

apachesolr_autocomplete.module in Apache Solr Autocomplete 7.2

Same filename and directory in other branches
  1. 6 apachesolr_autocomplete.module
  2. 7 apachesolr_autocomplete.module

Alters search forms to suggest terms using Apache Solr using AJAX.

File

apachesolr_autocomplete.module
View source
<?php

/**
 * @file
 *   Alters search forms to suggest terms using Apache Solr using AJAX.
 */

/**
 * Implementation of hook_init().
 */
function apachesolr_autocomplete_init() {
  drupal_add_js(drupal_get_path('module', 'apachesolr_autocomplete') . '/apachesolr_autocomplete.js');

  // TODO: Use #attached in forms instead of including JS in hook_init()?

  #drupal_add_css( drupal_get_path('module', 'apachesolr_autocomplete') . '/apachesolr_autocomplete.css'); // TODO: Remove css?
  drupal_add_library('system', 'ui.autocomplete');
}

// TODO: DEBUG.. remove apachesolr_autocomplete_form_alter function.
function apachesolr_autocomplete_form_alter(&$form, $form_state, $form_id) {
  dsm("apachesolr_autocomplete_form_alter(): form_id = {$form_id}");
}

/**
 * Implementation of hook_form_FORM_ID_alter().
 */
function apachesolr_autocomplete_form_search_form_alter(&$form, $form_state) {
  if ($form['module']['#value'] == 'apachesolr_search' || $form['module']['#value'] == 'apachesolr_multisitesearch') {
    $element =& $form['basic']['keys'];

    #$form['#attached']['js'][] = drupal_get_path('module', 'apachesolr_autocomplete') . '/apachesolr_autocomplete.js';
    apachesolr_autocomplete_do_alter($element, $form, 'apachesolr_search_page:core_search');
  }
}

/**
 * Implementation of hook_form_FORM_ID_alter().
 */
function apachesolr_autocomplete_form_search_block_form_alter(&$form, $form_state) {
  $element =& $form['search_block_form'];
  apachesolr_autocomplete_do_alter($element, $form, 'apachesolr_search_page:core_search');
}

/**
 * Implementation of hook_form_FORM_ID_alter().
 */
function apachesolr_autocomplete_form_apachesolr_search_custom_page_search_form_alter(&$form, $form_state) {
  $element =& $form['basic']['keys'];
  $context_id = 'apachesolr_search_page:' . $form['#search_page']['page_id'];
  apachesolr_autocomplete_do_alter($element, $form, $context_id);
}

/**
 * Helper function to do the actual altering of search forms.
 *
 * @param $element
 *   The element to alter. Should be passed by reference so that original form
 *   element will be altered.
 *   E.g.: apachesolr_autocomplete_do_alter(&$form['xyz'])
 * @param $form
 *   The form being altered.
 * @param $context_id
 *   A string identifying the form. For example: apachesolr_search_page:core_search
 */
function apachesolr_autocomplete_do_alter(&$element, $form, $context_id) {
  drupal_set_message("Altering form for context {$context_id}...");

  // TODO: DEBUG
  // The unique element ID for this form's keyword search element.
  $autocomplete_element_id = $form['#id'];

  // Create elements if they do not exist.
  if (!isset($element['#attributes'])) {
    $element['#attributes'] = array();
  }
  if (!isset($element['#attributes']['class'])) {
    $element['#attributes']['class'] = array();
  }
  array_push($element['#attributes']['class'], 'apachesolr-autocomplete');

  // Specify path to autocomplete handler.
  $autocomplete_path = 'apachesolr_autocomplete_callback/' . $context_id;

  // Add data-apachesolr-autocomplete attribute to element.
  $element['#attributes']['data-apachesolr-autocomplete-id'] = array(
    $autocomplete_element_id,
  );

  // Build a settings array.
  $settings = array(
    'id' => $autocomplete_element_id,
    // helps identify the element on jQuery
    'path' => url($autocomplete_path),
  );

  // Add our JS settings to the current page.
  drupal_add_js(array(
    'apachesolr_autocomplete' => array(
      $autocomplete_element_id => $settings,
    ),
  ), 'setting');
}

/**
 * Implementation of hook_menu().
 */
function apachesolr_autocomplete_menu() {
  $items = array();

  // Pattern is: apachesolr_autocomplete_callback/[context_id]
  $items['apachesolr_autocomplete_callback/%'] = array(
    'page callback' => 'apachesolr_autocomplete_callback',
    'page arguments' => array(
      1,
    ),
    'access callback' => 'user_access',
    'access arguments' => array(
      'search content',
    ),
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * Returns all the available engines to handle autocomplete.
 *
 * TODO: This should call a hook, whose implementations should return the info.
 * @return array
 */
function apachesolr_autocomplete_engines() {
  $suggestion_engines = array(
    'word_completion' => array(
      'enabled' => TRUE,
    ),
    'additional_term' => array(
      'enabled' => TRUE,
      'spellcheck_suggest' => TRUE,
      'suggest_keywords' => TRUE,
    ),
  );
  return $suggestion_engines;
}

/**
 * Return the default config for the current context.
 * @param $context_id
 *   A string identifying the form. For example: apachesolr_search_page:core_search
 * @return array
 */
function apachesolr_autocomplete_get_default_context($context_id) {
  $suggestions_to_return = 5;

  // For search pages.
  if (strpos($context_id, 'apachesolr_search_page:') === 0) {
    $search_page_id = substr($context_id, 23);
  }
  else {
    $search_page_id = 'core_search';
  }
  $search_page = apachesolr_search_page_load($search_page_id);
  if (!$search_page) {
    return FALSE;
  }

  // Include any settings provided by apachesolr_search, if defined.
  $params = apachesolr_search_conditions_default($search_page);

  // Set various parameters we'll use in autocomplete.
  $params['facet'] = 'true';
  $params['facet.field'] = array(
    'spell',
  );

  // We ask for $suggestions_to_return * 5 facets, because we want
  // not-too-frequent terms (will be filtered below). 5 is just my best guess.
  $params['facet.limit'] = $suggestions_to_return * 5;
  $params['facet.mincount'] = 1;
  $params['start'] = 0;
  $params['rows'] = 0;
  $solr = apachesolr_get_solr($search_page['env_id']);

  // Return the context.
  $context = array(
    'context_id' => $context_id,
    'search_page' => $search_page,
    'solr' => $solr,
    'apachesolr_params' => $params,
    'engines' => apachesolr_autocomplete_engines(),
    'suggestions_to_return' => 5,
    'spellcheck_suggest' => TRUE,
    'cache_max_age' => 60,
  );
  return $context;
}

/**
 * Callback for url apachesolr_autocomplete/autocomplete.
 * @param $context_id
 *   A string identifying the form. For example: apachesolr_search_page:core_search
 */
function apachesolr_autocomplete_callback($context_id) {

  // Get user entry
  $keys = isset($_GET['term']) ? $_GET['term'] : '';
  if (!$keys) {

    // Exit quickly.
    drupal_json_output(array());
    exit;
  }

  // TODO: Get more user entry, like state of 'retain current filters' and current filters, etc.
  // Get the configuration for the context_id
  $context = apachesolr_autocomplete_get_default_context($context_id);

  // Exit quickly if no context exists.
  if (!$context) {
    drupal_json_output(array());
    exit;
  }

  // TODO: Override the context with any UI-based configuration.
  // TODO: Override the context with user entry, like state of 'retain current filters' and current filters, etc. See .autocomplete(... source:...) on on apachesolr_autocomplete.js
  // Allow other modules to alter the context.
  drupal_alter('apachesolr_autocomplete_context', $context);

  // Run suggestion engines.
  $suggestions = apachesolr_autocomplete_invoke($keys, $context);

  // Output caching headers

  #header("Cache-Control: public, max-age=" . $context['cache_max_age']); // TODO: DEBUG.
  drupal_json_output(array_values($suggestions));

  #print_r($context); // TODO: DEBUG.

  #print_r($suggestions); // TODO: DEBUG.
  exit;
}

/**
 * Given a set of keys, invoke all the available autocomplete engines and build an array of suggestions.
 * @param $keys
 *   String to autocomplete on.
 * @param $context
 *   Configuration to build the autocomplete.
 * @return array
 *   List of suggestions.
 */
function apachesolr_autocomplete_invoke($keys, $context) {
  $suggestions = array();

  // Process each enabled engine.
  foreach ($context['engines'] as $engine_id => $engine) {
    if ($engine['enabled'] === TRUE) {
      $callback = 'apachesolr_autocomplete_suggest_' . $engine_id;
      $results = $callback($keys, $context);
      drupal_alter('apachesolr_autocomplete_suggestions', $results, $engine_id);
      if ($results) {
        $results = apachesolr_autocomplete_fix_indexes($results);
        $suggestions = array_merge($suggestions, $results);
      }
    }
  }
  return $suggestions;
}

/**
 * Convert the keys in an array from '*foo' to 'foo'
 * @param $results
 *   An array to convert.
 * @return array
 *   The converted array.
 */
function apachesolr_autocomplete_fix_indexes($results) {
  $new = array();
  foreach ($results as $key => $value) {
    $new_key = substr($key, 1);
    $new[$new_key] = $value;
    $new[$new_key]['id'] = $new_key;
  }
  return $new;
}

/**
 * Themes each returned suggestion.
 * TODO: Move theming functions into JS.
 */
function theme_apachesolr_autocomplete_highlight($variables) {
  static $first = TRUE;
  $html = '';
  $html .= '<div class="apachesolr_autocomplete suggestion">';
  $html .= '<strong>' . drupal_substr($variables['suggestion'], 0, strlen($variables['keys'])) . '</strong>' . drupal_substr($variables['suggestion'], strlen($variables['keys']));
  $html .= '</div>';
  if ($variables['count'] && $variables['show_counts']) {
    if ($first) {
      $html .= "<div class='apachesolr_autocomplete message' style='float:right'>";
      $html .= t('!count results', array(
        '!count' => $variables['count'],
      ));
      $html .= "</div><br style='clear:both'>";
      $first = false;
    }
    else {
      $html .= "<div class='apachesolr_autocomplete message count'>" . $variables['count'] . "</div><br style='clear:both'>";
    }
  }
  return $html;
}

/**
 * Themes the spellchecker's suggestion.
 * TODO: Move theming functions into JS.
 */
function theme_apachesolr_autocomplete_spellcheck($variables) {
  return '<span class="apachesolr_autocomplete message">' . t('Did you mean') . ':</span> ' . $variables['suggestion'];
}

/**
 * Helper function that suggests ways to complete partial words.
 *
 * For example, if $keys = "lea", this might return suggestions like:
 *    lean, learn, lease.
 * The suggested terms are returned in order of frequency (most frequent first).
 *
 */
function apachesolr_autocomplete_suggest_word_completion($keys, $context) {

  /**
   * Split $keys into two:
   *  $first_part will contain all complete words (delimited by spaces). Can be empty.
   *  $last_part is the (assumed incomplete) last word. If this is empty, don't suggest.
   * Example:
   *  $keys = "learning dis" : $first_part = "learning", $last_part = "dis"
   */
  preg_match('/^(:?(.* |))([^ ]+)$/', $keys, $matches);
  $first_part = @$matches[2];

  // Make sure $last_part contains meaningful characters
  $last_part = preg_replace('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/u', '', @$matches[3]);
  if ($last_part == '') {
    return array();
  }

  // Ask Solr to return facets that begin with $last_part; these will be the suggestions.
  $params = $context['apachesolr_params'];
  $params['facet.prefix'] = $last_part;

  // Get array of themed suggestions.
  $result = apachesolr_autocomplete_suggest($context['solr'], $first_part, $params, $context['suggestions_to_return']);
  if (!$result) {
    return array();
  }

  // Concatenate the original terms with the returned suggestion.
  $tmp = array();
  foreach ($result['suggestions'] as $key => $value) {
    $key = substr($key, 1);
    $value['value'] = $first_part . $value['value'];
    $tmp["*" . $value['value']] = $value;
  }
  return $tmp;
}

/**
 * Helper function that suggests additional terms to search for.
 *
 * For example, if $keys = "learn", this might return suggestions like:
 *    learn student, learn school, learn mathematics.
 * The suggested terms are returned in order of frequency (most frequent first).
 */
function apachesolr_autocomplete_suggest_additional_term($keys, $context) {
  $keys = trim($keys);
  $keys = check_plain($keys);
  if ($keys == '') {
    return array();
  }

  // Return no suggestions when $keys consists of only word delimiters
  if (drupal_strlen(preg_replace('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/u', '', $keys)) < 1) {
    return array();
  }

  // Initialize arrays
  $suggestions = array();

  // Get array of suggestions.
  $result = apachesolr_autocomplete_suggest($context['solr'], $keys, $context['apachesolr_params'], $context['suggestions_to_return']);
  if ($result && $context['engines']['additional_term']['suggest_keywords']) {
    if (isset($result['suggestions']) && sizeof($result['suggestions'])) {

      // Concatenate the original terms with the returned suggestion.
      $tmp = array();
      foreach ($result['suggestions'] as $key => $value) {
        $key = substr($key, 1);
        $value['value'] = $keys . ' ' . $value['value'];
        $tmp["*{$keys} {$key}"] = $value;
      }
      $suggestions = array_merge($suggestions, $tmp);
    }
  }

  // Suggest using the spellchecker.
  if ($context['engines']['additional_term']['spellcheck_suggest']) {
    if (isset($result['response']->spellcheck) && isset($result['response']->spellcheck->suggestions)) {
      $spellcheck_suggestions = get_object_vars($result['response']->spellcheck->suggestions);
      $replacements = array();
      foreach ($spellcheck_suggestions as $word => $value) {
        $replacements[$word] = $value->suggestion[0];
      }
      if (count($replacements)) {
        $new_keywords = strtr($keys, $replacements);
        if ($new_keywords != $keys) {

          // Place spellchecker suggestion before others
          $suggestion = array(
            'value' => $new_keywords,
            'theme' => 'spellchecker',
          );
          $suggestions = array_merge(array(
            '*' . $new_keywords => $suggestion,
          ), $suggestions);
        }
      }
    }
  }
  return $suggestions;
}
function apachesolr_autocomplete_suggest(DrupalApacheSolrServiceInterface $solr, $term, $params, $suggestions_to_return = 5) {
  $matches = array();
  $suggestions = array();
  $term = trim($term);

  // We need the keys array to make sure we don't suggest words that are already
  // in the search terms.
  $keys_array = explode(' ', $term);
  $keys_array = array_filter($keys_array);
  $query = apachesolr_drupal_query("apachesolr", $params, '', '', $solr);

  // Add spellcheck parameters.
  apachesolr_search_add_spellcheck_params($query);

  // Add fields to search in (qf)
  apachesolr_search_add_boost_params($query);

  // Allow modules to modify the query object.
  drupal_alter('apachesolr_autocomplete_query', $query);
  if (!$query) {
    return array();
  }

  // Query Solr
  $response = $query
    ->search($term);

  // Loop through requested facet.fields and get returned suggestions.
  foreach ($params['facet.field'] as $field) {

    #echo "Returned facets:\n"; // TODO: DEBUG

    #print_r((array)$response->facet_counts->facet_fields->{$field}); // TODO: DEBUG
    foreach ($response->facet_counts->facet_fields->{$field} as $terms => $count) {
      $terms = preg_replace('/[_-]+/', ' ', $terms);
      foreach (explode(' ', $terms) as $term) {

        // Trim the $term.
        $term = trim(preg_replace('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/u', '', $term));
        if ($term) {
          if (isset($matches[$term])) {
            $matches[$term] += $count;
          }
          else {
            $matches[$term] = $count;
          }
        }
      }
    }
  }
  if (sizeof($matches) > 0) {

    // Eliminate suggestions that are stopwords or are already in the query.
    $matches_clone = $matches;
    $stopwords = apachesolr_autocomplete_get_stopwords($solr);
    foreach ($matches_clone as $term => $count) {
      if (strlen($term) > 3 && !in_array($term, $stopwords) && !array_search($term, $keys_array)) {

        // Longer strings get higher ratings.

        #$matches_clone[$term] += strlen($term);
      }
      else {
        unset($matches_clone[$term]);
        unset($matches[$term]);
      }
    }

    // Don't suggest terms that are too frequent (in >90% of results).
    $max_occurrence = $response->response->numFound * 0.9;
    foreach ($matches_clone as $match => $count) {
      if ($count > $max_occurrence) {
        unset($matches_clone[$match]);
      }
    }

    // The $count in this array is actually a score. We want the highest ones first.
    arsort($matches_clone);

    // Shorten the array to the right ones.
    $matches_clone = array_slice($matches_clone, 0, $suggestions_to_return, TRUE);

    // Add current search as suggestion if results > 0
    if ($response->response->numFound > 0 && $term != '') {

      // Add * to array element key to force into a string, else PHP will
      // renumber keys that look like numbers on the returned array.
      $suggestions['*' . $term] = array(
        'value' => $term,
        'count' => $response->response->numFound,
      );
    }

    // Build suggestions using returned facets
    foreach ($matches_clone as $match => $count) {
      if ($term != $match) {
        $suggestion = trim($match);

        // On cases where there are more than 3 keywords, omit displaying
        //  the count because of the mm settings in solrconfig.xml
        if (substr_count($suggestion, ' ') >= 2) {
          $count = FALSE;
        }
        if ($suggestion != '') {

          // Add * to array element key to force into a string, else PHP will
          // renumber keys that look like numbers on the returned array.
          $suggestions['*' . $suggestion] = array(
            'value' => $suggestion,
            'count' => $count,
          );
        }
      }
    }
  }
  return array(
    'suggestions' => $suggestions,
    'response' => &$response,
  );
}

/**
 * Gets the current stopwords list configured in Solr.
 */
function apachesolr_autocomplete_get_stopwords(DrupalApacheSolrServiceInterface $solr) {
  static $words = array(), $cached_flag = false;

  // Try static cache.
  if ($cached_flag) {
    return $words;
  }

  // Try Drupal cache.
  $cid = 'apachesolr_autocomplete_stopwords';
  $cached = cache_get($cid);
  if ($cached && $cached->expire > REQUEST_TIME) {
    return $cached->data;
  }
  try {
    $response = $solr
      ->makeServletRequest('admin/file', array(
      'file' => 'stopwords.txt',
    ));
  } catch (Exception $e) {
    $cached_flag = TRUE;
    return $words;
  }
  foreach (explode("\n", $response->data) as $line) {
    if (drupal_substr($line, 0, 1) == "#") {
      continue;
    }
    if ($word = trim($line)) {
      $words[] = $word;
    }
  }

  // Cache in Drupal cache for 10 minutes.
  cache_set($cid, $words, 'cache', REQUEST_TIME + 600);
  $cached_flag = TRUE;
  return $words;
}

/**
 * Alter the apachesolr.module "advanced settings" form.
 * TODO: Move this off onto each engine.
 */
function apachesolr_autocomplete_form_apachesolr_settings_alter(&$form, $form_state) {
  $form['advanced']['apachesolr_autocomplete_suggest_keywords'] = array(
    '#type' => 'checkbox',
    '#title' => t('Enable additional keyword suggestions on the autocomplete widget'),
    '#description' => t('Suggest words to add to the currently typed-in words. E.g.: typing "blue" might suggest "blue bike" or "blue shirt".'),
  );
  $form['advanced']['apachesolr_autocomplete_suggest_spellcheck'] = array(
    '#type' => 'checkbox',
    '#title' => t('Enable spellchecker suggestions on the autocomplete widget'),
    '#description' => t('Suggest corrections to the currently typed-in words. E.g.: typing "rec" or "redd" might suggest "red".'),
  );
  $form['advanced']['apachesolr_autocomplete_counts'] = array(
    '#type' => 'checkbox',
    '#title' => t('Enable counts in autocomplete widget suggestions'),
    '#description' => t('WARNING: Counts shown alongside suggestions might be lower than the actual result count due to stemming and minimum match (mm) settings in solrconfig.xml.'),
  );
}

Functions

Namesort descending Description
apachesolr_autocomplete_callback Callback for url apachesolr_autocomplete/autocomplete.
apachesolr_autocomplete_do_alter Helper function to do the actual altering of search forms.
apachesolr_autocomplete_engines Returns all the available engines to handle autocomplete.
apachesolr_autocomplete_fix_indexes Convert the keys in an array from '*foo' to 'foo'
apachesolr_autocomplete_form_alter
apachesolr_autocomplete_form_apachesolr_search_custom_page_search_form_alter Implementation of hook_form_FORM_ID_alter().
apachesolr_autocomplete_form_apachesolr_settings_alter Alter the apachesolr.module "advanced settings" form. TODO: Move this off onto each engine.
apachesolr_autocomplete_form_search_block_form_alter Implementation of hook_form_FORM_ID_alter().
apachesolr_autocomplete_form_search_form_alter Implementation of hook_form_FORM_ID_alter().
apachesolr_autocomplete_get_default_context Return the default config for the current context.
apachesolr_autocomplete_get_stopwords Gets the current stopwords list configured in Solr.
apachesolr_autocomplete_init Implementation of hook_init().
apachesolr_autocomplete_invoke Given a set of keys, invoke all the available autocomplete engines and build an array of suggestions.
apachesolr_autocomplete_menu Implementation of hook_menu().
apachesolr_autocomplete_suggest
apachesolr_autocomplete_suggest_additional_term Helper function that suggests additional terms to search for.
apachesolr_autocomplete_suggest_word_completion Helper function that suggests ways to complete partial words.
theme_apachesolr_autocomplete_highlight Themes each returned suggestion. TODO: Move theming functions into JS.
theme_apachesolr_autocomplete_spellcheck Themes the spellchecker's suggestion. TODO: Move theming functions into JS.