Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.28% covered (warning)
70.28%
331 / 471
60.64% covered (warning)
60.64%
57 / 94
CRAP
0.00% covered (danger)
0.00%
0 / 1
Params
70.28% covered (warning)
70.28%
331 / 471
60.64% covered (warning)
60.64%
57 / 94
1779.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQueryAdapter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setQueryAdapter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __clone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getSearchClassId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initFromRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 initShards
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 initLimit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 initPage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 initSearch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 initBasicSearch
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 setBasicSearch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 convertToAdvancedSearch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 initAdvancedSearch
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 initSort
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setLastView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initView
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
 getDefaultSort
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSort
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 getSearchHandler
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSearchType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDisplayQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseFilter
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 parseFilterAndPrefix
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getAliasesForFacetField
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 hasFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 addFilter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isAdvancedFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 removeFilter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 removeAllFilters
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 addFacet
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getFacetOperator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 addCheckboxFacet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFacetLabel
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getFacetConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetFacetConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRawFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilterList
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
 getFiltersAsQueryParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFacetValueRawDisplayText
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 translateFacetValue
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 formatFilterListEntry
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 parseOperatorAndFieldName
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getCheckboxFacetValues
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCheckboxFacets
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 getRawCheckboxFacets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatFilterArrayAsQueryParams
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 initRangeFilters
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formatYearForDateRange
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 formatDateForFullDateRange
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 formatValueForNumericRange
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 buildGenericRangeFilter
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 initGenericRangeFilters
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
60.01
 buildNumericRangeFilter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 buildDateRangeFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildFullDateRangeFilter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 initDateFilters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 initFullDateFilters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 initNumericRangeFilters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 initFilters
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
9.23
 initHiddenFilters
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getHiddenFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHiddenFiltersAsQueryParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHiddenFilter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 addHiddenFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 addHiddenFilterForField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDisplayQueryWithReplacedTerm
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getViewList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getLimitList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getSortList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 minify
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 deminify
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getSavedSearchContextParameters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setQueryIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryIDLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSelectedShards
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOverrideQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOverrideQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuery
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setQuery
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 initFacetList
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 hasDefaultsApplied
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initCheckboxFacets
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 supportsFacetFiltering
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Abstract parameters search model.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Search_Base
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @author   Ere Maijala <ere.maijala@helsinki.fi>
27 * @author   Juha Luoma <juha.luoma@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Page
30 */
31
32namespace VuFind\Search\Base;
33
34use VuFind\I18n\TranslatableString;
35use VuFind\Search\Minified;
36use VuFind\Search\QueryAdapter;
37use VuFind\Search\QueryAdapterInterface;
38use VuFind\Solr\Utils as SolrUtils;
39use VuFindSearch\Backend\Solr\LuceneSyntaxHelper;
40use VuFindSearch\Query\AbstractQuery;
41use VuFindSearch\Query\Query;
42use VuFindSearch\Query\QueryGroup;
43
44use function call_user_func;
45use function count;
46use function get_class;
47use function in_array;
48use function intval;
49use function is_array;
50use function is_callable;
51use function is_float;
52use function is_int;
53use function is_object;
54use function strlen;
55
56/**
57 * Abstract parameters search model.
58 *
59 * This abstract class defines the parameters methods for modeling a search in VuFind
60 *
61 * @category VuFind
62 * @package  Search_Base
63 * @author   Demian Katz <demian.katz@villanova.edu>
64 * @author   Ere Maijala <ere.maijala@helsinki.fi>
65 * @author   Juha Luoma <juha.luoma@helsinki.fi>
66 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
67 * @link     https://vufind.org Main Page
68 */
69class Params
70{
71    /**
72     * Internal representation of user query.
73     *
74     * @var Query
75     */
76    protected $query;
77
78    /**
79     * Page number
80     *
81     * @var int
82     */
83    protected $page = 1;
84
85    /**
86     * Sort setting
87     *
88     * @var string
89     */
90    protected $sort = null;
91
92    /**
93     * Override special RSS sort feature?
94     *
95     * @var bool
96     */
97    protected $skipRssSort = false;
98
99    /**
100     * Result limit
101     *
102     * @var int
103     */
104    protected $limit = 20;
105
106    /**
107     * Search type (basic or advanced)
108     *
109     * @var string
110     */
111    protected $searchType  = 'basic';
112
113    /**
114     * Shards
115     *
116     * @var array
117     */
118    protected $selectedShards = [];
119
120    /**
121     * View
122     *
123     * @var string
124     */
125    protected $view = null;
126
127    /**
128     * Previously-used view (loaded in from session)
129     *
130     * @var string
131     */
132    protected $lastView = null;
133
134    /**
135     * Search options
136     *
137     * @var Options
138     */
139    protected $options;
140
141    /**
142     * Main facet configuration
143     *
144     * @var array
145     */
146    protected $facetConfig = [];
147
148    /**
149     * Extra facet labels
150     *
151     * @var array
152     */
153    protected $extraFacetLabels = [];
154
155    /**
156     * Config sections to search for facet labels if no override configuration
157     * is set.
158     *
159     * @var array
160     */
161    protected $defaultFacetLabelSections = ['ExtraFacetLabels'];
162
163    /**
164     * Config sections to search for checkbox facet labels if no override
165     * configuration is set.
166     *
167     * @var array
168     */
169    protected $defaultFacetLabelCheckboxSections = [];
170
171    /**
172     * Checkbox facet configuration
173     *
174     * @var array
175     */
176    protected $checkboxFacets = [];
177
178    /**
179     * Applied filters
180     *
181     * @var array
182     */
183    protected $filterList = [];
184
185    /**
186     * Pre-assigned filters
187     *
188     * @var array
189     */
190    protected $hiddenFilters = [];
191
192    /**
193     * Facets in "OR" mode
194     *
195     * @var array
196     */
197    protected $orFacets = [];
198
199    /**
200     * Override Query
201     */
202    protected $overrideQuery = false;
203
204    /**
205     * Are default filters applied?
206     *
207     * @var bool
208     */
209    protected $defaultsApplied = false;
210
211    /**
212     * Map of facet field aliases.
213     *
214     * @var array
215     */
216    protected $facetAliases = [];
217
218    /**
219     * Search context parameters.
220     *
221     * @var array
222     */
223    protected $searchContextParameters = [];
224
225    /**
226     * Config loader
227     *
228     * @var \VuFind\Config\PluginManager
229     */
230    protected $configLoader;
231
232    /**
233     * Query adapter
234     *
235     * @var ?QueryAdapterInterface
236     */
237    protected $queryAdapter = null;
238
239    /**
240     * Default query adapter class
241     *
242     * @var string
243     */
244    protected $queryAdapterClass = QueryAdapter::class;
245
246    /**
247     * Constructor
248     *
249     * @param \VuFind\Search\Base\Options  $options      Options to use
250     * @param \VuFind\Config\PluginManager $configLoader Config loader
251     */
252    public function __construct($options, \VuFind\Config\PluginManager $configLoader)
253    {
254        $this->setOptions($options);
255
256        $this->configLoader = $configLoader;
257
258        // Make sure we have some sort of query object:
259        $this->query = new Query();
260
261        // Set up facet label settings, to be used as fallbacks if specific facets
262        // are not already configured:
263        $config = $configLoader->get($options->getFacetsIni());
264        $sections = $config->FacetLabels->labelSections
265            ?? $this->defaultFacetLabelSections;
266        foreach ($sections as $section) {
267            foreach ($config->$section ?? [] as $field => $label) {
268                $this->extraFacetLabels[$field] = $label;
269            }
270        }
271
272        // Activate all relevant checkboxes, also important for labeling:
273        $checkboxSections = $config->FacetLabels->checkboxSections
274            ?? $this->defaultFacetLabelCheckboxSections;
275        foreach ($checkboxSections as $checkboxSection) {
276            $this->initCheckboxFacets($checkboxSection);
277        }
278    }
279
280    /**
281     * Get the search options object.
282     *
283     * @return \VuFind\Search\Base\Options
284     */
285    public function getOptions()
286    {
287        return $this->options;
288    }
289
290    /**
291     * Set the search options object.
292     *
293     * @param \VuFind\Search\Base\Options $options Options to use
294     *
295     * @return void
296     */
297    public function setOptions(Options $options)
298    {
299        $this->options = $options;
300    }
301
302    /**
303     * Get query adapter
304     *
305     * @return QueryAdapterInterface
306     */
307    public function getQueryAdapter(): QueryAdapterInterface
308    {
309        if (null === $this->queryAdapter) {
310            $this->queryAdapter = new ($this->queryAdapterClass)();
311        }
312        return $this->queryAdapter;
313    }
314
315    /**
316     * Set query adapter
317     *
318     * @param QueryAdapterInterface $queryAdapter Query adapter
319     *
320     * @return void
321     */
322    public function setQueryAdapter(QueryAdapterInterface $queryAdapter)
323    {
324        $this->queryAdapter = $queryAdapter;
325    }
326
327    /**
328     * Copy constructor
329     *
330     * @return void
331     */
332    public function __clone()
333    {
334        if (is_object($this->options)) {
335            $this->options = clone $this->options;
336        }
337        if (is_object($this->query)) {
338            $this->query = clone $this->query;
339        }
340    }
341
342    /**
343     * Get the identifier used for naming the various search classes in this family.
344     *
345     * @return string
346     */
347    public function getSearchClassId()
348    {
349        return $this->getOptions()->getSearchClassId();
350    }
351
352    /**
353     * Pull the search parameters
354     *
355     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
356     * request.
357     *
358     * @return void
359     */
360    public function initFromRequest($request)
361    {
362        // We should init view first, since RSS view may cause certain variant
363        // behaviors:
364        $this->initView($request);
365        $this->initLimit($request);
366        $this->initPage($request);
367        $this->initShards($request);
368        // We have to initialize sort after search, since the search options may
369        // affect the default sort option.
370        $this->initSearch($request);
371        $this->initSort($request);
372        $this->initFilters($request);
373        $this->initHiddenFilters($request);
374    }
375
376    /**
377     * Pull shard parameters from the request or set defaults
378     *
379     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
380     * request.
381     *
382     * @return void
383     */
384    protected function initShards($request)
385    {
386        $legalShards = array_keys($this->getOptions()->getShards());
387        $requestShards = $request->get('shard', []);
388        if (!is_array($requestShards)) {
389            $requestShards = [$requestShards];
390        }
391
392        // If a shard selection list is found as an incoming parameter,
393        // we should save valid values for future reference:
394        foreach ($requestShards as $current) {
395            if (in_array($current, $legalShards)) {
396                $this->selectedShards[] = $current;
397            }
398        }
399
400        // If we got this far and still have no selections established, revert to
401        // defaults:
402        if (empty($this->selectedShards)) {
403            $this->selectedShards = $this->getOptions()->getDefaultSelectedShards();
404        }
405    }
406
407    /**
408     * Pull the page size parameter or set to default
409     *
410     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
411     * request.
412     *
413     * @return void
414     */
415    protected function initLimit($request)
416    {
417        // Check for a limit parameter in the url.
418        $defaultLimit = $this->getOptions()->getDefaultLimit();
419        if (($limit = intval($request->get('limit'))) != $defaultLimit) {
420            // make sure the url parameter is a valid limit -- either
421            // one of the explicitly allowed values, or at least smaller
422            // than the largest allowed. (This leniency is useful in
423            // combination with combined search, where it is often useful
424            // to reduce the size of result lists without actually enabling
425            // the user's ability to select a reduced list size).
426            $legalOptions = $this->getOptions()->getLimitOptions();
427            if (
428                in_array($limit, $legalOptions)
429                || ($limit > 0 && $limit < max($legalOptions))
430            ) {
431                $this->limit = $limit;
432                return;
433            }
434        }
435
436        // Increase default limit for RSS mode:
437        if ($this->getView() == 'rss' && $defaultLimit < 50) {
438            $defaultLimit = 50;
439        }
440
441        // If we got this far, setting was missing or invalid; load the default
442        $this->limit = $defaultLimit;
443    }
444
445    /**
446     * Pull the page parameter
447     *
448     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
449     * request.
450     *
451     * @return void
452     */
453    protected function initPage($request)
454    {
455        $this->page = intval($request->get('page'));
456        if ($this->page < 1) {
457            $this->page = 1;
458        }
459    }
460
461    /**
462     * Initialize the object's search settings from a request object.
463     *
464     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
465     * request.
466     *
467     * @return void
468     */
469    protected function initSearch($request)
470    {
471        // Try to initialize a basic search; if that fails, try for an advanced
472        // search next!
473        if (!$this->initBasicSearch($request)) {
474            $this->initAdvancedSearch($request);
475        }
476    }
477
478    /**
479     * Support method for initSearch() -- handle basic settings.
480     *
481     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
482     * request.
483     *
484     * @return bool True if search settings were found, false if not.
485     */
486    protected function initBasicSearch($request)
487    {
488        // If no lookfor parameter was found, we have no search terms to
489        // add to our array!
490        if (null === ($lookfor = $request->get('lookfor'))) {
491            return false;
492        }
493
494        // If lookfor is an array, we may be dealing with a legacy Advanced
495        // Search URL. If there's only one parameter, we can flatten it,
496        // but otherwise we should treat it as an error -- no point in going
497        // to great lengths for compatibility.
498        if (is_array($lookfor)) {
499            if (count($lookfor) > 1) {
500                throw new \Exception('Unsupported search URL.');
501            }
502            $lookfor = $lookfor[0];
503        }
504
505        // Flatten type arrays for backward compatibility:
506        $handler = $request->get('type');
507        if (is_array($handler)) {
508            $handler = $handler[0];
509        }
510
511        // Set the search:
512        $this->setBasicSearch($lookfor, $handler);
513        return true;
514    }
515
516    /**
517     * Set a basic search query:
518     *
519     * @param string $lookfor The search query
520     * @param string $handler The search handler (null for default)
521     *
522     * @return void
523     */
524    public function setBasicSearch($lookfor, $handler = null)
525    {
526        $this->searchType = 'basic';
527
528        if (empty($handler)) {
529            $handler = $this->getOptions()->getDefaultHandler();
530        }
531
532        $this->query = new Query($lookfor, $handler);
533    }
534
535    /**
536     * Convert a basic query into an advanced query:
537     *
538     * @return void
539     */
540    public function convertToAdvancedSearch()
541    {
542        if ($this->searchType === 'basic') {
543            $this->query = new QueryGroup(
544                'AND',
545                [new QueryGroup('AND', [$this->query])]
546            );
547            $this->searchType = 'advanced';
548        }
549        if ($this->searchType !== 'advanced') {
550            throw new \Exception(
551                'Unsupported search type: ' . $this->searchType
552            );
553        }
554    }
555
556    /**
557     * Support method for initSearch() -- handle advanced settings. Advanced
558     * searches have numeric subscripts on the lookfor and type parameters --
559     * this is how they are distinguished from basic searches.
560     *
561     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
562     * request.
563     *
564     * @return void
565     */
566    protected function initAdvancedSearch($request)
567    {
568        $this->query = $this->getQueryAdapter()->fromRequest(
569            $request,
570            $this->getOptions()->getDefaultHandler()
571        );
572
573        $this->searchType = $this->query instanceof Query ? 'basic' : 'advanced';
574
575        // If we ended up with a basic search, it's probably the result of
576        // submitting an empty form, and more processing may be needed:
577        if ($this->searchType == 'basic') {
578            // Set a default handler if necessary:
579            if ($this->query->getHandler() === null) {
580                $this->query->setHandler($this->getOptions()->getDefaultHandler());
581            }
582            // If the user submitted the advanced search form, we want to treat
583            // the search as advanced even if it evaluated to a basic search.
584            if ($request->offsetExists('lookfor0')) {
585                $this->convertToAdvancedSearch();
586            }
587        }
588    }
589
590    /**
591     * Get the value for which type of sorting to use
592     *
593     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
594     * request.
595     *
596     * @return void
597     */
598    protected function initSort($request)
599    {
600        // Check for special parameter only relevant in RSS mode:
601        if ($request->get('skip_rss_sort', 'unset') != 'unset') {
602            $this->skipRssSort = true;
603        }
604        $this->setSort($request->get('sort'));
605    }
606
607    /**
608     * Set the last value of the view parameter (if available in session).
609     *
610     * @param string $view Last valid view parameter value
611     *
612     * @return void
613     */
614    public function setLastView($view)
615    {
616        $this->lastView = $view;
617    }
618
619    /**
620     * Get the value for which results view to use
621     *
622     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
623     * request.
624     *
625     * @return void
626     */
627    protected function initView($request)
628    {
629        // Check for a view parameter in the url.
630        $view = $request->get('view');
631        $validViews = array_keys($this->getOptions()->getViewOptions());
632        if ($view == 'rss') {
633            // RSS is a special case that does not require config validation
634            $this->setView('rss');
635        } elseif (!empty($view) && in_array($view, $validViews)) {
636            // make sure the url parameter is a valid view
637            $this->setView($view);
638        } elseif (
639            !empty($this->lastView)
640            && in_array($this->lastView, $validViews)
641        ) {
642            // if there is nothing in the URL, see if we had a previous value
643            // injected based on session information.
644            $this->setView($this->lastView);
645        } else {
646            // otherwise load the default
647            $this->setView($this->getOptions()->getDefaultView());
648        }
649    }
650
651    /**
652     * Return the default sorting value
653     *
654     * @return string
655     */
656    public function getDefaultSort()
657    {
658        return $this->getOptions()
659            ->getDefaultSortByHandler($this->getSearchHandler());
660    }
661
662    /**
663     * Return the current limit value
664     *
665     * @return int
666     */
667    public function getLimit()
668    {
669        return $this->limit;
670    }
671
672    /**
673     * Change the value of the limit
674     *
675     * @param int $l New limit value.
676     *
677     * @return void
678     */
679    public function setLimit($l)
680    {
681        $this->limit = $l;
682    }
683
684    /**
685     * Change the page
686     *
687     * @param int $p New page value.
688     *
689     * @return void
690     */
691    public function setPage($p)
692    {
693        $this->page = $p;
694    }
695
696    /**
697     * Get the page value
698     *
699     * @return int
700     */
701    public function getPage()
702    {
703        return $this->page;
704    }
705
706    /**
707     * Return the sorting value
708     *
709     * @return string
710     */
711    public function getSort()
712    {
713        return $this->sort;
714    }
715
716    /**
717     * Set the sorting value (note: sort will be set to default if an illegal
718     * or empty value is passed in).
719     *
720     * @param string $sort  New sort value (null for default)
721     * @param bool   $force Set sort value without validating it?
722     *
723     * @return void
724     */
725    public function setSort($sort, $force = false)
726    {
727        // Skip validation if requested:
728        if ($force) {
729            $this->sort = $sort;
730            return;
731        }
732
733        // Validate and assign the sort value:
734        $valid = array_keys($this->getOptions()->getSortOptions());
735
736        $matchedHiddenPatterns = array_filter(
737            $this->getOptions()->getHiddenSortOptions(),
738            function ($pattern) use ($sort) {
739                return preg_match('/' . $pattern . '/', $sort);
740            }
741        );
742
743        if (!empty($sort) && (in_array($sort, $valid) || count($matchedHiddenPatterns) > 0)) {
744            $this->sort = $sort;
745        } else {
746            $this->sort = $this->getDefaultSort();
747        }
748
749        // In RSS mode, we may want to adjust sort settings:
750        if (!$this->skipRssSort && $this->getView() == 'rss') {
751            $this->sort = $this->getOptions()->getRssSort($this->sort);
752        }
753    }
754
755    /**
756     * Return the selected search handler (null for complex searches which have no
757     * single handler)
758     *
759     * @return string|null
760     */
761    public function getSearchHandler()
762    {
763        // We can only definitively name a handler if we have a basic search:
764        $q = $this->getQuery();
765        return $q instanceof Query ? $q->getHandler() : null;
766    }
767
768    /**
769     * Return the search type (i.e. basic or advanced)
770     *
771     * @return string
772     */
773    public function getSearchType()
774    {
775        return $this->searchType;
776    }
777
778    /**
779     * Return the value for which search view we use
780     *
781     * @return string
782     */
783    public function getView()
784    {
785        return $this->view ?? $this->getOptions()->getDefaultView();
786    }
787
788    /**
789     * Set the value for which search view we use
790     *
791     * @param String $v New view setting
792     *
793     * @return void
794     */
795    public function setView($v)
796    {
797        $this->view = $v;
798    }
799
800    /**
801     * Build a string for onscreen display showing the
802     *   query used in the search (not the filters).
803     *
804     * @return string user friendly version of 'query'
805     */
806    public function getDisplayQuery()
807    {
808        // Set up callbacks:
809        $translate = [$this, 'translate'];
810        $showField = [$this->getOptions(), 'getHumanReadableFieldName'];
811
812        // Build display query:
813        return $this->getQueryAdapter()->display($this->getQuery(), $translate, $showField);
814    }
815
816    /**
817     * Parse apart the field and value from a URL filter string.
818     *
819     * @param string $filter A filter string from url : "field:value"
820     *
821     * @return array         Array with elements 0 = field, 1 = value.
822     */
823    public function parseFilter($filter)
824    {
825        // Special case: complex filters cannot be split into field/value
826        // since they have multiple parts (e.g. field1:a OR field2:b). Use
827        // a fake "#" field to collect these types of filters.
828        if ($this->isAdvancedFilter($filter) == true) {
829            return ['#', $filter];
830        }
831
832        // Split the string and assign the parts to $field and $value
833        $temp = explode(':', $filter, 2);
834        $field = array_shift($temp);
835        $value = count($temp) > 0 ? $temp[0] : '';
836
837        // Remove quotes from the value if there are any
838        if (str_starts_with($value, '"')) {
839            $value = substr($value, 1);
840        }
841        if (str_ends_with($value, '"')) {
842            $value = substr($value, 0, -1);
843        }
844        // One last little clean on whitespace
845        $value = trim($value);
846
847        // Send back the results:
848        return [$field, $value];
849    }
850
851    /**
852     * Parse apart any prefix, field and value from a URL filter string.
853     *
854     * @param string $filter A filter string from url : "field:value"
855     *
856     * @return array         Array with elements 0 = prefix, 1 = field, 2 = value.
857     */
858    public function parseFilterAndPrefix($filter)
859    {
860        [$field, $value] = $this->parseFilter($filter);
861        $prefix = substr($field, 0, 1);
862        if (in_array($prefix, ['-', '~'])) {
863            $field = substr($field, 1);
864        } else {
865            $prefix = '';
866        }
867        return [$prefix, $field, $value];
868    }
869
870    /**
871     * Given a facet field, return an array containing all aliases of that
872     * field.
873     *
874     * @param string $field Field to look up
875     *
876     * @return array
877     */
878    public function getAliasesForFacetField($field)
879    {
880        // Account for field prefixes used for Boolean logic:
881        $prefix = substr($field, 0, 1);
882        if ($prefix === '-' || $prefix === '~') {
883            $rawField = substr($field, 1);
884        } else {
885            $prefix = '';
886            $rawField = $field;
887        }
888        $fieldsToCheck = [$field];
889        foreach ($this->facetAliases as $k => $v) {
890            if ($v === $rawField) {
891                $fieldsToCheck[] = $prefix . $k;
892            }
893        }
894        return $fieldsToCheck;
895    }
896
897    /**
898     * Does the object already contain the specified filter?
899     *
900     * @param string $filter A filter string from url : "field:value"
901     *
902     * @return bool
903     */
904    public function hasFilter($filter)
905    {
906        // Extract field and value from URL string:
907        [$field, $value] = $this->parseFilter($filter);
908
909        // Check all of the relevant fields for matches:
910        foreach ($this->getAliasesForFacetField($field) as $current) {
911            if (
912                isset($this->filterList[$current])
913                && in_array($value, $this->filterList[$current])
914            ) {
915                return true;
916            }
917        }
918        return false;
919    }
920
921    /**
922     * Take a filter string and add it into the protected
923     *   array checking for duplicates.
924     *
925     * @param string $newFilter A filter string from url : "field:value"
926     *
927     * @return void
928     */
929    public function addFilter($newFilter)
930    {
931        // Check for duplicates -- if it's not in the array, we can add it
932        if (!$this->hasFilter($newFilter)) {
933            // Extract field and value from filter string:
934            [$field, $value] = $this->parseFilter($newFilter);
935            $this->filterList[$field][] = $value;
936        }
937    }
938
939    /**
940     * Detects if a filter is advanced (true) or simple (false). An advanced
941     * filter is currently defined as one surrounded by parentheses (possibly
942     * with a leading exclusion operator), while a simple filter is of the form
943     * field:value. Advanced filters are used to express more complex queries,
944     * such as combining multiple values from multiple fields using boolean
945     * operators.
946     *
947     * @param string $filter A filter string
948     *
949     * @return bool
950     */
951    public function isAdvancedFilter($filter)
952    {
953        return str_starts_with($filter, '(') || str_starts_with($filter, '-(');
954    }
955
956    /**
957     * Remove a filter from the list.
958     *
959     * @param string $oldFilter A filter string from url : "field:value"
960     *
961     * @return void
962     */
963    public function removeFilter($oldFilter)
964    {
965        // Extract field and value from URL string:
966        [$field, $value] = $this->parseFilter($oldFilter);
967
968        // Make sure the field exists
969        if (isset($this->filterList[$field])) {
970            // Assume by default that we will not need to rebuild the array:
971            $rebuildArray = false;
972
973            // Loop through all filters on the field
974            foreach ($this->filterList[$field] as $i => $currentFilter) {
975                // Does it contain the value we don't want?
976                if ($currentFilter == $value) {
977                    // If so remove it.
978                    unset($this->filterList[$field][$i]);
979
980                    // Flag that we now need to rebuild the array:
981                    $rebuildArray = true;
982                }
983            }
984
985            // If necessary, rebuild the array to remove gaps in the key sequence:
986            if ($rebuildArray) {
987                $this->filterList[$field] = array_values($this->filterList[$field]);
988                if (!$this->filterList[$field]) {
989                    unset($this->filterList[$field]);
990                }
991            }
992        }
993    }
994
995    /**
996     * Remove all filters from the list.
997     *
998     * @param string $field Name of field to remove filters from (null to remove
999     * all filters from all fields)
1000     *
1001     * @return void
1002     */
1003    public function removeAllFilters($field = null)
1004    {
1005        if ($field == null) {
1006            $this->filterList = [];
1007        } else {
1008            foreach (['', '-', '~'] as $prefix) {
1009                if (isset($this->filterList[$prefix . $field])) {
1010                    unset($this->filterList[$prefix . $field]);
1011                }
1012            }
1013        }
1014    }
1015
1016    /**
1017     * Add a field to facet on.
1018     *
1019     * @param string $newField Field name
1020     * @param string $newAlias Optional on-screen display label
1021     * @param bool   $ored     Should we treat this as an ORed facet?
1022     *
1023     * @return void
1024     */
1025    public function addFacet($newField, $newAlias = null, $ored = false)
1026    {
1027        if ($newAlias == null) {
1028            $newAlias = $newField;
1029        }
1030        $this->facetConfig[$newField] = $newAlias;
1031        if ($ored) {
1032            $this->orFacets[] = $newField;
1033        }
1034    }
1035
1036    /**
1037     * Get facet operator for the specified field
1038     *
1039     * @param string $field Field name
1040     *
1041     * @return string
1042     */
1043    public function getFacetOperator($field)
1044    {
1045        return in_array($field, $this->orFacets) ? 'OR' : 'AND';
1046    }
1047
1048    /**
1049     * Add a checkbox facet. When the checkbox is checked, the specified filter
1050     * will be applied to the search. When the checkbox is not checked, no filter
1051     * will be applied.
1052     *
1053     * @param string $filter  [field]:[value] pair to associate with checkbox
1054     * @param string $desc    Description to associate with the checkbox
1055     * @param bool   $dynamic Is this being added dynamically (true) or in response
1056     * to a user configuration (false)?
1057     *
1058     * @return void
1059     */
1060    public function addCheckboxFacet($filter, $desc, $dynamic = false)
1061    {
1062        // Extract the facet field name from the filter, then add the
1063        // relevant information to the array.
1064        [$fieldName] = explode(':', $filter);
1065        $this->checkboxFacets[$fieldName][$filter]
1066            = compact('desc', 'filter', 'dynamic');
1067    }
1068
1069    /**
1070     * Get a user-friendly string to describe the provided facet field.
1071     *
1072     * @param string $field   Facet field name.
1073     * @param string $value   Facet value.
1074     * @param string $default Default field name (null for default behavior).
1075     *
1076     * @return string         Human-readable description of field.
1077     */
1078    public function getFacetLabel($field, $value = null, $default = null)
1079    {
1080        if (
1081            !isset($this->facetConfig[$field])
1082            && !isset($this->extraFacetLabels[$field])
1083            && isset($this->facetAliases[$field])
1084        ) {
1085            $field = $this->facetAliases[$field];
1086        }
1087        $checkboxFacet = $this->checkboxFacets[$field]["$field:$value"] ?? null;
1088        if (null !== $checkboxFacet) {
1089            return $checkboxFacet['desc'];
1090        }
1091        if (isset($this->facetConfig[$field])) {
1092            return $this->facetConfig[$field];
1093        }
1094        return $this->extraFacetLabels[$field]
1095            ?? ($default ?: 'unrecognized_facet_label');
1096    }
1097
1098    /**
1099     * Get the current facet configuration.
1100     *
1101     * @return array
1102     */
1103    public function getFacetConfig()
1104    {
1105        return $this->facetConfig;
1106    }
1107
1108    /**
1109     * Reset the current facet configuration.
1110     *
1111     * @return void
1112     */
1113    public function resetFacetConfig()
1114    {
1115        $this->facetConfig = [];
1116    }
1117
1118    /**
1119     * Get the raw filter list.
1120     *
1121     * @return array
1122     */
1123    public function getRawFilters()
1124    {
1125        return $this->filterList;
1126    }
1127
1128    /**
1129     * Return an array structure containing information about all current filters.
1130     *
1131     * @param bool $excludeCheckboxFilters Should we exclude checkbox filters from
1132     * the list (to be used as a complement to getCheckboxFacets()).
1133     *
1134     * @return array                       Field, values and translation status
1135     */
1136    public function getFilterList($excludeCheckboxFilters = false)
1137    {
1138        // If we don't have any filters, return right away to avoid further
1139        // processing:
1140        if (!$this->filterList) {
1141            return [];
1142        }
1143
1144        // Get a list of checkbox filters to skip if necessary:
1145        $skipList = $excludeCheckboxFilters
1146            ? $this->getCheckboxFacetValues() : [];
1147
1148        $list = [];
1149        $translatedFacets = $this->getOptions()->getTranslatedFacets();
1150        // Loop through all the current filter fields
1151        foreach ($this->filterList as $field => $values) {
1152            [$operator, $field] = $this->parseOperatorAndFieldName($field);
1153            $translate = in_array($field, $translatedFacets);
1154            // and each value currently used for that field
1155            foreach ($values as $value) {
1156                // Add to the list unless it's in the list of fields to skip:
1157                if (
1158                    !isset($skipList[$field])
1159                    || !in_array($value, $skipList[$field])
1160                ) {
1161                    $facetLabel = $this->getFacetLabel($field, $value);
1162                    $list[$facetLabel][] = $this->formatFilterListEntry(
1163                        $field,
1164                        $value,
1165                        $operator,
1166                        $translate
1167                    );
1168                }
1169            }
1170        }
1171        return $list;
1172    }
1173
1174    /**
1175     * Get the filter list as a query parameter array.
1176     *
1177     * Returns an array of strings that parseFilter can parse.
1178     *
1179     * @return array
1180     */
1181    public function getFiltersAsQueryParams(): array
1182    {
1183        return $this->formatFilterArrayAsQueryParams($this->getRawFilters());
1184    }
1185
1186    /**
1187     * Get a display text for a facet field.
1188     *
1189     * @param string $field Facet field
1190     * @param string $value Facet value
1191     *
1192     * @return string
1193     */
1194    public function getFacetValueRawDisplayText(string $field, string $value): string
1195    {
1196        // Check for delimited facets -- if $field is a delimited facet field,
1197        // process $displayText accordingly:
1198        $delimitedFacetFields = $this->getOptions()->getDelimitedFacets(true);
1199        if (isset($delimitedFacetFields[$field])) {
1200            $parts = explode($delimitedFacetFields[$field], $value);
1201            return end($parts);
1202        }
1203
1204        return $value;
1205    }
1206
1207    /**
1208     * Translate a facet value.
1209     *
1210     * @param string                    $field Field name
1211     * @param string|TranslatableString $text  Field value (processed by
1212     * getFacetValueRawDisplayText)
1213     *
1214     * @return string
1215     */
1216    public function translateFacetValue(string $field, $text): string
1217    {
1218        $domain = $this->getOptions()->getTextDomainForTranslatedFacet($field);
1219        $translateFormat = $this->getOptions()->getFormatForTranslatedFacet($field);
1220        $translated = $this->translate([$domain, $text]);
1221        return $translateFormat
1222            ? $this->translate(
1223                $translateFormat,
1224                [
1225                    '%%raw%%' => $text,
1226                    '%%translated%%' => $translated,
1227                ]
1228            ) : $translated;
1229    }
1230
1231    /**
1232     * Format a single filter for use in getFilterList().
1233     *
1234     * @param string $field     Field name
1235     * @param string $value     Field value
1236     * @param string $operator  Operator (AND/OR/NOT)
1237     * @param bool   $translate Should we translate the label?
1238     *
1239     * @return array
1240     */
1241    protected function formatFilterListEntry($field, $value, $operator, $translate)
1242    {
1243        $rawDisplayText = $this->getFacetValueRawDisplayText($field, $value);
1244        $displayText = $translate
1245            ? $this->translateFacetValue($field, $rawDisplayText)
1246            : $rawDisplayText;
1247
1248        return compact('value', 'displayText', 'field', 'operator');
1249    }
1250
1251    /**
1252     * Parse the operator and field name from a prefixed field string.
1253     *
1254     * @param string $field Prefixed string
1255     *
1256     * @return array (0 = operator, 1 = field name)
1257     */
1258    protected function parseOperatorAndFieldName($field)
1259    {
1260        $firstChar = substr($field, 0, 1);
1261        if ($firstChar == '-') {
1262            $operator = 'NOT';
1263            $field = substr($field, 1);
1264        } elseif ($firstChar == '~') {
1265            $operator = 'OR';
1266            $field = substr($field, 1);
1267        } else {
1268            $operator = 'AND';
1269        }
1270        return [$operator, $field];
1271    }
1272
1273    /**
1274     * Get a formatted list of checkbox filter values ($field => array of values).
1275     *
1276     * @return array
1277     */
1278    protected function getCheckboxFacetValues()
1279    {
1280        $list = [];
1281        foreach ($this->getRawCheckboxFacets() as $facets) {
1282            foreach ($facets as $current) {
1283                [$field, $value] = $this->parseFilter($current['filter']);
1284                if (!isset($list[$field])) {
1285                    $list[$field] = [];
1286                }
1287                $list[$field][] = $value;
1288            }
1289        }
1290        return $list;
1291    }
1292
1293    /**
1294     * Get information on the current state of the boolean checkbox facets.
1295     *
1296     * @param array $include        List of checkbox filters to return (null for all)
1297     * @param bool  $includeDynamic Should we include dynamically-generated
1298     * checkboxes that are not part of the include list above?
1299     *
1300     * @return array
1301     */
1302    public function getCheckboxFacets(
1303        array $include = null,
1304        bool $includeDynamic = true
1305    ) {
1306        // Build up an array of checkbox facets with status booleans and
1307        // toggle URLs.
1308        $result = [];
1309        foreach ($this->getRawCheckboxFacets() as $facets) {
1310            foreach ($facets as $facet) {
1311                // If the current filter is not on the include list, skip it (but
1312                // accept everything if the include list is null).
1313                if (
1314                    ($include !== null && !in_array($facet['filter'], $include))
1315                    && !($includeDynamic && $facet['dynamic'])
1316                ) {
1317                    continue;
1318                }
1319                $facet['selected'] = $this->hasFilter($facet['filter']);
1320                // Is this checkbox always visible, even if non-selected on the
1321                // "no results" screen?  By default, no (may be overridden by
1322                // child classes).
1323                $facet['alwaysVisible'] = false;
1324                $result[] = $facet;
1325            }
1326        }
1327        return $result;
1328    }
1329
1330    /**
1331     * Return checkbox facets without any processing
1332     *
1333     * @return array
1334     */
1335    protected function getRawCheckboxFacets(): array
1336    {
1337        return $this->checkboxFacets;
1338    }
1339
1340    /**
1341     * Format a raw filter array as a query parameter array.
1342     *
1343     * Returns an array of strings that parseFilter can parse.
1344     *
1345     * @param array $filterArray Filter array
1346     *
1347     * @return array
1348     */
1349    protected function formatFilterArrayAsQueryParams(array $filterArray): array
1350    {
1351        $result = [];
1352        foreach ($filterArray as $field => $values) {
1353            foreach ($values as $current) {
1354                $result[] = "$field:\"$current\"";
1355            }
1356        }
1357        return $result;
1358    }
1359
1360    /**
1361     * Initialize all range filters.
1362     *
1363     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1364     * request.
1365     *
1366     * @return void
1367     */
1368    protected function initRangeFilters($request)
1369    {
1370        $this->initDateFilters($request);
1371        $this->initFullDateFilters($request);
1372        $this->initGenericRangeFilters($request);
1373        $this->initNumericRangeFilters($request);
1374    }
1375
1376    /**
1377     * Support method for initDateFilters() -- normalize a year for use in a
1378     * year-based date range.
1379     *
1380     * @param ?string $year     Value to check for valid year.
1381     * @param bool    $rangeEnd Is this the end of a range?
1382     *
1383     * @return string      Formatted year.
1384     *
1385     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1386     */
1387    protected function formatYearForDateRange($year, $rangeEnd = false)
1388    {
1389        // Make sure parameter is set and numeric; default to wildcard otherwise:
1390        $year = ($year && preg_match('/\d{2,4}/', $year)) ? $year : '*';
1391
1392        // Pad to four digits:
1393        if (strlen($year) == 2) {
1394            $year = '19' . $year;
1395        } elseif (strlen($year) == 3) {
1396            $year = '0' . $year;
1397        }
1398
1399        return $year;
1400    }
1401
1402    /**
1403     * Support method for initFullDateFilters() -- normalize a date for use in a
1404     * year/month/day date range.
1405     *
1406     * @param ?string $date     Value to check for valid date.
1407     * @param bool    $rangeEnd Is this the end of a range?
1408     *
1409     * @return string      Formatted date.
1410     */
1411    protected function formatDateForFullDateRange($date, $rangeEnd = false)
1412    {
1413        // Make sure date is valid; default to wildcard otherwise:
1414        $date = $date ? SolrUtils::sanitizeDate($date, $rangeEnd) : null;
1415        return $date ?? '*';
1416    }
1417
1418    /**
1419     * Support method for initNumericRangeFilters() -- normalize a year for use in
1420     * a date range.
1421     *
1422     * @param ?string $num      Value to format into a number.
1423     * @param bool    $rangeEnd Is this the end of a range?
1424     *
1425     * @return string     Formatted number.
1426     *
1427     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1428     */
1429    protected function formatValueForNumericRange($num, $rangeEnd = false)
1430    {
1431        // empty strings are always wildcards:
1432        if ($num == '') {
1433            return '*';
1434        }
1435
1436        // it's a string by default so this will kick it into interpreting it as a
1437        // number
1438        $num = $num + 0;
1439        return $num = !is_float($num) && !is_int($num) ? '*' : $num;
1440    }
1441
1442    /**
1443     * Support method for initGenericRangeFilters() -- build a filter query based on
1444     * a range of values.
1445     *
1446     * @param string $field field to use for filtering.
1447     * @param string $from  start of range.
1448     * @param string $to    end of range.
1449     * @param bool   $cs    Should ranges be case-sensitive?
1450     *
1451     * @return string       filter query.
1452     */
1453    protected function buildGenericRangeFilter($field, $from, $to, $cs = true)
1454    {
1455        // Assume Solr syntax -- this should be overridden in child classes where
1456        // other indexing methodologies are used.
1457        $range = "{$field}:[{$from} TO {$to}]";
1458        if (!$cs) {
1459            // Flip values if out of order:
1460            if (strcmp(strtolower($from), strtolower($to)) > 0) {
1461                $range = "{$field}:[{$to} TO {$from}]";
1462            }
1463            $helper = new LuceneSyntaxHelper(false, false);
1464            $range = $helper->capitalizeRanges($range);
1465        }
1466        return $range;
1467    }
1468
1469    /**
1470     * Support method for initFilters() -- initialize range filters. Factored
1471     * out as a separate method so that it can be more easily overridden by child
1472     * classes.
1473     *
1474     * @param \Laminas\Stdlib\Parameters $request         Parameter object
1475     * representing user request.
1476     * @param string                     $requestParam    Name of parameter
1477     * containing names of range filter fields.
1478     * @param callable                   $valueFilter     Optional callback to
1479     * process values in the range.
1480     * @param callable                   $filterGenerator Optional callback to create
1481     * a filter query from the range values.
1482     *
1483     * @return void
1484     */
1485    protected function initGenericRangeFilters(
1486        $request,
1487        $requestParam = 'genericrange',
1488        $valueFilter = null,
1489        $filterGenerator = null
1490    ) {
1491        $rangeFacets = $request->get($requestParam);
1492        if (!empty($rangeFacets)) {
1493            $ranges = is_array($rangeFacets) ? $rangeFacets : [$rangeFacets];
1494            foreach ($ranges as $range) {
1495                // Load start and end of range:
1496                $from = $request->get($range . 'from');
1497                $to = $request->get($range . 'to');
1498
1499                // Apply filtering/validation if necessary:
1500                if (is_callable($valueFilter)) {
1501                    $from = call_user_func($valueFilter, $from, false);
1502                    $to = call_user_func($valueFilter, $to, true);
1503                }
1504
1505                // Build filter only if necessary:
1506                if (!empty($range) && ($from != '*' || $to != '*')) {
1507                    $rangeFacet = is_callable($filterGenerator)
1508                        ? call_user_func($filterGenerator, $range, $from, $to)
1509                        : $this->buildGenericRangeFilter($range, $from, $to, false);
1510                    $this->addFilter($rangeFacet);
1511                }
1512            }
1513        }
1514    }
1515
1516    /**
1517     * Support method for initNumericRangeFilters() -- build a filter query based on
1518     * a range of numbers.
1519     *
1520     * @param string $field field to use for filtering.
1521     * @param string $from  number for start of range.
1522     * @param string $to    number for end of range.
1523     *
1524     * @return string       filter query.
1525     */
1526    protected function buildNumericRangeFilter($field, $from, $to)
1527    {
1528        // Make sure that $to is less than $from:
1529        if ($to != '*' && $from != '*' && $to < $from) {
1530            $tmp = $to;
1531            $to = $from;
1532            $from = $tmp;
1533        }
1534
1535        return $this->buildGenericRangeFilter($field, $from, $to);
1536    }
1537
1538    /**
1539     * Support method for initDateFilters() -- build a filter query based on a range
1540     * of 4-digit years.
1541     *
1542     * @param string $field field to use for filtering.
1543     * @param string $from  year for start of range.
1544     * @param string $to    year for end of range.
1545     *
1546     * @return string       filter query.
1547     */
1548    protected function buildDateRangeFilter($field, $from, $to)
1549    {
1550        // Dates work just like numbers:
1551        return $this->buildNumericRangeFilter($field, $from, $to);
1552    }
1553
1554    /**
1555     * Support method for initFullDateFilters() -- build a filter query based on a
1556     * range of dates.
1557     *
1558     * @param string $field field to use for filtering.
1559     * @param string $from  year for start of range.
1560     * @param string $to    year for end of range.
1561     *
1562     * @return string       filter query.
1563     */
1564    protected function buildFullDateRangeFilter($field, $from, $to)
1565    {
1566        // Make sure that $to is less than $from:
1567        if ($to != '*' && $from != '*' && strtotime($to) < strtotime($from)) {
1568            $tmp = $to;
1569            $to = $from;
1570            $from = $tmp;
1571        }
1572
1573        return $this->buildGenericRangeFilter($field, $from, $to);
1574    }
1575
1576    /**
1577     * Support method for initFilters() -- initialize year-based date filters.
1578     * Factored out as a separate method so that it can be more easily overridden
1579     * by child classes.
1580     *
1581     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1582     * request.
1583     *
1584     * @return void
1585     */
1586    protected function initDateFilters($request)
1587    {
1588        $this->initGenericRangeFilters(
1589            $request,
1590            'daterange',
1591            [$this, 'formatYearForDateRange'],
1592            [$this, 'buildDateRangeFilter']
1593        );
1594    }
1595
1596    /**
1597     * Support method for initFilters() -- initialize year/month/day-based date
1598     * filters. Factored out as a separate method so that it can be more easily
1599     * overridden by child classes.
1600     *
1601     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1602     * request.
1603     *
1604     * @return void
1605     */
1606    protected function initFullDateFilters($request)
1607    {
1608        $this->initGenericRangeFilters(
1609            $request,
1610            'fulldaterange',
1611            [$this, 'formatDateForFullDateRange'],
1612            [$this, 'buildFullDateRangeFilter']
1613        );
1614    }
1615
1616    /**
1617     * Support method for initFilters() -- initialize numeric range filters. Factored
1618     * out as a separate method so that it can be more easily overridden by child
1619     * classes.
1620     *
1621     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1622     * request.
1623     *
1624     * @return void
1625     */
1626    protected function initNumericRangeFilters($request)
1627    {
1628        $this->initGenericRangeFilters(
1629            $request,
1630            'numericrange',
1631            [$this, 'formatValueForNumericRange'],
1632            [$this, 'buildNumericRangeFilter']
1633        );
1634    }
1635
1636    /**
1637     * Add filters to the object based on values found in the request object.
1638     *
1639     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1640     * request.
1641     *
1642     * @return void
1643     */
1644    protected function initFilters($request)
1645    {
1646        // Handle standard filters:
1647        $filter = $request->get('filter');
1648        if (!empty($filter)) {
1649            if (is_array($filter)) {
1650                foreach ($filter as $current) {
1651                    $this->addFilter($current);
1652                }
1653            } else {
1654                $this->addFilter($filter);
1655            }
1656        }
1657
1658        // If we don't have the special flag indicating that defaults have
1659        // been applied, and if we do have defaults, apply them:
1660        if ($request->get('dfApplied')) {
1661            $this->defaultsApplied = true;
1662        } else {
1663            $defaults = $this->getOptions()->getDefaultFilters();
1664            if (!empty($defaults)) {
1665                foreach ($defaults as $current) {
1666                    $this->addFilter($current);
1667                }
1668                $this->defaultsApplied = true;
1669            }
1670        }
1671
1672        // Handle range filters:
1673        $this->initRangeFilters($request);
1674    }
1675
1676    /**
1677     * Add hidden filters to the object based on values found in the request object.
1678     *
1679     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
1680     * request.
1681     *
1682     * @return void
1683     */
1684    protected function initHiddenFilters($request)
1685    {
1686        $hiddenFilters = $request->get('hiddenFilters');
1687        if (!empty($hiddenFilters) && is_array($hiddenFilters)) {
1688            foreach ($hiddenFilters as $current) {
1689                $this->addHiddenFilter($current);
1690            }
1691        }
1692    }
1693
1694    /**
1695     * Get hidden filters grouped by field like normal filters.
1696     *
1697     * @return array
1698     */
1699    public function getHiddenFilters()
1700    {
1701        return $this->hiddenFilters;
1702    }
1703
1704    /**
1705     * Get the hidden filter list as a query parameter array.
1706     *
1707     * Returns an array of strings that parseFilter can parse.
1708     *
1709     * @return array
1710     */
1711    public function getHiddenFiltersAsQueryParams(): array
1712    {
1713        return $this->formatFilterArrayAsQueryParams($this->getHiddenFilters());
1714    }
1715
1716    /**
1717     * Does the object already contain the specified hidden filter?
1718     *
1719     * @param string $filter A filter string from url : "field:value"
1720     *
1721     * @return bool
1722     */
1723    public function hasHiddenFilter($filter)
1724    {
1725        // Extract field and value from URL string:
1726        [$field, $value] = $this->parseFilter($filter);
1727
1728        if (
1729            isset($this->hiddenFilters[$field])
1730            && in_array($value, $this->hiddenFilters[$field])
1731        ) {
1732            return true;
1733        }
1734        return false;
1735    }
1736
1737    /**
1738     * Take a filter string and add it into the protected hidden filters
1739     *   array checking for duplicates.
1740     *
1741     * @param string $newFilter A filter string from url : "field:value"
1742     *
1743     * @return void
1744     */
1745    public function addHiddenFilter($newFilter)
1746    {
1747        // Check for duplicates -- if it's not in the array, we can add it
1748        if (!$this->hasHiddenFilter($newFilter)) {
1749            // Extract field and value from filter string:
1750            [$field, $value] = $this->parseFilter($newFilter);
1751            if (!empty($field) && '' !== $value) {
1752                $this->hiddenFilters[$field][] = $value;
1753            }
1754        }
1755    }
1756
1757    /**
1758     * Take a filter string and add it into the protected hidden filters
1759     *   array checking for duplicates.
1760     *
1761     * @param string $field Field
1762     * @param string $value Filter value
1763     *
1764     * @return void
1765     */
1766    public function addHiddenFilterForField(string $field, string $value): void
1767    {
1768        $this->addHiddenFilter("$field:\"$value\"");
1769    }
1770
1771    /**
1772     * Return a query string for the current search with a search term replaced.
1773     *
1774     * @param string $oldTerm The old term to replace
1775     * @param string $newTerm The new term to search
1776     *
1777     * @return string         query string
1778     */
1779    public function getDisplayQueryWithReplacedTerm($oldTerm, $newTerm)
1780    {
1781        // Stash our old data for a minute
1782        $oldTerms = clone $this->query;
1783        // Replace the search term
1784        $this->query->replaceTerm($oldTerm, $newTerm);
1785        // Get the new query string
1786        $query = $this->getDisplayQuery();
1787        // Restore the old data
1788        $this->query = $oldTerms;
1789        // Return the query string
1790        return $query;
1791    }
1792
1793    /**
1794     * Basic 'getter' for list of available view options.
1795     *
1796     * @return array
1797     */
1798    public function getViewList()
1799    {
1800        $list = [];
1801        foreach ($this->getOptions()->getViewOptions() as $key => $value) {
1802            $list[$key] = [
1803                'desc' => $value,
1804                'selected' => ($key == $this->getView()),
1805            ];
1806        }
1807        return $list;
1808    }
1809
1810    /**
1811     * Return a list of urls for possible limits, along with which option
1812     *    should be currently selected.
1813     *
1814     * @return array Limit urls, descriptions and selected flags
1815     */
1816    public function getLimitList()
1817    {
1818        // Loop through all the current limits
1819        $valid = $this->getOptions()->getLimitOptions();
1820        $defaultLimit = $this->getOptions()->getDefaultLimit();
1821        $list = [];
1822        foreach ($valid as $limit) {
1823            $list[$limit] = [
1824                'desc' => $limit,
1825                'selected' => ($limit == $this->getLimit()),
1826                'default' => $limit == $defaultLimit,
1827            ];
1828        }
1829        return $list;
1830    }
1831
1832    /**
1833     * Return a list of urls for sorting, along with which option
1834     *    should be currently selected.
1835     *
1836     * @return array Sort urls, descriptions and selected flags
1837     */
1838    public function getSortList()
1839    {
1840        // Loop through all the current filter fields
1841        $valid = $this->getOptions()->getSortOptions();
1842        $defaultSort = $this->getDefaultSort();
1843        $list = [];
1844        foreach ($valid as $sort => $desc) {
1845            $list[$sort] = [
1846                'desc' => $desc,
1847                'selected' => ($sort == $this->getSort()),
1848                'default' => $sort == $defaultSort,
1849            ];
1850        }
1851        return $list;
1852    }
1853
1854    /**
1855     * Store settings to a minified object
1856     *
1857     * @param Minified $minified Minified Search Object
1858     *
1859     * @return void
1860     */
1861    public function minify(Minified &$minified): void
1862    {
1863        $minified->ty = $this->getSearchType();
1864        $minified->cl = $this->getSearchClassId();
1865
1866        // Search terms, we'll shorten keys
1867        $query = $this->getQuery();
1868        $minified->t = $this->getQueryAdapter()->minify($query);
1869
1870        // It would be nice to shorten filter fields too, but
1871        //      it would be a nightmare to maintain.
1872        $minified->f = $this->getRawFilters();
1873        $minified->hf = $this->getHiddenFilters();
1874
1875        $minified->scp = [
1876            'page' => $this->getPage(),
1877            'limit' => $this->getLimit(),
1878        ];
1879    }
1880
1881    /**
1882     * Restore settings from a minified object found in the database.
1883     *
1884     * @param \VuFind\Search\Minified $minified Minified Search Object
1885     *
1886     * @return void
1887     */
1888    public function deminify($minified)
1889    {
1890        // Some values will transfer without changes
1891        $this->filterList = $minified->f;
1892        $this->hiddenFilters = $minified->hf;
1893        $this->searchType = $minified->ty;
1894        $this->searchContextParameters = $minified->scp;
1895
1896        // Deminified searches will always have defaults already applied;
1897        // we don't want to accidentally manipulate them further.
1898        $defaults = $this->getOptions()->getDefaultFilters();
1899        if (!empty($defaults)) {
1900            $this->defaultsApplied = true;
1901        }
1902
1903        // Search terms, we need to expand keys
1904        $this->query = $this->getQueryAdapter()->deminify($minified->t);
1905    }
1906
1907    /**
1908     * Get remembered search context parameters from saved search. We track these separately since
1909     * in some contexts we want to use them (e.g. linking back to a search in breadcrumbs), but in
1910     * other contexts we want to ignore them (e.g. comparing two searches to see if they represent
1911     * the same query -- because page 1 and page 2 still represent the same overall search).
1912     *
1913     * @return array
1914     */
1915    public function getSavedSearchContextParameters(): array
1916    {
1917        return $this->searchContextParameters;
1918    }
1919
1920    /**
1921     * Override the normal search behavior with an explicit array of IDs that must
1922     * be retrieved.
1923     *
1924     * @param array $ids Record IDs to load
1925     *
1926     * @return void
1927     *
1928     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1929     */
1930    public function setQueryIDs($ids)
1931    {
1932        // This needs to be defined in child classes:
1933        throw new \Exception(get_class($this) . ' does not support setQueryIDs().');
1934    }
1935
1936    /**
1937     * Get the maximum number of IDs that may be sent to setQueryIDs (-1 for no
1938     * limit).
1939     *
1940     * @return int
1941     */
1942    public function getQueryIDLimit()
1943    {
1944        return -1;
1945    }
1946
1947    /**
1948     * Get an array of the names of all selected shards. These should correspond
1949     * with keys in the array returned by the option class's getShards() method.
1950     *
1951     * @return array
1952     */
1953    public function getSelectedShards()
1954    {
1955        return $this->selectedShards;
1956    }
1957
1958    /**
1959     * Translate a string (or string-castable object)
1960     *
1961     * @param string|object|array $target  String to translate or an array of text
1962     * domain and string to translate
1963     * @param array               $tokens  Tokens to inject into the translated
1964     * string
1965     * @param string              $default Default value to use if no translation is
1966     * found (null for no default).
1967     *
1968     * @return string
1969     */
1970    public function translate($target, $tokens = [], $default = null)
1971    {
1972        return $this->getOptions()->translate($target, $tokens, $default);
1973    }
1974
1975    /**
1976     * Set the override query
1977     *
1978     * @param string $q Override query
1979     *
1980     * @return void
1981     */
1982    public function setOverrideQuery($q)
1983    {
1984        $this->overrideQuery = $q;
1985    }
1986
1987    /**
1988     * Get the override query
1989     *
1990     * @return string
1991     */
1992    public function getOverrideQuery()
1993    {
1994        return $this->overrideQuery;
1995    }
1996
1997    /**
1998     * Return search query object.
1999     *
2000     * @return AbstractQuery
2001     */
2002    public function getQuery()
2003    {
2004        if ($this->overrideQuery) {
2005            return new Query($this->overrideQuery);
2006        }
2007        return $this->query;
2008    }
2009
2010    /**
2011     * Set search query object.
2012     *
2013     * @param AbstractQuery $query Query
2014     *
2015     * @return void
2016     */
2017    public function setQuery(AbstractQuery $query): void
2018    {
2019        if ($this->overrideQuery) {
2020            $this->overrideQuery = false;
2021        }
2022        $this->query = $query;
2023    }
2024
2025    /**
2026     * Initialize facet settings for the specified configuration sections.
2027     *
2028     * @param string $facetList     Config section containing fields to activate
2029     * @param string $facetSettings Config section containing related settings
2030     * @param string $cfgFile       Name of configuration to load (null to load
2031     * default facets configuration).
2032     *
2033     * @return bool                 True if facets set, false if no settings found
2034     */
2035    protected function initFacetList($facetList, $facetSettings, $cfgFile = null)
2036    {
2037        $config = $this->configLoader
2038            ->get($cfgFile ?? $this->getOptions()->getFacetsIni());
2039        if (!isset($config->$facetList)) {
2040            return false;
2041        }
2042        if (isset($config->$facetSettings->orFacets)) {
2043            $orFields
2044                = array_map('trim', explode(',', $config->$facetSettings->orFacets));
2045        } else {
2046            $orFields = [];
2047        }
2048        foreach ($config->$facetList as $key => $value) {
2049            $useOr = (isset($orFields[0]) && $orFields[0] == '*')
2050                || in_array($key, $orFields);
2051            $this->addFacet($key, $value, $useOr);
2052        }
2053
2054        return true;
2055    }
2056
2057    /**
2058     * Are default filters applied?
2059     *
2060     * @return bool
2061     */
2062    public function hasDefaultsApplied()
2063    {
2064        return $this->defaultsApplied;
2065    }
2066
2067    /**
2068     * Initialize checkbox facet settings for the specified configuration sections.
2069     *
2070     * @param string $facetList Config section containing fields to activate
2071     * @param string $cfgFile   Name of configuration to load (null to load
2072     * default facets configuration).
2073     *
2074     * @return bool             True if facets set, false if no settings found
2075     */
2076    protected function initCheckboxFacets(
2077        $facetList = 'CheckboxFacets',
2078        $cfgFile = null
2079    ) {
2080        $config = $this->configLoader
2081            ->get($cfgFile ?? $this->getOptions()->getFacetsIni());
2082        $retVal = false;
2083        // If the section is in reverse order, the tilde will flag this:
2084        if (str_starts_with($facetList, '~')) {
2085            foreach ($config->{substr($facetList, 1)} ?? [] as $value => $key) {
2086                $this->addCheckboxFacet($key, $value);
2087                $retVal = true;
2088            }
2089        } else {
2090            foreach ($config->$facetList ?? [] as $key => $value) {
2091                $this->addCheckboxFacet($key, $value);
2092                $retVal = true;
2093            }
2094        }
2095        return $retVal;
2096    }
2097
2098    /**
2099     * Check whether a specific facet supports filtering
2100     *
2101     * @param string $facet The facet to check
2102     *
2103     * @return bool
2104     */
2105    public function supportsFacetFiltering($facet)
2106    {
2107        $translatedFacets = $this->getOptions()->getTranslatedFacets();
2108        return method_exists($this, 'setFacetContains') && !in_array($facet, $translatedFacets);
2109    }
2110}