From 2842047c43ea094053d09e92e5580f6e0911f70e Mon Sep 17 00:00:00 2001
From: Demian Katz <demian.katz@villanova.edu>
Date: Fri, 4 Apr 2014 10:25:49 -0400
Subject: [PATCH] More flexible range facet options. - Resolves VUFIND-919.

---
 config/vufind/Summon.ini                      |  20 +-
 config/vufind/facets.ini                      |  16 +-
 .../src/VuFind/Controller/AbstractSearch.php  | 186 +++++++++++++++---
 .../VuFind/Controller/SearchController.php    |  11 +-
 .../VuFind/Controller/SummonController.php    |  10 +-
 .../src/VuFind/Recommend/SideFacets.php       | 115 +++++++++--
 .../VuFind/src/VuFind/Search/Base/Params.php  | 185 ++++++++++++++---
 .../VuFind/src/VuFind/Search/Solr/Params.php  |  10 +
 .../src/VuFind/Search/Summon/Params.php       |  10 +
 .../templates/Recommend/SideFacets.phtml      |  16 +-
 .../templates/search/advanced/ranges.phtml    |   8 +-
 .../templates/Recommend/SideFacets.phtml      |  70 +++----
 .../templates/search/advanced/ranges.phtml    |  74 +++----
 13 files changed, 561 insertions(+), 170 deletions(-)

diff --git a/config/vufind/Summon.ini b/config/vufind/Summon.ini
index 7b7fa5f846c..7cc56c350b7 100644
--- a/config/vufind/Summon.ini
+++ b/config/vufind/Summon.ini
@@ -69,6 +69,11 @@ timeout = 30
 [SpecialFacets]
 ; Any fields listed below will be treated as date ranges rather than plain facets:
 dateRange[] = PublicationDate
+; Any fields listed below will be treated as numeric ranges rather than plain facets:
+;numericRange[] = example_field_str
+; Any fields listed below will be treated as free-form ranges rather than plain
+; facets:
+;genericRange[] = example_field_str
 
 ; This section is reserved for special boolean facets.  These are displayed
 ; as checkboxes.  If the box is checked, the filter on the left side of the
@@ -137,10 +142,17 @@ ContentType = "Format"
 ; These settings affect the way facets are displayed on the advanced screen
 [Advanced_Facet_Settings]
 facet_limit      = 100      ; how many values should we show for each facet?
-; Some special facets for advanced searching can be turned on by inclusion in
-; the comma-separated list below, or turned off by being excluded.  Currently,
-; just one values is supported: "daterange" for the publication year range
-; control.
+; The facets listed under the [Advanced] section above will be used as limiters on
+; the advanced search screen and will be displayed uniformly as multi-select boxes.
+; Some facet types don't lend themselves to this format, and they can be turned on
+; by inclusion in the comma-separated list below, or turned off by being excluded.
+; Supported values:
+; daterange - for the range controls specified by the dateRange setting under
+;      [Special_Facets] above; if multiple fields are specified above but you
+;      only want certain ones on the advanced screen, you can filter with a
+;      colon separated list; e.g. "daterange:field1:field2:field3"
+; genericrange - just like daterange above, but for genericRange[] fields.
+; numericrange - just like daterange above, but for numericRange[] fields.
 special_facets      = daterange
 ; Should we OR together facets rather than ANDing them? Set to * for
 ; all facets, use a comma-separated list to apply to some of the facets, set
diff --git a/config/vufind/facets.ini b/config/vufind/facets.ini
index 97fbb1bdf5c..ca6ad3793b0 100644
--- a/config/vufind/facets.ini
+++ b/config/vufind/facets.ini
@@ -28,6 +28,11 @@ topic_facet        = "Suggested Topics"
 [SpecialFacets]
 ; Any fields listed below will be treated as date ranges rather than plain facets:
 dateRange[] = publishDate
+; Any fields listed below will be treated as numeric ranges rather than plain facets:
+;numericRange[] = example_field_str
+; Any fields listed below will be treated as free-form ranges rather than plain
+; facets:
+;genericRange[] = example_field_str
 
 ; This section is reserved for special boolean facets.  These are displayed
 ; as checkboxes.  If the box is checked, the filter on the left side of the
@@ -84,9 +89,14 @@ orFacets = *
 ; the advanced search screen and will be displayed uniformly as multi-select boxes.
 ; Some facet types don't lend themselves to this format, and they can be turned on
 ; by inclusion in the comma-separated list below, or turned off by being excluded.
-; Currently, just two values are supported: "illustrated", for the "illustrated/not
-; illustrated" radio button limiter and "daterange" for the publication year range
-; control.
+; Supported values:
+; daterange - for the range controls specified by the dateRange setting under
+;      [Special_Facets] above; if multiple fields are specified above but you
+;      only want certain ones on the advanced screen, you can filter with a
+;      colon separated list; e.g. "daterange:field1:field2:field3"
+; genericrange - just like daterange above, but for genericRange[] fields.
+; illustrated - for the "illustrated/not illustrated" radio button limiter
+; numericrange - just like daterange above, but for numericRange[] fields.
 special_facets   = "illustrated,daterange"
 
 ; Any facets named in the list below will have their values run through the 
diff --git a/module/VuFind/src/VuFind/Controller/AbstractSearch.php b/module/VuFind/src/VuFind/Controller/AbstractSearch.php
index 970345cc1e0..bfc25aa1c8f 100644
--- a/module/VuFind/src/VuFind/Controller/AbstractSearch.php
+++ b/module/VuFind/src/VuFind/Controller/AbstractSearch.php
@@ -369,47 +369,177 @@ class AbstractSearch extends AbstractBase
     }
 
     /**
-     * Get the current settings for the date range facet, if it is set:
+     * Get the current settings for the specified range facet, if it is set:
      *
+     * @param array  $fields      Fields to check
+     * @param string $type        Type of range to include in return value
      * @param object $savedSearch Saved search object (false if none)
-     * @param string $config      Name of config file
      *
      * @return array
      */
