Location: PHPKode > projects > VuFind > vufind-1.0.1/web/sys/SearchObject/Base.php
<?php
/**
 *
 * Copyright (C) Villanova University 2010.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 */
require_once 'services/MyResearch/lib/Search.php';
require_once 'sys/Recommend/RecommendationFactory.php';

/**
 * Search Object abstract base class.
 *
 * Generic base class for abstracting search URL generation and other standard 
 * functionality.  This should be extended to implement functionality for specific
 * VuFind modules (i.e. standard Solr search vs. Summon, etc.).
 */
abstract class SearchObject_Base
{
    // SEARCH PARAMETERS
    // RSS feed?
    protected $view = null;
    // Search terms
    protected $searchTerms = array();
    // Sorting
    protected $sort = null;
    protected $defaultSort = 'relevance';
    protected $defaultSortByType = array();

    // Filters
    protected $filterList = array();
    // Page number
    protected $page = 1;
    // Result limit
    protected $limit = 20;

    // STATS
    protected $resultsTotal = 0;

    // OTHER VARIABLES
    // Server URL
    protected $serverUrl = "";
    // Module and Action for building search results URLs
    protected $resultsModule = 'Search';
    protected $resultsAction = 'Results';
    // Facets information
    protected $facetConfig = array();    // Array of valid facet fields=>labels
    protected $checkboxFacets = array(); // Boolean facets represented as checkboxes
    protected $translatedFacets = array();  // Facets that need to be translated
    // Default Search Handler
    protected $defaultIndex = null;
    // Available sort options
    protected $sortOptions = array();
    // An ID number for saving/retrieving search
    protected $searchId    = null;
    protected $savedSearch = false;
    protected $searchType  = 'basic';
    // Possible values of $searchType:
    protected $basicSearchType = 'basic';
    protected $advancedSearchType = 'advanced';
    // Flag for logging/search history
    protected $disableLogging = false;
    // Debugging flag
    protected $debug;
    // Search options for the user
    protected $advancedTypes = array();
    protected $basicTypes = array();
    // Spelling
    protected $spellcheck    = true;
    protected $suggestions   = array();
    // Recommendation modules associated with the search:
    protected $recommend     = false;
    // The INI file to load recommendations settings from:
    protected $recommendIni = 'searches';

    // STATS
    protected $initTime = null;
    protected $endTime = null;
    protected $totalTime = null;

    protected $queryStartTime = null;
    protected $queryEndTime = null;
    protected $queryTime = null;

    /**
     * Constructor. Initialise some details about the server
     *
     * @access  public
     */
    public function __construct()
    {
        global $configArray;

        // Get the start of the server URL and store
        $this->serverUrl = $configArray['Site']['url'];
        
        // Set appropriate debug mode:
        $this->debug = $configArray['System']['debug'];
    }

    /* Parse apart the field and value from a URL filter string.
     *
     * @access  protected
     * @param   string  $filter     A filter string from url : "field:value"
     * @return  array               Array with elements 0 = field, 1 = value.
     */
    protected function parseFilter($filter)
    {
        // Split the string
        $temp = explode(':', $filter);
        // $field is the first value
        $field = array_shift($temp);
        // join them incase the value contained colons as well.
        $value = join(":", $temp);

        // Remove quotes from the value if there are any
        if (substr($value, 0, 1)  == '"') $value = substr($value, 1);
        if (substr($value, -1, 1) == '"') $value = substr($value, 0, -1);
        // One last little clean on whitespace
        $value = trim($value);
        
        // Send back the results:
        return array($field, $value);
    }

    /**
     * Does the object already contain the specified filter?
     *
     * @access  public
     * @param   string  $filter     A filter string from url : "field:value"
     * @return  bool
     */
    public function hasFilter($filter)
    {
        // Extract field and value from URL string:
        list($field, $value) = $this->parseFilter($filter);

        if (isset($this->filterList[$field]) && 
            in_array($value, $this->filterList[$field])) {
            return true;
        }
        return false;
    }

    /**
     * Take a filter string and add it into the protected
     *   array checking for duplicates.
     *
     * @access  public
     * @param   string  $newFilter   A filter string from url : "field:value"
     */
    public function addFilter($newFilter)
    {
        // Extract field and value from URL string:
        list($field, $value) = $this->parseFilter($newFilter);
        
        // Check for duplicates -- if it's not in the array, we can add it
        if (!$this->hasFilter($newFilter)) {
            $this->filterList[$field][] = $value;
        }
    }

    /**
     * Remove a filter from the list.
     *
     * @access  public
     * @param   string  $oldFilter   A filter string from url : "field:value"
     */
    public function removeFilter($oldFilter)
    {
        // Extract field and value from URL string:
        list($field, $value) = $this->parseFilter($oldFilter);

        // Make sure the field exists
        if (isset($this->filterList[$field])) {
            // Loop through all filters on the field
            for ($i = 0; $i < count($this->filterList[$field]); $i++) {
                // Does it contain the value we don't want?
                if ($this->filterList[$field][$i] == $value) {
                    // If so remove it.
                    unset($this->filterList[$field][$i]);
                }
            }
        }
    }

    /**
     * Get a user-friendly string to describe the provided facet field.
     *
     * @access  protected
     * @param   string  $field                  Facet field name.
     * @return  string                          Human-readable description of field.
     */
    protected function getFacetLabel($field)
    {
        return isset($this->facetConfig[$field]) ? 
            $this->facetConfig[$field] : "Other";
    }

    /**
     * Return an array structure containing all current filters
     *    and urls to remove them.
     *
     * @access  public
     * @param   bool   $excludeCheckboxFilters  Should we exclude checkbox filters
     *                                          from the list (to be used as a
     *                                          complement to getCheckboxFacets()).
     * @return  array    Field, values and removal urls
     */
    public function getFilterList($excludeCheckboxFilters = false)
    {
        // Get a list of checkbox filters to skip if necessary:
        $skipList = $excludeCheckboxFilters ?
            array_keys($this->checkboxFacets) : array();

        $list = array();
        // Loop through all the current filter fields
        foreach ($this->filterList as $field => $values) {
            // and each value currently used for that field
            $translate = in_array($field, $this->translatedFacets);
            foreach ($values as $value) {
                // Add to the list unless it's in the list of fields to skip:
                if (!in_array($field, $skipList)) {
                    $facetLabel = $this->getFacetLabel($field);
                    $display = $translate ? translate($value) : $value;
                    $list[$facetLabel][] = array(
                        'value'      => $value,     // raw value for use with Solr
                        'display'    => $display,   // version to display to user
                        'field'      => $field,
                        'removalUrl' => $this->renderLinkWithoutFilter("$field:$value")
                    );
                }
            }
        }
        return $list;
    }

