You are here

similar.module in Similar Entries 7

Same filename and directory in other branches
  1. 5 similar.module
  2. 6.2 similar.module
  3. 6 similar.module
  4. 7.2 similar.module

Module that shows a block listing similar entries. NOTE: Uses MySQL's FULLTEXT indexing for MyISAM tables.

Caching feature sponsored by http://xomba.com/

@author David Kent Norman http://deekayen.net/ @author Arnab Nandi http://arnab.org/ @author Jordan Halterman jordan.halterman@gmail.com

File

similar.module
View source
<?php

/**
 * @file
 * Module that shows a block listing similar entries.
 * NOTE: Uses MySQL's FULLTEXT indexing for MyISAM tables.
 *
 * Caching feature sponsored by http://xomba.com/
 *
 * @author David Kent Norman http://deekayen.net/
 * @author Arnab Nandi http://arnab.org/
 * @author Jordan Halterman jordan.halterman@gmail.com
 */

/**
 * Implements hook_help().
 */
function similar_help($path, $arg) {
  switch ($path) {
    case 'admin/help#similar':
      return '<p>' . t('Lists the most similar nodes to the current node.') . '</p>';
  }
}

/**
 * Implements hook_theme().
 */
function similar_theme() {
  return array(
    'similar_content' => array(
      'variables' => array(
        'node' => NULL,
      ),
    ),
  );
}

/**
 * Implements hook_cron().
 *
 * Checks if an index rebuild is needed as determined in Drupal
 * variables. If the similar_index variable is not empty it will
 * have an array of field names which were added on the last
 * cron run. If field settings have changed since that last cron
 * run then the old index will be deleted and a new one added that
 * includes all the relevant fields in the table. This is because
 * ideally we want a single index for multiple fields in a table.
 */
function similar_cron() {
  if (module_exists('field')) {
    $indices = similar_get_indices();

    // Get all text columns defined for fields in the database.
    $data = array();
    foreach (field_info_fields() as $field => $info) {
      if ($info['type'] == 'text') {
        $table = key($info['storage']['details']['sql'][FIELD_LOAD_CURRENT]);
        $data[$table][] = $info['storage']['details']['sql'][FIELD_LOAD_CURRENT][$table]['value'];
      }
    }

    // For each table check if the index needs to be reset.
    foreach ($data as $table => $fields) {
      _similar_add_index($table, $data[$table]);
    }
  }
}

/**
 * Adds FULLTEXT indexes to field value database columns.
 *
 * We want to make sure that we index as many columns as are available
 * with a single index to improve results. So we remove the old index
 * from the table before then reindexing the new fields. Indexed fields
 * are then stored in a Drupal variable which is an array of sub-arrays,
 * with the array key the table name and the sub-array being the fields
 * that are indexed. Before calling this function the $fields argument
 * should be compared against the existing index as stored in the
 * similar_indices variable.
 *
 * @param $table
 *   A string representing the table to index.
 * @param $fields
 *   An array of column names to add to the index. Even though most,
 *   if not all of our tables will only have one indexed column, we
 *   still may need to add multiple fields some time.
 */
function _similar_add_index($table, $fields) {
  $index = similar_get_indices();
  if (db_table_exists($table)) {

    // Drop the existing index on the table if it exists.
    if (isset($index[$table]) && !empty($index[$table]) && db_index_exists($table, 'similar')) {
      db_drop_index($table, 'similar');
      unset($index[$table]);
    }
    if (!empty($fields)) {
      $add_fields = implode(', ', $fields);
      db_query("ALTER TABLE {$table} ENGINE = MYISAM");
      db_query("ALTER TABLE {$table} ADD FULLTEXT `similar` ({$add_fields})");
      $index[$table] = $fields;
    }
  }
  elseif (isset($index[$table])) {
    unset($index[$table]);
  }
  variable_set('similar_indices', $index);
}

/**
 * Returns data about what fields are currently indexed.
 *
 * Indexed tables are fields are stored in a Drupal variable. Something
 * to do is look into ways to reset the variable upon reasonable events
 * like a field module install/uninstall or a cache clearance.
 *
 * @param $table
 *   An optional string identifying a specific table's indices to return.
 * @return
 *   An array of sub-arrays where the key is a table name and the value
 *   is an array of fields which are currently indexed in the table.
 */
function similar_get_indices($table = NULL) {
  $indices = variable_get('similar_indices', array());
  if (!empty($table)) {
    return isset($indices[$table]) ? $indices[$table] : FALSE;
  }
  else {
    return $indices;
  }
}

