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