    /**
     * Return a url for the current search with an additional filter
     *
     * @access  public
     * @param   string   $new_filter   A filter to add to the search url
     * @return  string   URL of a new search
     */
    public function renderLinkWithFilter($newFilter)
    {
        // Stash our old data for a minute
        $oldFilterList = $this->filterList;
        $oldPage       = $this->page;
        // Add the new filter
        $this->addFilter($newFilter);
        // Remove page number
        $this->page = 1;
        // Get the new url
        $url = $this->renderSearchUrl();
        // Restore the old data
        $this->filterList = $oldFilterList;
        $this->page       = $oldPage;
        // Return the URL
        return $url;
    }

    /**
     * Return a url for the current search without one of the current filters
     *
     * @access  public
     * @param   string   $old_filter   A filter to remove from the search url
     * @return  string   URL of a new search
     */
    public function renderLinkWithoutFilter($oldFilter)
    {
        return $this->renderLinkWithoutFilters(array($oldFilter));
    }

    /**
     * Return a url for the current search without several of the current filters
     *
     * @access  public
     * @param   array    $filters      The filters to remove from the search url
     * @return  string   URL of a new search
     */
    public function renderLinkWithoutFilters($filters)
    {
        // Stash our old data for a minute
        $oldFilterList = $this->filterList;
        $oldPage       = $this->page;
        // Remove the old filter
        foreach($filters as $oldFilter) {
            $this->removeFilter($oldFilter);
        }
        // Remove page number
        $this->page = 1;
        // Get the new url
        $url = $this->renderSearchUrl();
        // Restore the old data
        $this->filterList = $oldFilterList;
        $this->page       = $oldPage;
        // Return the URL
        return $url;
    }
    
    /**
     * Get the base URL for search results (including ? parameter prefix).
     *
     * @access  protected
     * @return  string   Base URL
     */
    protected function getBaseUrl()
    {
        return $this->serverUrl."/{$this->resultsModule}/{$this->resultsAction}?";
    }

    /**
     * Get the URL to load a saved search from the current module.
     *
     * @access  public
     * @return  string   Saved search URL.
     */
    public function getSavedUrl($id)
    {
        return $this->getBaseUrl() . 'saved=' . urlencode($id);
    }

    /**
     * Get an array of strings to attach to a base URL in order to reproduce the
     * current search.
     *
     * @access  protected
     * @return  array    Array of URL parameters (key=url_encoded_value format)
     */
    protected function getSearchParams()
    {
        $params = array();
        switch ($this->searchType) {
            // Advanced search
            case $this->advancedSearchType:
                $params[] = "join=" . urlencode($this->searchTerms[0]['join']);
                for ($i = 0; $i < count($this->searchTerms); $i++) {
                    $params[] = "bool".$i."[]=" . urlencode($this->searchTerms[$i]['group'][0]['bool']);
                    for ($j = 0; $j < count($this->searchTerms[$i]['group']); $j++) {
                        $params[] = "lookfor".$i."[]=" . urlencode($this->searchTerms[$i]['group'][$j]['lookfor']);
                        $params[] = "type".$i."[]="    . urlencode($this->searchTerms[$i]['group'][$j]['field']);
                    }
                }
                break;
            // Basic search
            default:
                if (isset($this->searchTerms[0]['lookfor'])) {
                    $params[] = "lookfor=" . urlencode($this->searchTerms[0]['lookfor']);
                }
                if (isset($this->searchTerms[0]['index'])) {
                    $params[] = "type="    . urlencode($this->searchTerms[0]['index']);
                }
                break;
        }
        return $params;
    }

    /**
     * Initialize the object's search settings for a basic search found in the
     * $_REQUEST superglobal.
     *
     * @access  protected
     * @return  boolean  True if search settings were found, false if not.
     */
    protected function initBasicSearch()
    {
        // If no lookfor parameter was found, we have no search terms to
        // add to our array!
        if (!isset($_REQUEST['lookfor'])) {
            return false;
        }
        
        // If lookfor is an array, we may be dealing with a legacy Advanced
        // Search URL.  If there's only one parameter, we can flatten it,
        // but otherwise we should treat it as an error -- no point in going
        // to great lengths for compatibility.
        if (is_array($_REQUEST['lookfor'])) {
            if (count($_REQUEST['lookfor']) == 1) {
                $_REQUEST['lookfor'] = $_REQUEST['lookfor'][0];
            } else {
                PEAR::RaiseError(new PEAR_Error("Unsupported search URL."));
                die();
            }
        }
        
        // If no type defined use default
        if ((isset($_REQUEST['type'])) && ($_REQUEST['type'] != '')) {
            $type = $_REQUEST['type'];
            
            // Flatten type arrays for backward compatibility:
            if (is_array($type)) {
                $type = $type[0];
            }
        } else {
            $type = $this->defaultIndex;
        }

        $this->searchTerms[] = array(
            'index'   => $type,
            'lookfor' => $_REQUEST['lookfor']
        );
        
        return true;
    }