/**
 * Implements hook_block_info().
 */
function similar_block_info() {
  return array(
    'similar' => array(
      'info' => t('Similar Entries'),
      'cache' => DRUPAL_CACHE_PER_PAGE,
    ),
  );
}

/**
 * Implements hook_block_configure().
 */
function similar_block_configure($delta = '') {
  $form = array();
  $form['list_settings'] = array(
    '#type' => 'fieldset',
    '#title' => t('List settings'),
    '#collapsible' => TRUE,
  );
  $form['list_settings']['similar_summary_enabled'] = array(
    '#type' => 'radios',
    '#title' => t('Include summary text'),
    '#default_value' => variable_get('similar_summary_enabled', 0),
    '#options' => array(
      t('No'),
      t('Yes'),
    ),
  );
  $form['list_settings']['similar_rel_nofollow'] = array(
    '#type' => 'radios',
    '#title' => t('Block search engines'),
    '#description' => t('Adds rel="nofollow" to the HTML source of similar links so search engines won\'t count similar links in their ranking calculations.'),
    '#default_value' => variable_get('similar_rel_nofollow', 0),
    '#options' => array(
      t('No'),
      t('Yes'),
    ),
  );
  for ($i = 1, $options = array(); $i < 101; $options[$i] = $i, $i += 1) {
  }
  $form['list_settings']['similar_num_display'] = array(
    '#type' => 'select',
    '#title' => t('Number of similar entries to find'),
    '#default_value' => variable_get('similar_num_display', 5),
    '#options' => $options,
  );
  $types = _similar_published_node_types();
  $form['list_settings']['similar_node_types'] = array(
    '#type' => 'checkboxes',
    '#title' => t('Content types to display'),
    '#default_value' => variable_get('similar_node_types', $types),
    '#options' => $types,
    '#multiple' => TRUE,
  );
  if (module_exists('field')) {
    $form['list_settings']['similar_include_fields'] = array(
      '#type' => 'checkbox',
      '#title' => t('Include content fields in matching'),
      '#default_value' => variable_get('similar_include_fields', 0),
      '#description' => t('Include extra fields defined with Field module in database queries.'),
    );
  }
  if (module_exists('taxonomy')) {
    $names = _similar_taxonomy_names();
    $form['similar_taxonomy'] = array(
      '#type' => 'fieldset',
      '#title' => t('Taxonomy category filter'),
      '#collapsible' => TRUE,
      '#collapsed' => TRUE,
    );
    $form['similar_taxonomy']['similar_taxonomy_filter'] = array(
      '#type' => 'radios',
      '#title' => t('Filter by taxonomy categories'),
      '#default_value' => variable_get('similar_taxonomy_filter', 0),
      '#options' => array(
        t('No category filtering'),
        t('Only show the similar nodes in the same category as the original node'),
        t('Use global category filtering'),
      ),
      '#description' => t('By selecting global filtering, only nodes assigned to the following selected categories will display as similar nodes, regardless of the categories the original node is or is not assigned to.'),
    );
    $form['similar_taxonomy']['similar_taxonomy_select'] = array(
      '#type' => 'fieldset',
      '#title' => t('Taxonomy categories to display'),
      '#collapsible' => TRUE,
      '#collapsed' => TRUE,
    );
    $form['similar_taxonomy']['similar_taxonomy_select']['similar_taxonomy_tids'] = array(
      '#type' => 'select',
      '#default_value' => variable_get('similar_taxonomy_tids', array_keys($names)),
      '#description' => t('Hold the CTRL key to (de)select multiple options.'),
      '#options' => $names,
      '#multiple' => TRUE,
    );
  }
  return $form;
}

/**
 * Implements hook_block_save().
 */
function similar_block_save($delta = '', $edit = array()) {
  variable_set('similar_summary_enabled', $edit['similar_summary_enabled']);
  variable_set('similar_rel_nofollow', $edit['similar_rel_nofollow']);
  variable_set('similar_num_display', $edit['similar_num_display']);
  variable_set('similar_node_types', $edit['similar_node_types']);
  variable_set('similar_include_fields', $edit['similar_include_fields']);
  if (module_exists('taxonomy')) {
    variable_set('similar_taxonomy_filter', $edit['similar_taxonomy_filter']);
    variable_set('similar_taxonomy_tids', $edit['similar_taxonomy_tids']);
  }
}

