Custom Image Search with Solr, Filefield Sources, and Ctools - Part 3

In our quest to build a custom image search functionality (see parts I and II), we are at the last two steps. We have the ability to display a modal window, search for images, and page through the results; now we just need to be able to write the file name and file id back to the source text field so it can be added to the node. Also, in the last post, we just glossed over the code that actually gathers the search parameters and searches Solr, so we will cover that code in detail here.

Writing the selected image to the field

First, let's look at the Select link that is displayed for each image back in our page callback:

$rows[] = array(
  'image' => $styled_image,
  'name' => $image['filename'],
  'add' => ctools_ajax_text_button("select", "imgsearch/nojs/imgadd/" . $fid . '/' . $next_field_id, t('Select')),
);

The ctools_ajax_text_button() wrapper function just includes the core ajax.inc file and adds the use-ajax class to the link. $fid is the file ID for the image (returned from Solr), and $next_field_id is the number of the element in the field collection.

The menu item for this function is:

$items['imgsearch/%ctools_js/imgadd/%/%'] = array(
  'title' => 'Add Image',
  'page callback' => 'nb_image_search_add_image',
  'page arguments' => array(1, 3, 4),
  'access callback' => TRUE,
  'type' => MENU_CALLBACK,
);

And the corresponding function to add the image:

/**
 *  Add the selected image to field_images
 */
function nb_image_search_add_image($js, $fid, $id) {
  if (!$js) {
    // We don't support degrading this from js because we're not
    // using the server to remember the state of the table.
    return MENU_ACCESS_DENIED;
  }
  ctools_include('ajax');
  ctools_include('modal');
  ctools_include('object-cache');

  //Get file name and selector from ctools object cache.
  $cache = ctools_object_cache_get('imgsearch', 'imgsearch_' . $id);

  $filename = $cache->fids[$fid];
  $imageurl = $filename .' [fid:' . $fid . ']';
  $url_selector = $cache->fieldname['url'];
  $person = $cache->meta[$fid]['person'];
  $person_selector = $cache->fieldname['person'];
  $organization = $cache->meta[$fid]['organization'];
  $organization_selector = $cache->fieldname['organization'];
  $year = $cache->meta[$fid]['year'];
  $year_selector = $cache->fieldname['year'];

  $ajax_commands = array();
  // Tell the browser to close the modal.
  $ajax_commands[] = ctools_modal_command_dismiss();
  // Add our custom insertImagePath command to the commands array.
  $ajax_commands[] = array
  (
    // The command will be used in our JavaScript file (see next section)
    'command' => 'insertImagePath',
    // We pass the field name and the image URL returned from the modal window.
    'url_selector' => $url_selector,
    'imageurl' => $imageurl,
    'person_selector' => $person_selector,
    'person' => $person,
    'organization_selector' => $organization_selector,
    'organization' => $organization,
    'year_selector' => $year_selector,
    'year' => $year,
  );

  // Clear ctools cache to avoid any possible conflicts on the same node.
  ctools_object_cache_clear('imgsearch', 'imgsearch_' . $id);

  print ajax_render($ajax_commands);
}

This is where the image data cached in the ctools_object_cache comes into play. Based on the $next_field_id passed by the modal window link, we get the image data and build an array to pass to our ajax command that will write the image name and file ID to the source field. Once the $ajax_commands array has been created and filled, we make sure to clear out the ctools object cache to avoid conflicts.

The sharp-eyed person will notice in the code above that the Drupal ajax command - insertImagePath - is a custom command. This is because for some reason, the core Drupal ajax code does not include a function that uses the jQuery val() method to write a value to a text field. The command looks like this:

/**
 * Add custom behaviors to Drupal.ajax.prototype.commands
 */
(function($, Drupal)
{
  // Our function name is prototyped as part of the Drupal.ajax namespace, adding to the commands:
  Drupal.ajax.prototype.commands.insertImagePath = function(ajax, response, status)
  {
    // The values we passed in our Ajax callback function will be available inside the
    // response object.
    $(response.url_selector).val(response.imageurl);
    $(response.person_selector).val(response.person);
    $(response.organization_selector).val(response.organization);
    $(response.year_selector).val(response.year);
    // Set focus on field.
    $(response.url_selector).focus();
  };
}(jQuery, Drupal));

So the result is that when the Select link in the modal window is clicked, the modal is closed, and the image name and fid are written to the filefield source text field, like so.

my_image_file.jpg [fid:12345]

Indexing image data into Solr

Finally, we are to the Solr functionality. Before we can search Solr for the image data, we need to index it, and we need to add some custom indexing specifically for images. To do that, we use hook_apachesolr_index_document_build_ENTITY_TYPE().

/**
 * Implementation of hook_apachesolr_index_document_build_ENTITY_TYPE
 *
 * @param ApacheSolrDocument $document
 * @param $entity
 * @param $env_id
 */