    /**
     * Initialize the object's search settings for an advanced search found in the
     * $_REQUEST superglobal.  Advanced searches have numeric subscripts on the
     * lookfor and type parameters -- this is how they are distinguished from basic
     * searches.
     *
     * @access  protected
     */
    protected function initAdvancedSearch()
    {
        //********************
        // Advanced Search logic
        //  'lookfor0[]' 'type0[]'
        //  'lookfor1[]' 'type1[]' ...
        $this->searchType = $this->advancedSearchType;
        $groupCount = 0;
        // Loop through each search group
        while (isset($_REQUEST['lookfor'.$groupCount])) {
            $group = array();
            // Loop through each term inside the group
            for ($i = 0; $i < count($_REQUEST['lookfor'.$groupCount]); $i++) {
                // Ignore advanced search fields with no lookup
                if ($_REQUEST['lookfor'.$groupCount][$i] != '') {
                    // Use default fields if not set
                    if (isset($_REQUEST['type'.$groupCount][$i]) && $_REQUEST['type'.$groupCount][$i] != '') {
                        $type = $_REQUEST['type'.$groupCount][$i];
                    } else {
                        $type = $this->defaultIndex;
                    }

                    // Add term to this group
                    $group[] = array(
                        'field'   => $type,
                        'lookfor' => $_REQUEST['lookfor'.$groupCount][$i],
                        'bool'    => $_REQUEST['bool'.$groupCount][0]
                    );
                }
            }

            // Make sure we aren't adding groups that had no terms
            if (count($group) > 0) {
                // Add the completed group to the list
                $this->searchTerms[] = array(
                    'group' => $group,
                    'join'  => $_REQUEST['join']
                );
            }

            // Increment 
            $groupCount++;
        }

        // Finally, if every advanced row was empty
        if (count($this->searchTerms) == 0) {
            // Treat it as an empty basic search
            $this->searchType = $this->basicSearchType;
            $this->searchTerms[] = array(
                'index'   => $this->defaultIndex,
                'lookfor' => ''
            );
        }
    }

    /**
     * Add view mode to the object based on the $_REQUEST superglobal.
     *
     * @access  protected
     */
    protected function initView()
    {
        //********************
        // RSS feed? We should only see this in GET from a url.
        if (isset($_REQUEST['view']) && ($_REQUEST['view'] == 'rss')) {
          $this->view = 'rss';
        }
    }

    /**
     * Add page number to the object based on the $_REQUEST superglobal.
     *
     * @access  protected
     */
    protected function initPage()
    {
        if (isset($_REQUEST['page'])) {
            $this->page = $_REQUEST['page'];
        }
        $this->page = intval($this->page);
        if ($this->page < 1) {
            $this->page = 1;
        }
    }

    /**
     * Add sort value to the object based on the $_REQUEST superglobal.
     *
     * @access  protected
     */
    protected function initSort()
    {
        if (isset($_REQUEST['sort'])) {
            $this->sort = $_REQUEST['sort'];
        } else {
            // Is there a search-specific sort type set?
            $type = isset($_REQUEST['type']) ? $_REQUEST['type'] : false;
            if ($type && isset($this->defaultSortByType[$type])) {
                $this->sort = $this->defaultSortByType[$type];
            // If no search-specific sort type was found, use the overall default:
            } else {
                $this->sort = $this->defaultSort;
            }
        }
    }

    /**
     * Add filters to the object based on values found in the $_REQUEST superglobal.
     *
     * @access  protected
     */
    protected function initFilters()
    {
        if (isset($_REQUEST['filter'])) {
            if (is_array($_REQUEST['filter'])) {
                foreach($_REQUEST['filter'] as $filter) {
                    $this->addFilter($filter);
                }
            } else {
                $this->addFilter($_REQUEST['filter']);
            }
        }
    }

    /**
     * Build a url for the current search
     *
     * @access  public
     * @return  string   URL of a search
     */
    public function renderSearchUrl()
    {
        // Get the base URL and initialize the parameters attached to it:
        $url = $this->getBaseUrl();
        $params = $this->getSearchParams();

        // Add any filters
        if (count($this->filterList) > 0) {
            foreach ($this->filterList as $field => $filter) {
                foreach ($filter as $value) {
                    $params[] = "filter[]=" . urlencode("$field:\"$value\"");
                }
            }
        }

        // Sorting
        if ($this->sort != null && $this->sort != $this->defaultSort) {
            $params[] = "sort=" . urlencode($this->sort);
        }

        // Page number
        if ($this->page != 1) {
            // Don't url encode if it's the paging template
            if ($this->page == '%d') {
                $params[] = "page=" . $this->page;
            // Otherwise... encode to prevent XSS.
            } else {
                $params[] = "page=" . urlencode($this->page);
            }
        }

        // View
        if ($this->view != null) {
            $params[] = "view=" . urlencode($this->view);
        }

        // Join all parameters with an escaped ampersand,
        //   add to the base url and return
        return $url . join("&", $params);
    }

    /**
     * Return a url for use by pagination template
     *
     * @access  public
     * @return  string   URL of a new search
     */
    public function renderLinkPageTemplate()
    {
        // Stash our old data for a minute
        $oldPage = $this->page;
        // Add the page template
        $this->page = '%d';
        // Get the new url
        $url = $this->renderSearchUrl();
        // Restore the old data
        $this->page = $oldPage;
        // Return the URL
        return $url;
    }

    /**
     * Return a url for the current search with a new sort
     *
     * @access  public
     * @param   string   $new_sort   A field to sort by
     * @return  string   URL of a new search
     */
    public function renderLinkWithSort($newSort)
    {
        // Stash our old data for a minute
        $oldSort = $this->sort;
        // Add the new sort
        $this->sort = $newSort;
        // Get the new url
        $url = $this->renderSearchUrl();
        // Restore the old data
        $this->sort = $oldSort;
        // Return the URL
        return $url;
    }

    /**
     * Return a list of urls for sorting, along with which option
     *    should be currently selected.
     *
     * @access  public
     * @return  array    Sort urls, descriptions and selected flags
     */
    public function getSortList()
    {
        // Loop through all the current filter fields
        $valid = $this->getSortOptions();
        $list = array();
        foreach ($valid as $sort => $desc) {
            $list[$sort] = array(
                'sortUrl'  => $this->renderLinkWithSort($sort),
                'desc' => $desc,
                'selected' => ($sort == $this->sort)
            );
        }
        return $list;
    }