-    protected function getDateRangeSettings($savedSearch = false, $config = 'facets')
+    protected function getRangeSettings($fields, $type, $savedSearch = false)
     {
         $parts = array();
 
-        $config = $this->getServiceLocator()->get('VuFind\Config')->get($config);
-
-        if (isset($config->SpecialFacets->dateRange)) {
-            foreach ($config->SpecialFacets->dateRange as $field) {
-                // Default to blank strings:
-                $from = $to = '';
-
-                // Check to see if there is an existing range in the search object:
-                if ($savedSearch) {
-                    $filters = $savedSearch->getParams()->getFilters();
-                    if (isset($filters[$field])) {
-                        foreach ($filters[$field] as $current) {
-                            if ($range = SolrUtils::parseRange($current)) {
-                                $from = $range['from'] == '*' ? '' : $range['from'];
-                                $to = $range['to'] == '*' ? '' : $range['to'];
-                                $savedSearch->getParams()
-                                    ->removeFilter($field . ':' . $current);
-                                break;
-                            }
+        foreach ($fields as $field) {
+            // Default to blank strings:
+            $from = $to = '';
+
+            // Check to see if there is an existing range in the search object:
+            if ($savedSearch) {
+                $filters = $savedSearch->getParams()->getFilters();
+                if (isset($filters[$field])) {
+                    foreach ($filters[$field] as $current) {
+                        if ($range = SolrUtils::parseRange($current)) {
+                            $from = $range['from'] == '*' ? '' : $range['from'];
+                            $to = $range['to'] == '*' ? '' : $range['to'];
+                            $savedSearch->getParams()
+                                ->removeFilter($field . ':' . $current);
+                            break;
                         }
                     }
                 }
-
-                // Send back the settings:
-                $parts[] = array(
-                    'field' => $field,
-                    'values' => array($from, $to)
-                );
             }
+
+            // Send back the settings:
+            $parts[] = array(
+                'field' => $field,
+                'type' => $type,
+                'values' => array($from, $to)
+            );
         }
+
         return $parts;
     }
+
+    /**
+     * Get the current settings for the date range facets, if set:
+     *
+     * @param object $savedSearch Saved search object (false if none)
+     * @param string $config      Name of config file
+     * @param array  $filter      Whitelist of fields to include (if empty, all
+     * fields will be returned)
+     *
+     * @return array
+     */
+    protected function getDateRangeSettings($savedSearch = false, $config = 'facets',
+        $filter = array()
+    ) {
+        $config = $this->getServiceLocator()->get('VuFind\Config')->get($config);
+
+        $fields = isset($config->SpecialFacets->dateRange)
+            ? $config->SpecialFacets->dateRange->toArray()
+            : array();
+
+        if (!empty($filter)) {
+            $fields = array_intersect($fields, $filter);
+        }
+
+        return $this->getRangeSettings($fields, 'date', $savedSearch);
+    }
+
+    /**
+     * Get the current settings for the generic range facets, if set:
+     *
+     * @param object $savedSearch Saved search object (false if none)
+     * @param string $config      Name of config file
+     * @param array  $filter      Whitelist of fields to include (if empty, all
+     * fields will be returned)
+     *
+     * @return array
+     */
+    protected function getGenericRangeSettings($savedSearch = false,
+        $config = 'facets', $filter = array()
+    ) {
+        $config = $this->getServiceLocator()->get('VuFind\Config')->get($config);
+
+        $fields = isset($config->SpecialFacets->genericRange)
+            ? $config->SpecialFacets->genericRange->toArray()
+            : array();
+
+        if (!empty($filter)) {
+            $fields = array_intersect($fields, $filter);
+        }
+
+        return $this->getRangeSettings($fields, 'generic', $savedSearch);
+    }
+
+    /**
+     * Get the current settings for the numeric range facets, if set:
+     *
+     * @param object $savedSearch Saved search object (false if none)
+     * @param string $config      Name of config file
+     * @param array  $filter      Whitelist of fields to include (if empty, all
+     * fields will be returned)
+     *
+     * @return array
+     */
+    protected function getNumericRangeSettings($savedSearch = false,
+        $config = 'facets', $filter = array()
+    ) {
+        $config = $this->getServiceLocator()->get('VuFind\Config')->get($config);
+
+        $fields = isset($config->SpecialFacets->numericRange)
+            ? $config->SpecialFacets->numericRange->toArray()
+            : array();
+
+        if (!empty($filter)) {
+            $fields = array_intersect($fields, $filter);
+        }
+
+        return $this->getRangeSettings($fields, 'numeric', $savedSearch);
+    }
+
+    /**
+     * Get all active range facets:
+     *
+     * @param array  $specialFacets Special facet setting (in parsed format)
+     * @param object $savedSearch   Saved search object (false if none)
+     * @param string $config        Name of config file
+     *
+     * @return array
+     */
+    protected function getAllRangeSettings($specialFacets, $savedSearch = false,
+        $config = 'facets'
+    ) {
+        $result = array();
+        if (isset($specialFacets['daterange'])) {
+            $dates = $this->getDateRangeSettings(
+                $savedSearch, $config, $specialFacets['daterange']
+            );
+            $result = array_merge($result, $dates);
+        }
+        if (isset($specialFacets['genericrange'])) {
+            $generic = $this->getGenericRangeSettings(
+                $savedSearch, $config, $specialFacets['genericrange']
+            );
+            $result = array_merge($result, $generic);
+        }
+        if (isset($specialFacets['numericrange'])) {
+            $numeric = $this->getNumericRangeSettings(
+                $savedSearch, $config, $specialFacets['numericrange']
+            );
+            $result = array_merge($result, $numeric);
+        }
+        return $result;
+    }
+
+    /**
+     * Parse the "special facets" setting.
+     *
+     * @param string $specialFacets Unparsed string
+     *
+     * @return array
+     */
+    protected function parseSpecialFacetsSetting($specialFacets)
+    {
+        // Parse the special facets into a more useful format:
+        $parsed = array();
+        foreach (explode(',', $specialFacets) as $current) {
+            $parts = explode(':', $current);
+            $key = array_shift($parts);
+            $parsed[$key] = $parts;
+        }
+        return $parsed;
+    }
 }
\ No newline at end of file
diff --git a/module/VuFind/src/VuFind/Controller/SearchController.php b/module/VuFind/src/VuFind/Controller/SearchController.php
index cf5f74e2f7c..8d867453dce 100644
--- a/module/VuFind/src/VuFind/Controller/SearchController.php
+++ b/module/VuFind/src/VuFind/Controller/SearchController.php
@@ -54,15 +54,14 @@ class SearchController extends AbstractSearch
         $view->facetList = $this->processAdvancedFacets(
             $this->getAdvancedFacets()->getFacetList(), $view->saved
         );
-        $specialFacets = $view->options->getSpecialAdvancedFacets();
-        if (stristr($specialFacets, 'illustrated')) {
+        $specialFacets = $this->parseSpecialFacetsSetting(
+            $view->options->getSpecialAdvancedFacets()
+        );
+        if (isset($specialFacets['illustrated'])) {
             $view->illustratedLimit
                 = $this->getIllustrationSettings($view->saved);
         }
-        if (stristr($specialFacets, 'daterange')) {
-            $view->ranges
-                = $this->getDateRangeSettings($view->saved);
-        }
+        $view->ranges = $this->getAllRangeSettings($specialFacets, $view->saved);
         return $view;
     }
 
diff --git a/module/VuFind/src/VuFind/Controller/SummonController.php b/module/VuFind/src/VuFind/Controller/SummonController.php
index f27eb9b650b..888d33113bf 100644
--- a/module/VuFind/src/VuFind/Controller/SummonController.php
+++ b/module/VuFind/src/VuFind/Controller/SummonController.php
@@ -98,11 +98,11 @@ class SummonController extends AbstractSearch
         $view->facetList = $this->processAdvancedFacets(
             $this->getAdvancedFacets()->getFacetList(), $view->saved
         );
-        $specialFacets = $view->options->getSpecialAdvancedFacets();
-        if (stristr($specialFacets, 'daterange')) {
-            $view->ranges
-                = $this->getDateRangeSettings($view->saved, 'Summon');
-        }
+        $specialFacets = $this->parseSpecialFacetsSetting(
+            $view->options->getSpecialAdvancedFacets()
+        );
+        $view->ranges = $this
+            ->getAllRangeSettings($specialFacets, $view->saved, 'Summon');
 
         return $view;
     }
diff --git a/module/VuFind/src/VuFind/Recommend/SideFacets.php b/module/VuFind/src/VuFind/Recommend/SideFacets.php
index dfc6661dab3..158d1256cb1 100644
--- a/module/VuFind/src/VuFind/Recommend/SideFacets.php
+++ b/module/VuFind/src/VuFind/Recommend/SideFacets.php
@@ -48,6 +48,20 @@ class SideFacets extends AbstractFacets
      */
     protected $dateFacets = array();
 
+    /**
+     * Generic range facet configuration
+     *
+     * @var array
+     */
+    protected $genericRangeFacets = array();
+
+    /**
+     * Numeric range facet configuration
+     *
+     * @var array
+     */
+    protected $numericRangeFacets = array();
+
     /**
      * Main facet configuration
      *
@@ -96,11 +110,19 @@ class SideFacets extends AbstractFacets
         // Load boolean configurations:
         $this->loadBooleanConfigs($config, array_keys($this->mainFacets));
 
-        // Get a list of fields that should be displayed as date ranges rather than
+        // Get a list of fields that should be displayed as ranges rather than
         // standard facet lists.
         if (isset($config->SpecialFacets->dateRange)) {
             $this->dateFacets = $config->SpecialFacets->dateRange->toArray();
         }
+        if (isset($config->SpecialFacets->genericRange)) {
+            $this->genericRangeFacets
+                = $config->SpecialFacets->genericRange->toArray();
+        }
+        if (isset($config->SpecialFacets->numericRange)) {
+            $this->numericRangeFacets
+                = $config->SpecialFacets->numericRange->toArray();
+        }
 
         // Checkbox facets:
         if (substr($checkboxSection, 0, 1) == '~') {
@@ -166,22 +188,54 @@ class SideFacets extends AbstractFacets
      */
     public function getDateFacets()
     {
-        $filters = $this->results->getParams()->getFilters();
-        $result = array();
-        foreach ($this->dateFacets as $current) {
-            $from = $to = '';
-            if (isset($filters[$current])) {
-                foreach ($filters[$current] as $filter) {
-                    if ($range = SolrUtils::parseRange($filter)) {
-                        $from = $range['from'] == '*' ? '' : $range['from'];
-                        $to = $range['to'] == '*' ? '' : $range['to'];
-                        break;
-                    }
-                }
+        return $this->getRangeFacets('dateFacets');
+    }
+
+    /**
+     * getGenericRangeFacets
+     *
+     * Return generic range facet information in a format processed for use in the
+     * view.
+     *
+     * @return array Array of from/to value arrays keyed by field.
+     */
+    public function getGenericRangeFacets()
+    {
+        return $this->getRangeFacets('genericRangeFacets');
+    }
+
+    /**
+     * getNumericRangeFacets
+     *
+     * Return numeric range facet information in a format processed for use in the
+     * view.
+     *
+     * @return array Array of from/to value arrays keyed by field.
+     */
+    public function getNumericRangeFacets()
+    {
+        return $this->getRangeFacets('numericRangeFacets');
+    }
+
+    /**
+     * Get combined range details.
+     *
+     * @return array
+     */
+    public function getAllRangeFacets()
+    {
+        $raw = array(
+            'date' => $this->getDateFacets(),
+            'generic' => $this->getGenericRangeFacets(),
+            'numeric' => $this->getNumericRangeFacets()
+        );
+        $processed = array();
+        foreach ($raw as $type => $values) {
+            foreach ($values as $field => $range) {
+                $processed[$field] = array('type' => $type, 'values' => $range);
             }
-            $result[$current] = array($from, $to);
         }
-        return $result;
+        return $processed;
     }
 
     /**
@@ -198,4 +252,35 @@ class SideFacets extends AbstractFacets
         }
         return array_map('trim', explode(',', $this->collapsedFacets));
     }
+
+    /**
+     * getRangeFacets
+     *
+     * Return range facet information in a format processed for use in the view.
+     *
+     * @param string $property Name of property containing active range facets
+     *
+     * @return array Array of from/to value arrays keyed by field.
+     */
+    protected function getRangeFacets($property)
+    {
+        $filters = $this->results->getParams()->getFilters();
+        $result = array();
+        if (isset($this->$property) && is_array($this->$property)) {
+            foreach ($this->$property as $current) {
+                $from = $to = '';
+                if (isset($filters[$current])) {
+                    foreach ($filters[$current] as $filter) {
+                        if ($range = SolrUtils::parseRange($filter)) {
+                            $from = $range['from'] == '*' ? '' : $range['from'];
+                            $to = $range['to'] == '*' ? '' : $range['to'];
+                            break;
+                        }
+                    }
+                }
+                $result[$current] = array($from, $to);
+            }
+        }
+        return $result;
+    }
 }
diff --git a/module/VuFind/src/VuFind/Search/Base/Params.php b/module/VuFind/src/VuFind/Search/Base/Params.php
index b945bc619ec..33c5e555379 100644
--- a/module/VuFind/src/VuFind/Search/Base/Params.php
+++ b/module/VuFind/src/VuFind/Search/Base/Params.php
@@ -28,7 +28,7 @@
 namespace VuFind\Search\Base;
 use Zend\ServiceManager\ServiceLocatorAwareInterface,
     Zend\ServiceManager\ServiceLocatorInterface;
-use VuFindSearch\Query\Query;
+use VuFindSearch\Backend\Solr\LuceneSyntaxHelper, VuFindSearch\Query\Query;
 use VuFind\Search\QueryAdapter;
 
 /**
@@ -1065,6 +1065,21 @@ class Params implements ServiceLocatorAwareInterface
         return $facets;
     }
 
+    /**
+     * Initialize all range filters.
+     *
+     * @param \Zend\StdLib\Parameters $request         Parameter object representing
+     * user request.
+     *
+     * @return void
+     */
+    protected function initRangeFilters($request)
+    {
+        $this->initDateFilters($request);
+        $this->initGenericRangeFilters($request);
+        $this->initNumericRangeFilters($request);
+    }
+
     /**
      * Support method for initDateFilters() -- normalize a year for use in a date
      * range.
@@ -1089,16 +1104,108 @@ class Params implements ServiceLocatorAwareInterface
     }
 
     /**
-     * Support method for initDateFilters() -- build a filter query based on a range
-     * of dates.
+     * Support method for initNumericRangeFilters() -- normalize a year for use in
+     * a date range.
+     *
+     * @param string $num Value to format into a number.
+     *
+     * @return string     Formatted number.
+     */
+    protected function formatValueForNumericRange($num)
+    {
+        // empty strings are always wildcards:
+        if ($num == '') {
+            return '*';
+        }
+
+        // it's a string by default so this will kick it into interpreting it as a
+        // number
+        $num = $num + 0;
+        return $num = !is_float($num) && !is_int($num) ? '*' : $num;
+    }
+
+    /**
+     * Support method for initGenericRangeFilters() -- build a filter query based on
+     * a range of values.
      *
      * @param string $field field to use for filtering.
-     * @param string $from  year for start of range.
-     * @param string $to    year for end of range.
+     * @param string $from  start of range.
+     * @param string $to    end of range.
+     * @param bool   $cs    Should ranges be case-sensitive?
      *
      * @return string       filter query.
      */
-    protected function buildDateRangeFilter($field, $from, $to)
+    protected function buildGenericRangeFilter($field, $from, $to, $cs = true)
+    {
+        // Assume Solr syntax -- this should be overridden in child classes where
+        // other indexing methodologies are used.
+        $range = "{$field}:[{$from} TO {$to}]";
+        if (!$cs) {
+            // Flip values if out of order:
+            if (strcmp(strtolower($from), strtolower($to)) > 0) {
+                $range = "{$field}:[{$to} TO {$from}]";
+            }
+            $helper = new LuceneSyntaxHelper(false, false);
+            $range = $helper->capitalizeRanges($range);
+        }
+        return $range;
+    }
+
+    /**
+     * Support method for initFilters() -- initialize range filters.  Factored
+     * out as a separate method so that it can be more easily overridden by child
+     * classes.
+     *
+     * @param \Zend\StdLib\Parameters $request         Parameter object representing
+     * user request.
+     * @param string                  $requestParam    Name of parameter containing
+     * names of range filter fields.
+     * @param Callable                $valueFilter     Optional callback to process
+     * values in the range.
+     * @param Callable                $filterGenerator Optional callback to create
+     * a filter query from the range values.
+     *
+     * @return void
+     */
+     protected function initGenericRangeFilters($request,
+        $requestParam = 'genericrange', $valueFilter = null, $filterGenerator = null
+     ) {
+         $rangeFacets = $request->get($requestParam);
+         if (!empty($rangeFacets)) {
+             $ranges = is_array($rangeFacets) ? $rangeFacets : array($rangeFacets);
+             foreach ($ranges as $range) {
+                 // Load start and end of range:
+                 $from = $request->get($range . 'from');
+                 $to = $request->get($range . 'to');
+
+                 // Apply filtering/validation if necessary:
+                 if (is_callable($valueFilter)) {
+                     $from = call_user_func($valueFilter, $from);
+                     $to = call_user_func($valueFilter, $to);
+                 }
+
+                 // Build filter only if necessary:
+                 if (!empty($range) && ($from != '*' || $to != '*')) {
+                     $rangeFacet = is_callable($filterGenerator)
+                        ? call_user_func($filterGenerator, $range, $from, $to)
+                        : $this->buildGenericRangeFilter($range, $from, $to, false);
+                     $this->addFilter($rangeFacet);
+                 }
+             }
+         }
+     }
+
+    /**
+     * Support method for initNumericRangeFilters() -- build a filter query based on
+     * a range of numbers.
+     *
+     * @param string $field field to use for filtering.
+     * @param string $from  number for start of range.
+     * @param string $to    number for end of range.
+     *
+     * @return string       filter query.
+     */
+    protected function buildNumericRangeFilter($field, $from, $to)
     {
         // Make sure that $to is less than $from:
         if ($to != '*' && $from!= '*' && $to < $from) {
@@ -1107,9 +1214,23 @@ class Params implements ServiceLocatorAwareInterface
             $from = $tmp;
         }
 
-        // Assume Solr syntax -- this should be overridden in child classes where
-        // other indexing methodologies are used.
-        return "{$field}:[{$from} TO {$to}]";
+        return $this->buildGenericRangeFilter($field, $from, $to);
+    }
+
+    /**
+     * Support method for initDateFilters() -- build a filter query based on a range
+     * of dates.
+     *
+     * @param string $field field to use for filtering.
+     * @param string $from  year for start of range.
+     * @param string $to    year for end of range.
+     *
+     * @return string       filter query.
+     */
+    protected function buildDateRangeFilter($field, $from, $to)
+    {
+        // Dates work just like numbers:
+        return $this->buildNumericRangeFilter($field, $from, $to);
     }
 
     /**
@@ -1124,26 +1245,28 @@ class Params implements ServiceLocatorAwareInterface
      */
     protected function initDateFilters($request)
     {
-        $daterange = $request->get('daterange');
-        if (!empty($daterange)) {
-            $ranges = is_array($daterange) ? $daterange : array($daterange);
-            foreach ($ranges as $range) {
-                // Validate start and end of range:
-                $yearFrom = $this->formatYearForDateRange(
-                    $request->get($range . 'from')
-                );
-                $yearTo = $this->formatYearForDateRange(
-                    $request->get($range . 'to')
-                );
-
-                // Build filter only if necessary:
-                if (!empty($range) && ($yearFrom != '*' || $yearTo != '*')) {
-                    $dateFilter
-                        = $this->buildDateRangeFilter($range, $yearFrom, $yearTo);
-                    $this->addFilter($dateFilter);
-                }
-            }
-        }
+        return $this->initGenericRangeFilters(
+            $request, 'daterange', array($this, 'formatYearForDateRange'),
+            array($this, 'buildDateRangeFilter')
+        );
+    }
+
+    /**
+     * Support method for initFilters() -- initialize numeric range filters. Factored
+     * out as a separate method so that it can be more easily overridden by child
+     * classes.
+     *
+     * @param \Zend\StdLib\Parameters $request Parameter object representing user
+     * request.
+     *
+     * @return void
+     */
+    protected function initNumericRangeFilters($request)
+    {
+        return $this->initGenericRangeFilters(
+            $request, 'numericrange', array($this, 'formatValueForNumericRange'),
+            array($this, 'buildNumericRangeFilter')
+        );
     }
 
     /**
@@ -1168,8 +1291,8 @@ class Params implements ServiceLocatorAwareInterface
             }
         }
 
-        // Handle date range filters:
-        $this->initDateFilters($request);
+        // Handle range filters:
+        $this->initRangeFilters($request);
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php
index e6c44bd8526..f4ae8fee22f 100644
--- a/module/VuFind/src/VuFind/Search/Solr/Params.php
+++ b/module/VuFind/src/VuFind/Search/Solr/Params.php
@@ -497,8 +497,18 @@ class Params extends \VuFind\Search\Base\Params
         );
 
         // Convert range queries to a language-non-specific format:
+        $caseInsensitiveRegex = '/^\(\[(.*) TO (.*)\] OR \[(.*) TO (.*)\]\)$/';
         if (preg_match('/^\[(.*) TO (.*)\]$/', $value, $matches)) {
+            // Simple case: [X TO Y]
             $filter['displayText'] = $matches[1] . '-' . $matches[2];
+        } else if (preg_match($caseInsensitiveRegex, $value, $matches)) {
+            // Case insensitive case: [x TO y] OR [X TO Y]; convert
+            // only if values in both ranges match up!
+            if (strtolower($matches[3]) == strtolower($matches[1])
+                && strtolower($matches[4]) == strtolower($matches[2])
+            ) {
+                $filter['displayText'] = $matches[1] . '-' . $matches[2];
+            }
         }
 
         return $filter;
diff --git a/module/VuFind/src/VuFind/Search/Summon/Params.php b/module/VuFind/src/VuFind/Search/Summon/Params.php
index 9cde5cc1bcb..e01d32764a7 100644
--- a/module/VuFind/src/VuFind/Search/Summon/Params.php
+++ b/module/VuFind/src/VuFind/Search/Summon/Params.php
@@ -290,8 +290,18 @@ class Params extends \VuFind\Search\Base\Params
         );
 
         // Convert range queries to a language-non-specific format:
+        $caseInsensitiveRegex = '/^\(\[(.*) TO (.*)\] OR \[(.*) TO (.*)\]\)$/';
         if (preg_match('/^\[(.*) TO (.*)\]$/', $value, $matches)) {
+            // Simple case: [X TO Y]
             $filter['displayText'] = $matches[1] . '-' . $matches[2];
+        } else if (preg_match($caseInsensitiveRegex, $value, $matches)) {
+            // Case insensitive case: [x TO y] OR [X TO Y]; convert
+            // only if values in both ranges match up!
+            if (strtolower($matches[3]) == strtolower($matches[1])
+                && strtolower($matches[4]) == strtolower($matches[2])
+            ) {
+                $filter['displayText'] = $matches[1] . '-' . $matches[2];
+            }
         }
 
         return $filter;
diff --git a/themes/blueprint/templates/Recommend/SideFacets.phtml b/themes/blueprint/templates/Recommend/SideFacets.phtml
index 6edc945ee86..91a056b0039 100644
--- a/themes/blueprint/templates/Recommend/SideFacets.phtml
+++ b/themes/blueprint/templates/Recommend/SideFacets.phtml
@@ -39,22 +39,24 @@
     </ul>
   <? endif; ?>
   <?= isset($this->sideFacetExtraControls) ? $this->sideFacetExtraControls : '' ?>
-  <? $sideFacetSet = $this->recommend->getFacetSet(); $dateFacets = $this->recommend->getDateFacets(); ?>
+  <? $sideFacetSet = $this->recommend->getFacetSet(); $rangeFacets = $this->recommend->getAllRangeFacets(); ?>
   <? if (!empty($sideFacetSet) && $results->getResultTotal() > 0): ?>
     <? foreach ($sideFacetSet as $title => $cluster): ?>
       <? $allowExclude = $this->recommend->excludeAllowed($title); ?>
-      <? if (isset($dateFacets[$title])): ?>
-        <? /* Load the publication date slider UI widget */ $this->headScript()->appendFile('pubdate_slider.js'); ?>
+      <? if (isset($rangeFacets[$title])): ?>
+        <? if ($rangeFacets[$title]['type'] == 'date'): ?>
+          <? /* Load the publication date slider UI widget */ $this->headScript()->appendFile('pubdate_slider.js'); ?>
+        <? endif; ?>
         <form action="" name="<?=$this->escapeHtml($title)?>Filter" id="<?=$this->escapeHtml($title)?>Filter">
           <?=$results->getUrlQuery()->asHiddenFields(array('page' => '/./', 'filter' => "/^{$title}:.*/"))?>
-          <input type="hidden" name="daterange[]" value="<?=$this->escapeHtml($title)?>"/>
+          <input type="hidden" name="<?=$this->escapeHtml($rangeFacets[$title]['type'])?>range[]" value="<?=$this->escapeHtml($title)?>"/>
           <fieldset class="publishDateLimit" id="<?=$this->escapeHtml($title)?>">
             <legend><?=$this->transEsc($cluster['label'])?></legend>
             <label for="<?=$this->escapeHtml($title)?>from"><?=$this->transEsc('date_from')?>:</label>
-            <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$this->escapeHtml($title)?>from" id="<?=$this->escapeHtml($title)?>from" value="<?=isset($dateFacets[$title][0])?$this->escapeHtml($dateFacets[$title][0]):''?>" />
+            <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$this->escapeHtml($title)?>from" id="<?=$this->escapeHtml($title)?>from" value="<?=isset($rangeFacets[$title]['values'][0])?$this->escapeHtml($rangeFacets[$title]['values'][0]):''?>" />
             <label for="<?=$this->escapeHtml($title)?>to"><?=$this->transEsc('date_to')?>:</label>
-            <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$this->escapeHtml($title)?>to" id="<?=$this->escapeHtml($title)?>to" value="<?=isset($dateFacets[$title][1])?$this->escapeHtml($dateFacets[$title][1]):''?>" />
-            <div id="<?=$this->escapeHtml($title)?>Slider" class="dateSlider"></div>
+            <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$this->escapeHtml($title)?>to" id="<?=$this->escapeHtml($title)?>to" value="<?=isset($rangeFacets[$title]['values'][1])?$this->escapeHtml($rangeFacets[$title]['values'][1]):''?>" />
+            <div id="<?=$this->escapeHtml($title)?>Slider" class="<?=$this->escapeHtml($rangeFacets[$title]['type'])?>Slider"></div>
             <input type="submit" value="<?=$this->transEsc('Set')?>" id="<?=$this->escapeHtml($title)?>goButton"/>
           </fieldset>
         </form>
diff --git a/themes/blueprint/templates/search/advanced/ranges.phtml b/themes/blueprint/templates/search/advanced/ranges.phtml
index ab3f6bff3e5..74d7240a09d 100644
--- a/themes/blueprint/templates/search/advanced/ranges.phtml
+++ b/themes/blueprint/templates/search/advanced/ranges.phtml
@@ -1,15 +1,17 @@
 <? if (isset($this->ranges) && !empty($this->ranges)): ?>
   <? $params = $this->searchParams($this->searchClassId); $params->activateAllFacets(); ?>
-  <? /* Load the publication date slider UI widget */ $this->headScript()->appendFile('pubdate_slider.js'); ?>
   <? foreach ($this->ranges as $current): $escField = $this->escapeHtml($current['field']); ?>
-    <input type="hidden" name="daterange[]" value="<?=$escField?>"/>
+    <? if ($current['type'] == 'date'): ?>
+      <? /* Load the publication date slider UI widget */ $this->headScript()->appendFile('pubdate_slider.js'); ?>
+    <? endif; ?>
+    <input type="hidden" name="<?=$this->escapeHtml($current['type'])?>range[]" value="<?=$escField?>"/>
     <fieldset class="publishDateLimit span-5" id="<?=$escField?>">
       <legend><?=$this->transEsc($params->getFacetLabel($current['field']))?></legend>
       <label for="<?=$escField?>from"><?=$this->transEsc('date_from')?>:</label>
       <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$escField?>from" id="<?=$escField?>from" value="<?=$this->escapeHtml($current['values'][0])?>" />
       <label for="<?=$escField?>to"><?=$this->transEsc('date_to')?>:</label>
       <input type="text" size="4" maxlength="4" class="yearbox" name="<?=$escField?>to" id="<?=$escField?>to" value="<?=$this->escapeHtml($current['values'][1])?>" />
-      <div id="<?=$escField?>Slider" class="dateSlider"></div>
+      <div id="<?=$escField?>Slider" class="<?=$this->escapeHtml($current['type'])?>Slider"></div>
     </fieldset>
   <? endforeach; ?>
 <? endif; ?>
diff --git a/themes/bootstrap/templates/Recommend/SideFacets.phtml b/themes/bootstrap/templates/Recommend/SideFacets.phtml
index 0c2b5edbdd2..f8047a75893 100644
--- a/themes/bootstrap/templates/Recommend/SideFacets.phtml
+++ b/themes/bootstrap/templates/Recommend/SideFacets.phtml
@@ -43,59 +43,63 @@
   </ul>
 <? endif; ?>
 <?= isset($this->sideFacetExtraControls) ? $this->sideFacetExtraControls : '' ?>
-<? $sideFacetSet = $this->recommend->getFacetSet(); $dateFacets = $this->recommend->getDateFacets(); ?>
+<? $sideFacetSet = $this->recommend->getFacetSet(); $rangeFacets = $this->recommend->getAllRangeFacets(); ?>
 <? if (!empty($sideFacetSet) && $results->getResultTotal() > 0): ?>
   <? foreach ($sideFacetSet as $title => $cluster): ?>
     <? $allowExclude = $this->recommend->excludeAllowed($title); ?>
     <ul class="nav nav-list collapsed <? if(!in_array($title, $collapsedFacets)): ?> open<? endif ?>">
-      <? if (isset($dateFacets[$title])): ?>
+      <? if (isset($rangeFacets[$title])): ?>
         <li class="nav-header"><?=$this->transEsc($cluster['label'])?></li>
         <li>
           <form class="form-inline text-center" action="" name="<?=$this->escapeHtml($title)?>Filter" id="<?=$this->escapeHtml($title)?>Filter">
             <?=$results->getUrlQuery()->asHiddenFields(array('page' => "/./", 'filter' => "/^{$title}:.*/"))?>
-            <input type="hidden" name="daterange[]" value="<?=$this->escapeHtml($title)?>"/>
+            <input type="hidden" name="<?=$this->escapeHtml($rangeFacets[$title]['type'])?>range[]" value="<?=$this->escapeHtml($title)?>"/>
             <div class="row-fluid">
               <label class="span6" for="<?=$this->escapeHtml($title)?>from">
                 <?=$this->transEsc('date_from')?>:<br/>
-                <input type="text" maxlength="4" class="span12" name="<?=$this->escapeHtml($title)?>from" id="<?=$this->escapeHtml($title)?>from" value="<?=isset($dateFacets[$title][0])?$dateFacets[$title][0]:''?>" />
+                <input type="text" maxlength="4" class="span12" name="<?=$this->escapeHtml($title)?>from" id="<?=$this->escapeHtml($title)?>from" value="<?=isset($rangeFacets[$title]['values'][0])?$this->escapeHtml($rangeFacets[$title]['values'][0]):''?>" />
               </label>
               <label class="span6" for="<?=$this->escapeHtml($title)?>to">
                 <?=$this->transEsc('date_to')?>:<br/>
-                <input type="text" maxlength="4" class="span12" name="<?=$this->escapeHtml($title)?>to" id="<?=$this->escapeHtml($title)?>to" value="<?=isset($dateFacets[$title][1])?$dateFacets[$title][1]:''?>" />
+                <input type="text" maxlength="4" class="span12" name="<?=$this->escapeHtml($title)?>to" id="<?=$this->escapeHtml($title)?>to" value="<?=isset($rangeFacets[$title]['values'][1])?$this->escapeHtml($rangeFacets[$title]['values'][1]):''?>" />
               </label>
             </div>
-            <div class="row-fluid"><input type="text" class="span10 hidden" id="<?=$this->escapeHtml($title)?>dateSlider"/></div>
+            <? if ($rangeFacets[$title]['type'] == 'date'): ?>
+              <div class="row-fluid"><input type="text" class="span10 hidden" id="<?=$this->escapeHtml($title)?><?=$this->escapeHtml($rangeFacets[$title]['type'])?>Slider"/></div>
+            <? endif; ?>
             <input class="btn" type="submit" value="<?=$this->transEsc('Set')?>"/>
           </form>
         </li>
-        <? $this->headScript()->appendFile('bootstrap-slider.js'); ?>
-        <?
-          $min = !empty($dateFacets[$title][0]) ? min($dateFacets[$title][0], 1400) : 1400;
-          $future = date('Y', time()+31536000);
-          $max = !empty($dateFacets[$title][1]) ? max($future, $dateFacets[$title][1]) : $future;
-          $low  = !empty($dateFacets[$title][0]) ? $dateFacets[$title][0] : $min;
-          $high = !empty($dateFacets[$title][1]) ? $dateFacets[$title][1] : $max;
-          $script = <<<JS
-          $(document).ready(function() {
-            var fillTexts = function() {
-              var v = {$this->escapeHtml($title)}dateSlider.getValue();
-              $('#{$this->escapeHtml($title)}from').val(v[0]);
-              $('#{$this->escapeHtml($title)}to').val(v[1]);
-            };
-            var {$this->escapeHtml($title)}dateSlider = $('#{$this->escapeHtml($title)}dateSlider')
-              .slider({
-                 'min':{$min},
-                 'max':{$max},
-                 'handle':"square",
-                 'tooltip':"hide",
-                 'value':[{$low},{$high}]
-              })
-              .on('slide', fillTexts)
-              .data('slider');
-          });
+        <? if ($rangeFacets[$title]['type'] == 'date'): ?>
+          <? $this->headScript()->appendFile('bootstrap-slider.js'); ?>
+          <?
+            $min = !empty($rangeFacets[$title][0]) ? min($rangeFacets[$title][0], 1400) : 1400;
+            $future = date('Y', time()+31536000);
+            $max = !empty($rangeFacets[$title][1]) ? max($future, $rangeFacets[$title][1]) : $future;
+            $low  = !empty($rangeFacets[$title][0]) ? $rangeFacets[$title][0] : $min;
+            $high = !empty($rangeFacets[$title][1]) ? $rangeFacets[$title][1] : $max;
+            $script = <<<JS
+$(document).ready(function() {
+  var fillTexts = function() {
+    var v = {$this->escapeHtml($title)}dateSlider.getValue();
+    $('#{$this->escapeHtml($title)}from').val(v[0]);
+    $('#{$this->escapeHtml($title)}to').val(v[1]);
+  };
+  var {$this->escapeHtml($title)}dateSlider = $('#{$this->escapeHtml($title)}dateSlider')
+    .slider({
+       'min':{$min},
+       'max':{$max},
+       'handle':"square",
+       'tooltip':"hide",
+       'value':[{$low},{$high}]
+    })
+    .on('slide', fillTexts)
+    .data('slider');
+});
 JS;
-        ?>
-        <?=$this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET'); ?>
+          ?>
+          <?=$this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET'); ?>
+        <? endif; ?>
       <? else: ?>
         <li class="nav-header"><?=$this->transEsc($cluster['label'])?></li>
         <? foreach ($cluster['list'] as $i=>$thisFacet): ?>
diff --git a/themes/bootstrap/templates/search/advanced/ranges.phtml b/themes/bootstrap/templates/search/advanced/ranges.phtml
index bb780f2a9b6..6dee945bbf2 100644
--- a/themes/bootstrap/templates/search/advanced/ranges.phtml
+++ b/themes/bootstrap/templates/search/advanced/ranges.phtml
@@ -3,47 +3,51 @@
   <? foreach ($this->ranges as $current): $escField = $this->escapeHtml($current['field']); ?>
     <fieldset class="span4 text-center">
       <legend class="text-left"><?=$this->transEsc($params->getFacetLabel($current['field']))?></legend>
-      <input type="hidden" name="daterange[]" value="<?=$escField?>"/>
+      <input type="hidden" name="<?=$this->escapeHtml($current['type'])?>range[]" value="<?=$escField?>"/>
       <label for="<?=$escField?>from"><?=$this->transEsc('date_from')?>:</label>
       <input type="text" maxlength="4" class="yearbox span4" name="<?=$escField?>from" id="<?=$escField?>from" value="<?=isset($current['values'][0])?$this->escapeHtml($current['values'][0]):''?>" />
       <label for="<?=$escField?>to"><?=$this->transEsc('date_to')?>:</label>
       <input type="text" maxlength="4" class="yearbox span4" name="<?=$escField?>to" id="<?=$escField?>to" value="<?=isset($current['values'][1])?$this->escapeHtml($current['values'][1]):''?>" />
-      <div class="pad"><input type="text" id="<?=$escField?>dateSlider"></div>
+      <? if ($current['type'] == 'date'): ?>
+        <div class="pad"><input type="text" id="<?=$escField?><?=$this->escapeHtml($current['type'])?>Slider"></div>
+      <? endif; ?>
     </fieldset>
-    <?
-      $this->headScript()->appendFile('bootstrap-slider.js');
-      $min = !empty($current['values'][0]) ? min($current['values'][0], 1400) : 1400;
-      $future = date('Y', time()+31536000);
-      $max = !empty($current['values'][1]) ? max($future, $current['values'][1]) : $future;
-      $low  = !empty($current['values'][0]) ? $current['values'][0] : $min;
-      $high = !empty($current['values'][1]) ? $current['values'][1] : $max;
-      $min = intval($min);
-      $max = intval($max);
-      $low = intval($low);
-      $high = intval($high);
-      $init = !empty($current['values'][0]) ? 'fillTexts()' : '';
-      $script = <<<JS
-        $(document).ready(function() {
-          var fillTexts = function() {
-            var v = {$escField}dateSlider.getValue();
-            $('#${escField}from').val(v[0]);
-            $('#${escField}to').val(v[1]);
-          };
-          var {$escField}dateSlider = $('#{$escField}dateSlider')
-            .slider({
-               'min':{$min},
-               'max':{$max},
-               'handle':"square",
-               'tooltip':"hide",
-               'value':[{$low},{$high}]
-            })
-            .on('slide', fillTexts)
-            .data('slider');
-          {$init}
-        });
+    <? if ($current['type'] == 'date'): ?>
+      <?
+        $this->headScript()->appendFile('bootstrap-slider.js');
+        $min = !empty($current['values'][0]) ? min($current['values'][0], 1400) : 1400;
+        $future = date('Y', time()+31536000);
+        $max = !empty($current['values'][1]) ? max($future, $current['values'][1]) : $future;
+        $low  = !empty($current['values'][0]) ? $current['values'][0] : $min;
+        $high = !empty($current['values'][1]) ? $current['values'][1] : $max;
+        $min = intval($min);
+        $max = intval($max);
+        $low = intval($low);
+        $high = intval($high);
+        $init = !empty($current['values'][0]) ? 'fillTexts()' : '';
+        $script = <<<JS
+          $(document).ready(function() {
+            var fillTexts = function() {
+              var v = {$escField}dateSlider.getValue();
+              $('#${escField}from').val(v[0]);
+              $('#${escField}to').val(v[1]);
+            };
+            var {$escField}dateSlider = $('#{$escField}dateSlider')
+              .slider({
+                 'min':{$min},
+                 'max':{$max},
+                 'handle':"square",
+                 'tooltip':"hide",
+                 'value':[{$low},{$high}]
+              })
+              .on('slide', fillTexts)
+              .data('slider');
+            {$init}
+          });
 JS;
-    ?>
-    <?=$this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET'); ?>
+      ?>
+      <?=$this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET'); ?>
+    <? endif; ?>
   <? endforeach; ?>
 <? endif; ?>
 </div>
-- 
GitLab