/**
 * Implements hook_block_view().
 */
function similar_block_view($delta = '') {
  if (arg(0) == 'node' && is_numeric(arg(1)) && arg(2) != 'edit') {
    $node = node_load(arg(1));
  }
  else {
    return;
  }
  $similar_node_types = variable_get('similar_node_types', _similar_published_node_types());
  if ($node->nid > 0 && !empty($similar_node_types[$node->type])) {
    unset($similar_node_types);

    // The subject is displayed at the top of the block. Note that it should
    // be passed through t() for translation.
    $block['subject'] = t('Similar entries');
    $block['content'] = theme('similar_content', array(
      'node' => $node,
    ));
  }
  return empty($block['content']) ? '' : $block;
}

/**
 * Queries for published node types.
 * @link http://drupal.org/node/33444 @endlink
 *
 * @return
 *   An array of node types that are available.
 */
function _similar_published_node_types() {
  $types = array();
  $result = db_select('node', 'n')
    ->fields('n', array(
    'type',
  ))
    ->distinct()
    ->condition('n.status', 0, '<>')
    ->orderBy('n.type', 'ASC')
    ->execute()
    ->fetchAll();
  foreach ($result as $type) {
    $types[$type->type] = $type->type;
  }
  return $types;
}

/**
 * Queries for taxonomy names.
 * @link http://drupal.org/node/51041 @endlink
 *
 *@return
 *   An array of taxonomy term names to be used.
 */
function _similar_taxonomy_names() {
  $names = array();
  $query = db_select('taxonomy_term_data', 'd');
  $query
    ->addField('d', 'tid');
  $query
    ->addField('d', 'name', 'data_name');
  $query
    ->innerJoin('taxonomy_vocabulary', 'v', 'd.vid = v.vid');
  $query
    ->addField('v', 'name', 'vocab_name');
  $query
    ->addField('v', 'vid');
  $query
    ->fields('v', array(
    'vid',
    'name',
  ));
  $query
    ->orderBy('v.name', 'ASC');
  $query
    ->orderBy('d.name', 'ASC');
  $result = $query
    ->execute()
    ->fetchAll();
  foreach ($result as $data) {
    $names[$data->tid] = $data->vocab_name . ': ' . $data->data_name;
  }
  return $names;
}

/**
 * Queries for taxonomies to which a specific node belongs.
 * @link http://drupal.org/node/51041 @endlink
 *
 * @param $nid
 *   A node ID.
 * @return
 *   An array of terms related to the given node.
 */
function _similar_taxonomy_membership($nid) {
  $tids = array();
  $result = db_select('taxonomy_index', 'i')
    ->fields('i', array(
    'tid',
  ))
    ->condition('i.nid', $nid, '=')
    ->execute()
    ->fetchAll();
  foreach ($result as $data) {
    $tids[$data->tid] = $data->tid;
  }
  return $tids;
}

/**
 * Strips characters from node type strings.
 */
function _similar_content_type_escape(&$item) {
  $item = str_replace(array(
    "\0",
    "\n",
    "\r",
    "\\",
    "'",
    "\"",
    "\32",
  ), '', $item);
}

/**
 * Prevents SQL injection be forcing an integer value.
 */
function _similar_force_int(&$item) {
  $item = (int) $item;
}

/**
 * Queries the database for similar entries and puts them in a HTML list.
 *
 * @param $node
 *   The current node being viewed.
 * @return
 *   A themed item list of related links.
 */