    /**
     * Basic 'getters'
     *
     * @access  public
     * @return  mixed    various internal variables
     */
    public function getAdvancedTypes()  {return $this->advancedTypes;}
    public function getBasicTypes()     {return $this->basicTypes;}
    public function getFilters()        {return $this->filterList;}
    public function getPage()           {return $this->page;}
    public function getQuerySpeed()     {return $this->queryTime;}
    public function getRawSuggestions() {return $this->suggestions;}
    public function getResultTotal()    {return $this->resultsTotal;}
    public function getSearchId()       {return $this->searchId;}
    public function getSearchTerms()    {return $this->searchTerms;}
    public function getSearchType()     {return $this->searchType;}
    public function getStartTime()      {return $this->initTime;}
    public function getTotalSpeed()     {return $this->totalTime;}
    public function getView()           {return $this->view;}
    public function isSavedSearch()     {return $this->savedSearch;}
    public function getLimit()          {return $this->limit;}

    /**
     * Protected 'getters' for values not intended for use outside the class.
     *
     * @access  protected
     * @return  mixed    various internal variables
     */
    protected function getSortOptions() { return $this->sortOptions; }
    
    /**
     * Reset a simple query against the default index.
     *
     * @access  public
     * @param   string  $query   Query string
     * @param   string  $index   Index to search (exclude to use default)
     */
    public function setBasicQuery($query, $index = null)
    {
        if (is_null($index)) {
            $index = $this->defaultIndex;
        }
        $this->searchTerms = array();
        $this->searchTerms[] = array(
            'index'   => $index,
            'lookfor' => $query
        );
    }

    /**
     * Set the number of search results returned per page.
     *
     * @access  public
     * @param   int     $limit      New page limit value
     */
    public function setLimit($limit)
    {
        $this->limit = $limit;
    }

    /**
     * Set the result page, override the page parameter in  $_REQUEST.
     *
     * @access  public
     * @param   int     $page      New page value
     */
    public function setPage($page)
    {
        $this->page = $page;
    }

    /**
     * Add a field to facet on.
     *
     * @access  public
     * @param   string  $newField   Field name
     * @param   string  $newAlias   Optional on-screen display label
     */
    public function addFacet($newField, $newAlias = null)
    {
        if ($newAlias == null) {
            $newAlias = $newField;
        }
        $this->facetConfig[$newField] = $newAlias;
    }

    /**
     * Add a checkbox facet.  When the checkbox is checked, the specified filter
     * will be applied to the search.  When the checkbox is not checked, no filter
     * will be applied.
     *
     * @access  public
     * @param   string  $filter     [field]:[value] pair to associate with checkbox
     * @param   string  $desc       Description to associate with the checkbox
     */
    public function addCheckboxFacet($filter, $desc) {
        // Extract the facet field name from the filter, then add the
        // relevant information to the array.
        list($fieldName) = explode(':', $filter);
        $this->checkboxFacets[$fieldName] = 
            array('desc' => $desc, 'filter' => $filter);
    }

    /**
     * Get information on the current state of the boolean checkbox facets.
     *
     * @access  public
     * @return  array
     */
    public function getCheckboxFacets()
    {
        // Create a lookup array of filter removal URLs -- this will tell us
        // if any of the boxes are checked, and help us uncheck them if they are.
        //
        // Note that this assumes that each boolean filter's field name will only
        // show up once anywhere in the filter list -- this is why you can't use
        // the same field both in the checkbox facet list and the regular facet
        // list.
        $filters = $this->getFilterList();
        $deselect = array();
        foreach($filters as $currentSet) {
            foreach($currentSet as $current) {
                $deselect[$current['field']] = $current['removalUrl'];
            }
        }
        
        // Now build up an array of checkbox facets with status booleans and
        // toggle URLs.
        $facets = array();
        foreach($this->checkboxFacets as $field => $details) {
            $facets[$field] = $details;
            if (isset($deselect[$field])) {
                $facets[$field]['selected'] = true;
                $facets[$field]['toggleUrl'] = $deselect[$field];
            } else {
                $facets[$field]['selected'] = false;
                $facets[$field]['toggleUrl'] = 
                    $this->renderLinkWithFilter($details['filter']);
            }
            // Is this checkbox always visible, even if non-selected on the
            // "no results" screen?  By default, no (may be overridden by
            // child classes).
            $facets[$field]['alwaysVisible'] = false;
        }
        return $facets;
    }

    /**
     * Return an array of data summarising the results of a search.
     *
     * @access  public
     * @return  array   summary of results
     */
    public function getResultSummary()
    {
        $summary = array();

        $summary['page']        = $this->page;
        $summary['perPage']     = $this->limit;
        $summary['resultTotal'] = $this->resultsTotal;
        // 1st record is easy, work out the start of this page
        $summary['startRecord'] = (($this->page - 1) * $this->limit) + 1;
        // Last record needs more care
        if ($this->resultsTotal < $this->limit) {
            // There are less records returned then one page, use total results
            $summary['endRecord'] = $this->resultsTotal;
        } else if (($this->page * $this->limit) > $this->resultsTotal) {
            // The end of the curent page runs past the last record, use total results
            $summary['endRecord'] = $this->resultsTotal;
        } else {
            // Otherwise use the last record on this page
            $summary['endRecord'] = $this->page * $this->limit;
        }

        return $summary;
    }

    /**
     * Get a link to a blank search restricted by the specified facet value.
     *
     * @access  protected
     * @param   string      $field      The facet field to limit on
     * @param   string      $value      The facet value to limit with
     * @return  string                  The URL to the desired search
     */
    protected function getExpandingFacetLink($field, $value)
    {
        // Stash our old search
        $temp_data = $this->searchTerms;
        $temp_type = $this->searchType;

        // Add an empty search
        $this->searchType = $this->basicSearchType;
        $this->setBasicQuery('');

        // Get the link:
        $url = $this->renderLinkWithFilter("{$field}:{$value}");

        // Restore our old search
        $this->searchTerms = $temp_data;
        $this->searchType  = $temp_type;

        // Send back the requested link>
        return $url;
    }

    /**
     * Returns the stored list of facets for the last search
     *
     * @access  public
     * @param   array   $filter         Array of field => on-screen description
     *                                  listing all of the desired facet fields;
     *                                  set to null to get all configured values.
     * @param   bool    $expandingLinks If true, we will include expanding URLs
     *                                  (i.e. get all matches for a facet, not
     *                                  just a limit to the current search) in
     *                                  the return array.
     * @return  array   Facets data arrays
     */
    public function getFacetList($filter = null, $expandingLinks = false)
    {
        // Assume no facets by default -- child classes can override this to extract
        // the necessary details from the results saved by processSearch().
        return array();
    }
    