function nb_image_search_apachesolr_index_document_build_node(ApacheSolrDocument $document, $entity, $env_id) {
  if ($entity->type == 'blog') {
    if (isset($entity->field_images['und'][0])) {
      $imagefield = entity_load('field_collection_item', ($entity->field_images['und'][0]));
      foreach($imagefield as $key => $image)  {
        if(isset($image->field_image[LANGUAGE_NONE])) {
           // Since they will be search on the image name, we don't want the file extension indexed, so
          // we remove it here.
          $uri  = $image->field_image[LANGUAGE_NONE][0]['uri'];
          // Index the entire image URL
          $document->addField('sm_field_image', $uri);
          $fid = $image->field_image[LANGUAGE_NONE][0]['fid'];
          $length = strrpos($uri, '.') - strrpos($uri, '/') - 1;
          $document->addfield('tom_image_name', substr($uri, strrpos($uri, '/') + 1, $length));
          // But we still need the file name with the extension, so we use a separate field for that.
          $document->addfield('sm_image_name_ext', substr($uri, strrpos($uri, '/') + 1));
          $document->addField('sm_image_path', substr($uri, 0, strrpos($uri, '/') + 1));
          $document->addfield('im_image_fid', $fid);
          // Also index person, organization, and year fields.
          foreach (array('person', 'organization') as $field) {
            $field_name = 'field_' . $field;
            if (isset($image->{$field_name}[LANGUAGE_NONE][0])) {
              $document->addField('tom_image_' . $field, $image->{$field_name}[LANGUAGE_NONE][0]['value']);
            }
            if (!empty($image->field_year)) {
              $document->addField('im_image_year', $image->field_year[LANGUAGE_NONE][0]['value']);
            }
          }
        }
      }
    }
    // There is no image for this node, so get the default image for field_image and index it.
    else{
      $default_file = nb_image_search_get_image_default();
      $default_image_uri = $default_file->uri;
      $document->addField('sm_field_image', $default_image_uri);
    }
  }
}

And here is the referenced helper function to get the default image.

/**
 * Helper function to get the path for the default image.
 *
 * @return default file uri
 */
function nb_image_search_get_image_default() {
  // First, see if the path is already cached.s
  $default_file = &drupal_static(__FUNCTION__);
  if (!isset($default_image_uri)) {
    if ($cache = cache_get('field_image_default')) {
      $default_file = $cache->data;
    }
    // Not cached, so we need to generate it this time through.
    else {
      $field = field_info_field('field_images');
      $instance = field_info_instance('field_collection_item', 'field_image', 'field_images');
      // Load the file object.
      $default_file = file_load($instance['settings']['default_image']);
      // Cache the file object.
      cache_set('field_image_default', $default_file, 'cache');
    }
  }

  return $default_file;
}

All we are doing here is splitting out the image information into smaller parts and indexing them into separate fields in the Solr index, and then also indexing data from the Person, Organization, and Year fields if present.

Searching Solr for images

And finally, we are to the code to actually search Solr. The Apache Solr Search module is currently a requirement, but only a very small portion of it is needed. The D7 version of the module includes a forked version of the Solr PHP Client library, and that's most of what we will be using. The majority of the Apache Solr Search module code handles the Drupal side of things (pages, blocks, facets, etc), and since we are just doing a quick search behind the scenes, we don't need all of that code; we just need the PHP client to directly access Solr (except for one function).

/**
 * Worker function to search for images.
 */
function nb_image_search_search($values, $page_num = 0) {
  // Call solr directly, since we don't need all of the other Drupal stuff
  // to be done once we get the results.
  $query = apachesolr_drupal_query('apachesolr', array(), '', '');

  if (isset($values['search_terms'])) {
    $keys = str_replace("%20", " ", $values['search_terms']);
  }
  else {
    $keys = '';
  }

  // Limit results to only records that have a value for tom_field_images.
  $query->addParam('fq', 'tom_image_name:[* TO *]');

  //Need to add fields to be searched to qf param.
  $query->addParam('qf', 'tom_image_name');

  foreach (array('person', 'organization') as $field) {
    $field_name = 'search_' . $field;
    if($values[$field_name] !== '') {
      $query->addFilter('sm_image_' . $field, $values['search_' . $field]);
    }
  }
  if ($values['search_year'] != '') {
    $query->addFilter('im_image_year', $values['search_year']);
  }

  // Get current params
  $rows = $query->getParam('rows');
  // Set $start param from pager
  $start = $rows * $page_num;
  $query->replaceParam('start', $start);

  // Specify the fields to be returned in the search results.
  $query->addParam('fl', 'sm_image_path');
  $query->addParam('fl', 'sm_image_name_ext');
  $query->addParam('fl', 'im_image_fid');
  $query->addParam('fl', 'sm_image_person');
  $query->addParam('fl', 'sm_image_organization');
  $query->addParam('fl', 'im_image_year');

  $response = $query->search($keys);
  $results = json_decode($response->data);

  // Loop through results and put them in an array for the modal form building function.
  if (count($results->response->docs)) {
    foreach ($results->response->docs as $result) {
      $filepath = $result->sm_image_path[0];
      $filename = $result->sm_image_name_ext[0];
      $fid = $result->im_image_fid[0];
      $images[] = array(
        'filename' => $filename,
        'filepath' => $filepath,
        'fid' => $fid,
        'person' => $result->sm_image_person[0],
        'organization' => $result->sm_image_organization[0],
        'year' => $result->im_image_year[0],
      );
    }
  }
  else {
    $images = 'No matching results found.';
  }
  $params = $query->getParams();
  $return['rows'] = $params['rows'];
  $return['images'] = $images;
  $return['total_found'] = $results->response->numFound;

  return $return;
}

The code above goes through the following steps:

  1. Build the default Solr query object.
  2. Get the search terms.
  3. Specify fields to be searched and values to search with.
  4. Specify the offset to be returned as determined by the pager value.
  5. Run the query.
  6. Put the results into an array, and return them to the page callback.

And that's it. You now have a functional custom image search.

A few notes about this module:

  • As it currently stands, it is fairly specific to my specific setup of four fields in a field collection. However, it should be fairly simple to modify it to fit your needs, including just using it with a simple image field by itself.
  • Since a Multifield uses an identical field structure as Field Collection, this also works with multifields.
  • It is pretty easy to see extending this to include admin settings where you can specify the fields(s) where you want to add this. Creating it as a Filefield Source already allows you to do that, but you also have to know the field structure to pass to the ajax code.

This post will be updated when I have put it on drupal.org as a project.

Sep21