function theme_similar_content($variables) {
  $node = $variables['node'];
  $boolean = variable_get('similar_boolean_mode', 1) == 1;
  $summary = variable_get('similar_summary_enabled', 0);
  $items = array();
  $text = $node->title;
  if (isset($node->body) && isset($node->body[$node->language])) {
    $text .= " {$node->body[$node->language][0]['value']}";
  }

  // Create a comma-separated list of available node types.
  $types = _similar_published_node_types();
  $types = variable_get('similar_node_types', $types);
  array_walk($types, '_similar_content_type_escape');

  // Build the database query.
  $query = db_select('node_revision', 'r');
  $query
    ->addField('r', 'nid');
  $query
    ->join('node', 'n', 'r.nid = n.nid AND r.vid = n.vid');

  // Add fields and MATCH queries.
  $selects = array();
  if (db_table_exists('field_data_body')) {
    $body_table = $query
      ->join('field_data_body', 'b', 'n.nid = b.entity_id');
    $selects[] = "{$body_table}.body_value";
  }

  // Add extra fields to the query if enabled.
  if (module_exists('field') && variable_get('similar_include_fields', 0) == 1) {
    foreach (similar_get_indices() as $table => $indexed) {
      if (!empty($indexed) && db_table_exists($table)) {
        $alias = $query
          ->join($table, $table, "n.nid = {$table}.entity_id");
        if (count($indexed) > 1) {
          $selects[] = "{$alias}." . implode(", {$alias}.", $indexed);
        }
        elseif (count($indexed) == 1) {
          $field = array_pop($indexed);
          $selects[] = "{$alias}.{$field}";
        }
      }
    }
  }
  $expression_params = array();
  $expression = "(2 *(MATCH (r.title) AGAINST (:expr_param)))";
  $expression_params['expr_param'] = $text;
  $param_count = 1;
  foreach ($selects as $field) {
    $param_count++;
    $expression_params['expr_param_' . $param_count] = $text;
    $expression .= " + (MATCH ({$field}) AGAINST (:expr_param_{$param_count}))";
  }
  $expression = "({$expression})";

  //$fields = implode(", ", $selects);

  //$expression = "MATCH ($fields) AGAINST ('$text')";
  $query
    ->addExpression($expression, 'score', $expression_params);
  $query
    ->condition('n.status', 0, '<>');
  $query
    ->condition('r.nid', $node->nid, '<>');
  $query
    ->condition('n.type', $types, 'IN');
  $query
    ->groupBy('n.nid');
  $query
    ->having('score > 0');
  $query
    ->orderBy('score', 'DESC');
  $query
    ->range(0, variable_get('similar_num_display', 5));
  $query
    ->addTag('node_access');

  // Add taxonomy filter if enabled.
  if (module_exists('taxonomy') && (variable_get('similar_taxonomy_filter', 0) == 2 && ($taxonomy_tids = variable_get('similar_taxonomy_tids', array()))) || variable_get('similar_taxonomy_filter', 0) == 1 && ($taxonomy_tids = _similar_taxonomy_membership($node->nid))) {
    array_walk($taxonomy_tids, '_similar_force_int');
    if (sizeof($taxonomy_tids) > 1) {
      $taxonomy_tids = implode(',', $taxonomy_tids);
    }
    else {
      list(, $taxonomy_tids) = each($taxonomy_tids);
      $taxonomy_tids = (int) $taxonomy_tids;
    }
    $query
      ->join('taxonomy_index', 'i', 'r.nid = i.nid AND i.tid IN (:tids)', array(
      ':tids' => $taxonomy_tids,
    ));
  }
  $result = $query
    ->execute()
    ->fetchAll();
  foreach ($result as $node) {
    $content = node_load($node->nid);
    $no_follow = variable_get('similar_rel_nofollow', 0) ? array(
      'rel' => 'nofollow',
    ) : array();
    if ($summary) {
      $items[] = '<div class="similar-title">' . l($content->title, 'node/' . $node->nid, array(
        'attributes' => array(
          'title' => $content->title,
        ) + $no_follow,
        'absolute' => TRUE,
      )) . '</div><div class="similar-summary">' . check_markup($content->body[$content->language][0]['safe_summary'], $content->body[$content->language][0]['format'], FALSE) . '</div>';
    }
    else {
      $items[] = l($content->title, 'node/' . $node->nid, array(
        'attributes' => array(
          'title' => $content->title,
        ) + $no_follow,
      ));
    }
  }
  return sizeof($items) > 0 ? theme('item_list', array(
    'items' => $items,
  )) : '';
}

Functions

Namesort descending Description
similar_block_configure Implements hook_block_configure().
similar_block_info Implements hook_block_info().
similar_block_save Implements hook_block_save().
similar_block_view Implements hook_block_view().
similar_cron Implements hook_cron().
similar_get_indices Returns data about what fields are currently indexed.
similar_help Implements hook_help().
similar_theme Implements hook_theme().
theme_similar_content Queries the database for similar entries and puts them in a HTML list.
_similar_add_index Adds FULLTEXT indexes to field value database columns.
_similar_content_type_escape Strips characters from node type strings.
_similar_force_int Prevents SQL injection be forcing an integer value.
_similar_published_node_types Queries for published node types. http://drupal.org/node/33444
_similar_taxonomy_membership Queries for taxonomies to which a specific node belongs. http://drupal.org/node/51041
_similar_taxonomy_names Queries for taxonomy names. http://drupal.org/node/51041