    /**
     * Disable logging. Used to stop administrative searches
     *    appearing in search histories
     *
     * @access  public
     */
    public function disableLogging() {
        $this->disableLogging = true;
    }

    /**
     * Used during repeated deminification (such as search history).
     *   To scrub fields populated above.
     *
     * @access  protected
     */
    protected function purge()
    {
        $this->searchType   = $this->basicSearchType;
        $this->searchId     = null;
        $this->resultsTotal = null;
        $this->filterList   = null;
        $this->initTime     = null;
        $this->queryTime    = null;
        // An array so we don't have to initialise
        //   the empty array during population.
        $this->searchTerms  = array();
    }

    /**
     * Create a minified copy of this object for storage in the database.
     *
     * @access  protected
     * @return  object     A SearchObject instance
     */
    protected function minify()
    {
        // Clone ourself as a minified object
        $newObject = new minSO($this);
        // Return the new object
        return $newObject;
    }

    /**
     * Initialise the object from a minified one stored in a cookie or database.
     *  Needs to be kept up-to-date with the minSO object at the end of this file.
     *
     * @access  public
     * @param   object     A minSO object
     */
    public function deminify($minified)
    {
        // Clean the object
        $this->purge();

        // Most values will transfer without changes
        $this->searchId     = $minified->id;
        $this->initTime     = $minified->i;
        $this->queryTime    = $minified->s;
        $this->resultsTotal = $minified->r;
        $this->filterList   = $minified->f;
        $this->searchType   = $minified->ty;

        // Search terms, we need to expand keys
        $tempTerms = $minified->t;
        foreach ($tempTerms as $term) {
            $newTerm = array();
            foreach ($term as $k => $v) {
                switch ($k) {
                  case 'j' :  $newTerm['join']    = $v; break;
                  case 'i' :  $newTerm['index']   = $v; break;
                  case 'l' :  $newTerm['lookfor'] = $v; break;
                  case 'g' :
                      $newTerm['group'] = array();
                      foreach ($v as $line) {
                          $search = array();
                          foreach ($line as $k2 => $v2) {
                              switch ($k2) {
                                  case 'b' :  $search['bool']    = $v2; break;
                                  case 'f' :  $search['field']   = $v2; break;
                                  case 'l' :  $search['lookfor'] = $v2; break;
                              }
                          }
                          $newTerm['group'][] = $search;
                      }
                      break;
                }
            }
            $this->searchTerms[] = $newTerm;
        }
    }

    /**
     * Add into the search table (history)
     *
     * @access  protected
     */
    protected function addToHistory()
    {
        global $user;

        // Get the list of all old searches for this session and/or user
        $s = new SearchEntry();
        $searchHistory = $s->getSearches(session_id(), is_object($user) ? $user->id : null);

        // Duplicate elimination
        $dupSaved  = false;
        foreach ($searchHistory as $oldSearch) {
            // Deminify the old search
            $minSO = unserialize($oldSearch->search_object);
            $dupSearch = SearchObjectFactory::deminify($minSO);
            // See if the classes and urls match
            if (get_class($dupSearch) && get_class($this) &&
                $dupSearch->renderSearchUrl() == $this->renderSearchUrl()) {
                // Is the older search saved?
                if ($oldSearch->saved) {
                    // Flag for later
                    $dupSaved = true;
                    // Record the details
                    $this->searchId    = $oldSearch->id;
                    $this->savedSearch = true;
                } else {
                    // Delete this search
                    $oldSearch->delete();
                }
            }
        }

        // Save this search unless we found a 'saved' duplicate
        if (!$dupSaved) {
            $search = new SearchEntry();
            $search->session_id = session_id();
            $search->created = date('Y-m-d');
            $search->search_object = serialize($this->minify());

            $search->insert();
            // Record the details
            $this->searchId    = $search->id;
            $this->savedSearch = false;

            // Chicken and egg... We didn't know the id before insert
            $search->search_object = serialize($this->minify());
            $search->update();
        }
    }

    /**
     * If there is a saved search being loaded through $_REQUEST, redirect to the
     * URL for that search.  If no saved search was requested, return false.  If 
     * unable to load a requested saved search, return a PEAR_Error object.
     *
     * @access  protected
     * @return  mixed               Does not return on successful load, returns 
     *                              false if no search to restore, returns
     *                              PEAR_Error object in case of trouble.
     */
    protected function restoreSavedSearch()
    {
        global $user;

        // Is this is a saved search?
        if (isset($_REQUEST['saved'])) {
            // Yes, retrieve it
            $search = new SearchEntry();
            $search->id = $_REQUEST['saved'];
            if ($search->find(true)) {
                // Found, make sure the user has the
                //   rights to view this search
                if ($search->session_id == session_id() || $search->user_id == $user->id) {
                    // They do, deminify it to a new object.
                    $minSO = unserialize($search->search_object);
                    $savedSearch = SearchObjectFactory::deminify($minSO);

                    // Now redirect to the URL associated with the saved search;
                    // this simplifies problems caused by mixing different classes
                    // of search object, and it also prevents the user from ever
                    // landing on a "?saved=xxxx" URL, which may not persist beyond
                    // the current session.  (We want all searches to be 
                    // persistent and bookmarkable).
                    header('Location: ' . $savedSearch->renderSearchUrl());
                    die();
                } else {
                    // They don't
                    // TODO : Error handling -
                    //    User is trying to view a saved search from
                    //    another session (deliberate or expired) or
                    //    associated with another user.
                    return new PEAR_Error("Attempt to access invalid search ID");
                }
            }
        }
        
        // Report no saved search to restore.
        return false;
    }

    /**
     * Initialise the object from the global
     *  search parameters in $_REQUEST.
     *
     * @access  public
     * @return  boolean
     */
    public function init()
    {
        // Start the timer
        $mtime = explode(' ', microtime());
        $this->initTime = $mtime[1] + $mtime[0];
        return true;
    }

    /**
     * An optional de-initialise function.
     * 
     *   At this stage it's used for finish of timing calculations
     *   and logged search history.
     * 
     *   Possible future uses will including closing child resources if
     *     required (database connections) or finish database writes
     *     (audit tables). Remember the destructor() can also be used
     *     for mandatory stuff.
     *   Might need parameters for logging level and whether to keep
     *     the search in history etc.
     *
     * @access  public
     */
    public function close()
    {
        // Finish timing
        $mtime = explode(" ", microtime());
        $this->endTime = $mtime[1] + $mtime[0];
        $this->totalTime = $this->endTime - $this->initTime;

        if (!$this->disableLogging) {
            // Add to search history
            $this->addToHistory();
        }
        
        //if ($this->debug) {
        //    echo $this->debugOutput();
        //}
    }

    /**
     * DEBUGGING. Support function for debugOutput().
     *
     * @access  protected
     * @return  string
     */
    protected function debugOutputSearchTerms()
    {
        // Advanced search
        if (isset($this->searchTerms[0]['group'])) {
            $output = "GROUP JOIN : " . $this->searchTerms[0]['join'] . "<br/>\n";
            for ($i = 0; $i < count($this->searchTerms); $i++) {
                $output .= "BOOL ($i) : " . $this->searchTerms[$i]['group'][0]['bool'] . "<br/>\n";
                for ($j = 0; $j < count($this->searchTerms[$i]['group']); $j++) {
                    $output .= "TERMS ($i)($j) : " . $this->searchTerms[$i]['group'][$j]['lookfor'] . "<br/>\n";
                    $output .= "INDEX ($i)($j) : " . $this->searchTerms[$i]['group'][$j]['field'] . "<br/>\n";
                }
            }
        // Basic search
        } else {
            $output = "TERMS : " . $this->searchTerms[0]['lookfor'] . "<br/>\n";
            $output .= "INDEX : " . $this->searchTerms[0]['index']   . "<br/>\n";
        }
        
        return $output;
    }

    /**
     * DEBUGGING. Use this to print out your search.
     *
     * @access  public
     * @return  string
     */
    public function debugOutput()
    {
        $output = "VIEW : " . $this->view . "<br/>\n";
        $output .= $this->debugOutputSearchTerms();

        foreach ($this->filterList as $field => $filter) {
            foreach ($filter as $value) {
                $output .= "FILTER : $field => $value<br/>\n";
            }
        }
        $output .= "PAGE : "   . $this->page         . "<br/>\n";
        $output .= "SORT : "   . $this->sort         . "<br/>\n";
        $output .= "TIMING : START : "   . $this->initTime       . "<br/>\n";
        $output .= "TIMING : QUERY.S : " . $this->queryStartTime . "<br/>\n";
        $output .= "TIMING : QUERY.E : " . $this->queryEndTime   . "<br/>\n";
        $output .= "TIMING : FINISH : "  . $this->endTime        . "<br/>\n";

        return $output;
    }

    /**
     * Start the timer to figure out how long a query takes.  Complements
     * stopQueryTimer().
     *
     * @access protected
     */
    protected function startQueryTimer()
    {
        // Get time before the query
        $time = explode(" ", microtime());
        $this->queryStartTime = $time[1] + $time[0];
    }

    /**
     * End the timer to figure out how long a query takes.  Complements
     * startQueryTimer().
     *
     * @access protected
     */
    protected function stopQueryTimer()
    {
        $time = explode(" ", microtime());
        $this->queryEndTime = $time[1] + $time[0];
        $this->queryTime = $this->queryEndTime - $this->queryStartTime;
    }

    /**
     * Return the field (index) searched by a basic search
     *
     * @access  public
     * @return  string   The searched index
     */
    public function getSearchIndex()
    {
        // Single search index does not apply to advanced search:
        if ($this->searchType == $this->advancedSearchType) {
            return null;
        }
        return $this->searchTerms[0]['index'];
    }

    /**
     * Find a word amongst the current search terms
     *
     * @access  protected
     * @param   string   Search term to find
     * @return  bool     True/False if the word was found
     */
    protected function findSearchTerm($needle) {
        // Advanced search
        if (isset($this->searchTerms[0]['group'])) {
            foreach ($this->searchTerms as $group) {
                foreach ($group['group'] as $search) {
                    if (preg_match("/\b$needle\b/", $search['lookfor'])) {
                        return true;
                    }
                }
            }

        // Basic search
        } else {
            foreach ($this->searchTerms as $haystack) {
                if (preg_match("/\b$needle\b/", $haystack['lookfor'])) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Replace a search term in the query
     *
     * @access  protected
     * @param   string   $from  Search term to find
     * @param   string   $to    Search term to insert
     */
    protected function replaceSearchTerm($from, $to) {
        // Escape $from so it is regular expression safe (just in case it
        // includes any weird punctuation -- unlikely but possible):
        $from = addcslashes($from, '\^$.[]|()?*+{}/');

        // If we are looking for a quoted phrase
        // we can't use word boundaries
        if (strpos($from, '"') === false) {
            $pattern = "/\b$from\b/i";
        } else  {
            $pattern = "/$from/i";
        }

        // Advanced search
        if (isset($this->searchTerms[0]['group'])) {
            for ($i = 0; $i < count($this->searchTerms); $i++) {
                for ($j = 0; $j < count($this->searchTerms[$i]['group']); $j++) {
                    $this->searchTerms[$i]['group'][$j]['lookfor'] = 
                        preg_replace($pattern, $to, 
                            $this->searchTerms[$i]['group'][$j]['lookfor']);
                }
            }
        // Basic search
        } else {
            for ($i = 0; $i < count($this->searchTerms); $i++) {
                // Perform the replacement:
                $this->searchTerms[$i]['lookfor'] = preg_replace($pattern, 
                    $to, $this->searchTerms[$i]['lookfor']);
            }
        }
    }

    /**
     * Return a query string for the current search with
     *   a search term replaced
     *
     * @access  public
     * @param   string   $oldTerm   The old term to replace
     * @param   string   $newTerm   The new term to search
     * @return  string   query string
     */
    public function getDisplayQueryWithReplacedTerm($oldTerm, $newTerm)
    {
        // Stash our old data for a minute
        $oldTerms = $this->searchTerms;
        // Replace the search term
        $this->replaceSearchTerm($oldTerm, $newTerm);
        // Get the new query string
        $query = $this->displayQuery();
        // Restore the old data
        $this->searchTerms = $oldTerms;
        // Return the query string
        return $query;
    }

    /**
     * Input Tokenizer - Specifically for spelling purposes
     *
     * Because of its focus on spelling, these tokens are unsuitable
     * for actual searching. They are stripping important search data
     * such as joins and groups, simply because they don't need to be
     * spellchecked.
     *
     * @return  array               Tokenized array
     * @access  public
     */
    public function spellingTokens($input)
    {
        $joins = array("AND", "OR", "NOT");
        $paren = array("(" => "", ")" => "");

        // Base of this algorithm comes straight from
        // PHP doco examples & benighted at gmail dot com
        // http://php.net/manual/en/function.strtok.php
        $tokens = array();
        $token = strtok($input,' ');
        while ($token) {
            // find bracketed tokens
            if ($token{0}=='(') {$token .= ' '.strtok(')').')';}
            // find double quoted tokens
            if ($token{0}=='"') {$token .= ' '.strtok('"').'"';}
            // find single quoted tokens
            if ($token{0}=="'") {$token .= ' '.strtok("'")."'";}
            $tokens[] = $token;
            $token = strtok(' ');
        }
        // Some cleaning of tokens that are just boolean joins
        //  and removal of brackets
        $return = array();
        foreach ($tokens as $token) {
            // Ignore join
            if (!in_array($token, $joins)) {
                // And strip parentheses
                $final = trim(strtr($token, $paren));
                if ($final != "") $return[] = $final; 
            }
        }
        return $return;
    }

    /**
     * Return a url for the current search with a search term replaced
     *
     * @access  public
     * @param   string   $oldTerm   The old term to replace
     * @param   string   $newTerm   The new term to search
     * @return  string   URL of a new search
     */
    public function renderLinkWithReplacedTerm($oldTerm, $newTerm)
    {
        // Stash our old data for a minute
        $oldTerms = $this->searchTerms;
        $oldPage = $this->page;
        // Switch to page 1 -- it doesn't make sense to maintain the current page
        // when changing the contents of the search
        $this->page = 1;
        // Replace the term
        $this->replaceSearchTerm($oldTerm, $newTerm);
        // Get the new url
        $url = $this->renderSearchUrl();
        // Restore the old data
        $this->searchTerms = $oldTerms;
        $this->page = $oldPage;
        // Return the URL
        return $url;
    }

    /**
     * Get the templates used to display recommendations for the current search.
     *
     * @access  public
     * @param   string      $location           'top' or 'side'
     * @return  array       Array of templates to display at the specified location.
     */
    public function getRecommendationsTemplates($location = 'top')
    {
        $retval = array();
        if (isset($this->recommend[$location]) && 
            !empty($this->recommend[$location])) {
            foreach($this->recommend[$location] as $current) {
                $retval[] = $current->getTemplate();
            }
        }
        return $retval;
    }

    /**
     * Load all recommendation settings from the relevant ini file.  Returns an
     * associative array where the key is the location of the recommendations (top
     * or side) and the value is the settings found in the file (which may be either
     * a single string or an array of strings).
     *
     * @access  protected
     * @return  array           associative: location (top/side) => search settings
     */
    protected function getRecommendationSettings()
    {
        // Load the necessary settings to determine the appropriate recommendations
        // module:
        $search = $this->searchTerms;
        $searchSettings = getExtraConfigArray($this->recommendIni);
        
        // If we have just one search type, save it so we can try to load a 
        // type-specific recommendations module:
        if (count($search) == 1 && isset($search[0]['index'])) {
            $searchType = $search[0]['index'];
        } else {
            $searchType = false;
        }
        
        // Load a type-specific recommendations setting if possible, or the default
        // otherwise:
        $recommend = array();
        if ($searchType && 
            isset($searchSettings['TopRecommendations'][$searchType])) {
            $recommend['top'] = $searchSettings['TopRecommendations'][$searchType];
        } else {
            $recommend['top'] = 
                isset($searchSettings['General']['default_top_recommend']) ?
                $searchSettings['General']['default_top_recommend'] : false;
        }
        if ($searchType && 
            isset($searchSettings['SideRecommendations'][$searchType])) {
            $recommend['side'] = $searchSettings['SideRecommendations'][$searchType];
        } else {
            $recommend['side'] = 
                isset($searchSettings['General']['default_side_recommend']) ?
                $searchSettings['General']['default_side_recommend'] : false;
        }
        
        return $recommend;
    }

    /**
     * Initialize the recommendations module based on current searchTerms.
     *
     * @access  protected
     */
    protected function initRecommendations()
    {
        // If no settings were found, quit now:
        $settings = $this->getRecommendationSettings();
        if (empty($settings)) {
            $this->recommend = false;
            return;
        }
        
        // Process recommendations for each location:
        $this->recommend = array('top' => array(), 'side' => array());
        foreach($settings as $location => $currentSet) {
            // If the current location is disabled, skip processing!
            if (empty($currentSet)) {
                continue;
            }
            // Make sure the current location's set of recommendations is an array;
            // if it's a single string, this normalization will simplify processing.
            if (!is_array($currentSet)) {
                $currentSet = array($currentSet);
            }
            // Now loop through all recommendation settings for the location.
            foreach($currentSet as $current) {
                // Break apart the setting into module name and extra parameters:
                $current = explode(':', $current);
                $module = array_shift($current);
                $params = implode(':', $current);
                
                // Can we build a recommendation module with the provided settings?
                // If the factory throws an error, we'll assume for now it means we
                // tried to load a non-existent module, and we'll ignore it.
                $obj = RecommendationFactory::initRecommendation($module, $this,
                    $params);
                if ($obj && !PEAR::isError($obj)) {
                    $obj->init();
                    $this->recommend[$location][] = $obj;
                }
            }
        }
    }

    /**
     * Load all available facet settings.  This is mainly useful for showing
     * appropriate labels when an existing search has multiple filters associated
     * with it.
     *
     * @access  public
     * @param   string      $preferredSection       Section to favor when loading
     *                                              settings; if multiple sections
     *                                              contain the same facet, this
     *                                              section's description will be
     *                                              favored.
     */
    public function activateAllFacets($preferredSection = false)
    {
        // By default, there is only set of facet settings, so this function isn't
        // really necessary.  However, in the Search History screen, we need to
        // use this for Solr-based Search Objects, so we need this dummy method to
        // allow other types of Search Objects to co-exist with Solr-based ones.
        // See the Solr Search Object for details of how this works if you need to
        // implement context-sensitive facet settings in another module.
    }

    /**
     * Translate a field name to a displayable string for rendering a query in
     * human-readable format:
     *
     * @access  protected
     * @param   string      $field          Field name to display.
     * @return  string                      Human-readable version of field name.
     */
    protected function getHumanReadableFieldName($field)
    {
        if (isset($this->basicTypes[$field])) {
            return translate($this->basicTypes[$field]);
        } else if (isset($this->advancedTypes[$field])) {
            return translate($this->advancedTypes[$field]);
        } else {
            return $field;
        }
    }

    /**
     * Get a human-readable presentation version of the advanced search query
     * stored in the object.  This will not work if $this->searchType is not
     * 'advanced.'
     *
     * @access  protected
     * @return  string
     */
    protected function buildAdvancedDisplayQuery()
    {
        // Groups and exclusions. This mirrors some logic in Solr.php
        $groups   = array();
        $excludes = array();

        foreach ($this->searchTerms as $search) {
            $thisGroup = array();
            // Process each search group
            foreach ($search['group'] as $group) {
                // Build this group individually as a basic search
                $thisGroup[] = $this->getHumanReadableFieldName($group['field']) .
                    ":{$group['lookfor']}";
            }
            // Is this an exclusion (NOT) group or a normal group?
            if ($search['group'][0]['bool'] == 'NOT') {
                $excludes[] = join(" OR ", $thisGroup);
            } else {
                $groups[] = join(" ".$search['group'][0]['bool']." ", $thisGroup);
            }
        }

        // Base 'advanced' query
        $output = "(" . join(") " . $this->searchTerms[0]['join'] . " (", $groups) . ")";
        // Concatenate exclusion after that
        if (count($excludes) > 0) {
            $output .= " NOT ((" . join(") OR (", $excludes) . "))";
        }
        
        return $output;
    }

    /**
     * Build a string for onscreen display showing the
     *   query used in the search (not the filters).
     * 
     * @access  public
     * @return  string   user friendly version of 'query'
     */
    public function displayQuery()
    {
        // Advanced search?
        if ($this->searchType == $this->advancedSearchType) {
            return $this->buildAdvancedDisplayQuery();
        }
        // Default -- Basic search:
        return $this->searchTerms[0]['lookfor'];
    }

    /**
     * Turn the list of spelling suggestions into an array of urls
     *   for on-screen use to implement the suggestions.
     *
     * @access  public
     * @return  array     Spelling suggestion data arrays
     */
    abstract public function getSpellingSuggestions();

    /**
     * Actually process and submit the search; in addition to returning results,
     * this method is responsible for populating various class properties that
     * are returned by other get methods (i.e. getFacetList).
     *
     * @access  public
     * @param   bool   $returnIndexErrors  Should we die inside the index code if
     *                                     we encounter an error (false) or return
     *                                     it for access via the getIndexError() 
     *                                     method (true)?
     * @param   bool   $recommendations    Should we process recommendations along
     *                                     with the search itself?
     * @return  object   Search results (format may vary from class to class).
     */
    abstract public function processSearch($returnIndexErrors = false, 
        $recommendations = false);

    /**
     * Get error message from index response, if any.  This will only work if 
     * processSearch was called with $returnIndexErrors set to true!
     *
     * @access  public
     * @return  mixed       false if no error, error string otherwise.
     */
    abstract public function getIndexError();
}

/**
 * ****************************************************
 * 
 * A minified search object used exclusively for trimming
 *  a search object down to it's barest minimum size
 *  before storage in a cookie or database.
 * 
 * It's still contains enough data granularity to
 *  programmatically recreate search urls.
 * 
 * This class isn't intended for general use, but simply
 *  a way of storing/retrieving data from a search object:
 * 
 * eg. Store
 * $searchHistory[] = serialize($this->minify());
 * 
 * eg. Retrieve
 * $searchObject  = SearchObjectFactory::initSearchObject();
 * $searchObject->deminify(unserialize($search));
 * 
 */
class minSO
{
    public $t = array();
    public $f = array();
    public $id, $i, $s, $r, $ty;

    /**
     * Constructor. Building minified object from the
     *    searchObject passed in. Needs to be kept
     *    up-to-date with the deminify() function on
     *    searchObject.
     *
     * @access  public
     */
    public function __construct($searchObject)
    {
        // Most values will transfer without changes
        $this->id = $searchObject->getSearchId();
        $this->i  = $searchObject->getStartTime();
        $this->s  = $searchObject->getQuerySpeed();
        $this->r  = $searchObject->getResultTotal();
        $this->ty = $searchObject->getSearchType();

        // Search terms, we'll shorten keys
        $tempTerms = $searchObject->getSearchTerms();
        foreach ($tempTerms as $term) {
            $newTerm = array();
            foreach ($term as $k => $v) {
                switch ($k) {
                  case 'join'    :  $newTerm['j'] = $v; break;
                  case 'index'   :  $newTerm['i'] = $v; break;
                  case 'lookfor' :  $newTerm['l'] = $v; break;
                  case 'group' :
                      $newTerm['g'] = array();
                      foreach ($v as $line) {
                          $search = array();
                          foreach ($line as $k2 => $v2) {
                              switch ($k2) {
                                  case 'bool'    :  $search['b'] = $v2; break;
                                  case 'field'   :  $search['f'] = $v2; break;
                                  case 'lookfor' :  $search['l'] = $v2; break;
                              }
                          }
                          $newTerm['g'][] = $search;
                      }
                      break;
                }
            }
            $this->t[] = $newTerm;
        }

        // It would be nice to shorten filter fields too, but
        //      it would be a nightmare to maintain.
        $this->f = $searchObject->getFilters();
    }
}
?>
Return current item: VuFind