Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.33% covered (warning)
58.33%
133 / 228
13.04% covered (danger)
13.04%
3 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Params
58.33% covered (warning)
58.33%
133 / 228
13.04% covered (danger)
13.04%
3 / 23
634.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 getFilterSettings
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
9
 getFacetSettings
66.67% covered (warning)
66.67%
20 / 30
0.00% covered (danger)
0.00%
0 / 1
23.33
 initSearch
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setFacetContains
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFacetContainsIgnoreCase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFacetOffset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFacetPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFacetSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIndexSortedFacets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initFacetList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 initAdvancedFacets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initHomePageFacets
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 initNewItemsFacets
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 initFilters
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 setQueryIDs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getQueryIDLimit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeSort
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
7
 getBackendParameters
82.05% covered (warning)
82.05%
32 / 39
0.00% covered (danger)
0.00%
0 / 1
18.67
 setPivotFacets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPivotFacets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatFilterListEntry
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
 getCheckboxFacets
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * Solr aspect of the Search Multi-class (Params)
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2011.
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_Solr
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @author   Ere Maijala <ere.maijala@helsinki.fi>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org Main Page
29 */
30
31namespace VuFind\Search\Solr;
32
33use VuFindSearch\ParamBag;
34
35use function count;
36use function in_array;
37use function is_array;
38
39/**
40 * Solr Search Parameters
41 *
42 * @category VuFind
43 * @package  Search_Solr
44 * @author   Demian Katz <demian.katz@villanova.edu>
45 * @author   Ere Maijala <ere.maijala@helsinki.fi>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org Main Page
48 */
49class Params extends \VuFind\Search\Base\Params
50{
51    use \VuFind\Search\Params\FacetLimitTrait;
52    use \VuFind\Search\Params\FacetRestrictionsTrait;
53
54    /**
55     * Search with facet.contains
56     * cf. https://lucene.apache.org/solr/guide/7_3/faceting.html
57     *
58     * @var string
59     */
60    protected $facetContains = null;
61
62    /**
63     * Ignore Case when using facet.contains
64     * cf. https://lucene.apache.org/solr/guide/7_3/faceting.html
65     *
66     * @var bool
67     */
68    protected $facetContainsIgnoreCase = null;
69
70    /**
71     * Offset for facet results
72     *
73     * @var int
74     */
75    protected $facetOffset = null;
76
77    /**
78     * Prefix for facet searching
79     *
80     * @var string
81     */
82    protected $facetPrefix = null;
83
84    /**
85     * Sorting order for facet search results
86     *
87     * @var string
88     */
89    protected $facetSort = null;
90
91    /**
92     * Sorting order of single facet by index
93     *
94     * @var array
95     */
96    protected $indexSortedFacets = null;
97
98    /**
99     * Fields for visual faceting
100     *
101     * @var string
102     */
103    protected $pivotFacets = null;
104
105    /**
106     * Hierarchical Facet Helper
107     *
108     * @var HierarchicalFacetHelper
109     */
110    protected $facetHelper;
111
112    /**
113     * Are we searching by ID only (instead of a normal query)?
114     *
115     * @var bool
116     */
117    protected $searchingById = false;
118
119    /**
120     * Config sections to search for facet labels if no override configuration
121     * is set.
122     *
123     * @var array
124     */
125    protected $defaultFacetLabelSections
126        = ['Advanced', 'HomePage', 'ResultsTop', 'Results', 'ExtraFacetLabels'];
127
128    /**
129     * Config sections to search for checkbox facet labels if no override
130     * configuration is set.
131     *
132     * @var array
133     */
134    protected $defaultFacetLabelCheckboxSections = ['CheckboxFacets'];
135
136    /**
137     * Constructor
138     *
139     * @param \VuFind\Search\Base\Options  $options      Options to use
140     * @param \VuFind\Config\PluginManager $configLoader Config loader
141     * @param HierarchicalFacetHelper      $facetHelper  Hierarchical facet helper
142     */
143    public function __construct(
144        $options,
145        \VuFind\Config\PluginManager $configLoader,
146        HierarchicalFacetHelper $facetHelper = null
147    ) {
148        parent::__construct($options, $configLoader);
149        $this->facetHelper = $facetHelper;
150
151        // Use basic facet limit by default, if set:
152        $config = $configLoader->get($options->getFacetsIni());
153        $this->initFacetLimitsFromConfig($config->Results_Settings ?? null);
154        $this->initFacetRestrictionsFromConfig($config->Results_Settings ?? null);
155        if (isset($config->LegacyFields)) {
156            $this->facetAliases = $config->LegacyFields->toArray();
157        }
158        if (
159            isset($config->Results_Settings->sorted_by_index)
160            && count($config->Results_Settings->sorted_by_index) > 0
161        ) {
162            $this->setIndexSortedFacets(
163                $config->Results_Settings->sorted_by_index->toArray()
164            );
165        }
166    }
167
168    /**
169     * Return the current filters as an array of strings ['field:filter']
170     *
171     * @return array $filterQuery
172     */
173    public function getFilterSettings()
174    {
175        // Define Filter Query
176        $filterQuery = [];
177        $orFilters = [];
178        $filterList = array_merge_recursive(
179            $this->getHiddenFilters(),
180            $this->filterList
181        );
182        foreach ($filterList as $field => $filter) {
183            if ($orFacet = str_starts_with($field, '~')) {
184                $field = substr($field, 1);
185            }
186            foreach ($filter as $value) {
187                // Special case -- complex filter, that should be taken as-is:
188                if ($field == '#') {
189                    $q = $value;
190                } elseif (
191                    str_ends_with($value, '*')
192                    || preg_match('/\[[^\]]+\s+TO\s+[^\]]+\]/', $value)
193                ) {
194                    // Special case -- allow trailing wildcards and ranges
195                    $q = $field . ':' . $value;
196                } else {
197                    $q = $field . ':"' . addcslashes($value, '"\\') . '"';
198                }
199                if ($orFacet) {
200                    $orFilters[$field] ??= [];
201                    $orFilters[$field][] = $q;
202                } else {
203                    $filterQuery[] = $q;
204                }
205            }
206        }
207        foreach ($orFilters as $field => $parts) {
208            $filterQuery[] = '{!tag=' . $field . '_filter}' . $field
209                . ':(' . implode(' OR ', $parts) . ')';
210        }
211        return $filterQuery;
212    }
213
214    /**
215     * Return current facet configurations
216     *
217     * @return array $facetSet
218     */
219    public function getFacetSettings()
220    {
221        // Build a list of facets we want from the index
222        $facetSet = [];
223
224        if (!empty($this->facetConfig)) {
225            $facetSet['limit'] = $this->facetLimit;
226            foreach (array_keys($this->facetConfig) as $facetField) {
227                $fieldLimit = $this->getFacetLimitForField($facetField);
228                if ($fieldLimit != $this->facetLimit) {
229                    $facetSet["f.{$facetField}.facet.limit"] = $fieldLimit;
230                }
231                $fieldPrefix = $this->getFacetPrefixForField($facetField);
232                if (!empty($fieldPrefix)) {
233                    $facetSet["f.{$facetField}.facet.prefix"] = $fieldPrefix;
234                }
235                $fieldMatches = $this->getFacetMatchesForField($facetField);
236                if (!empty($fieldMatches)) {
237                    $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches;
238                }
239                if ($this->getFacetOperator($facetField) == 'OR') {
240                    $facetField = '{!ex=' . $facetField . '_filter}' . $facetField;
241                }
242                $facetSet['field'][] = $facetField;
243            }
244            if ($this->facetContains != null) {
245                $facetSet['contains'] = $this->facetContains;
246            }
247            if ($this->facetContainsIgnoreCase != null) {
248                $facetSet['contains.ignoreCase']
249                    = $this->facetContainsIgnoreCase ? 'true' : 'false';
250            }
251            if ($this->facetOffset != null) {
252                $facetSet['offset'] = $this->facetOffset;
253            }
254            if ($this->facetPrefix != null) {
255                $facetSet['prefix'] = $this->facetPrefix;
256            }
257            $facetSet['sort'] = $this->facetSort ?: 'count';
258            if ($this->indexSortedFacets != null) {
259                foreach ($this->indexSortedFacets as $field) {
260                    $facetSet["f.{$field}.facet.sort"] = 'index';
261                }
262            }
263        }
264        return $facetSet;
265    }
266
267    /**
268     * Initialize the object's search settings from a request object.
269     *
270     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
271     * request.
272     *
273     * @return void
274     */
275    protected function initSearch($request)
276    {
277        // Special case -- did we get a list of IDs instead of a standard query?
278        $ids = $request->get('overrideIds', null);
279        if (is_array($ids)) {
280            $this->setQueryIDs($ids);
281        } else {
282            // Use standard initialization:
283            parent::initSearch($request);
284        }
285    }
286
287    /**
288     * Set Facet Contains
289     *
290     * @param string $p the new contains value
291     *
292     * @return void
293     */
294    public function setFacetContains($p)
295    {
296        $this->facetContains = $p;
297    }
298
299    /**
300     * Set Facet Contains Ignore Case
301     *
302     * @param bool $val the new boolean value
303     *
304     * @return void
305     */
306    public function setFacetContainsIgnoreCase($val)
307    {
308        $this->facetContainsIgnoreCase = $val;
309    }
310
311    /**
312     * Set Facet Offset
313     *
314     * @param int $o the new offset value
315     *
316     * @return void
317     */
318    public function setFacetOffset($o)
319    {
320        $this->facetOffset = $o;
321    }
322
323    /**
324     * Set Facet Prefix
325     *
326     * @param string $p the new prefix value
327     *
328     * @return void
329     */
330    public function setFacetPrefix($p)
331    {
332        $this->facetPrefix = $p;
333    }
334
335    /**
336     * Set Facet Sorting
337     *
338     * @param string $s the new sorting action value
339     *
340     * @return void
341     */
342    public function setFacetSort($s)
343    {
344        $this->facetSort = $s;
345    }
346
347    /**
348     * Set Index Facet Sorting
349     *
350     * @param array $s the facets sorted by index
351     *
352     * @return void
353     */
354    public function setIndexSortedFacets(array $s)
355    {
356        $this->indexSortedFacets = $s;
357    }
358
359    /**
360     * Initialize facet settings for the specified configuration sections.
361     *
362     * @param string $facetList     Config section containing fields to activate
363     * @param string $facetSettings Config section containing related settings
364     * @param string $cfgFile       Name of configuration to load (null to load
365     * default facets configuration).
366     *
367     * @return bool                 True if facets set, false if no settings found
368     */
369    protected function initFacetList($facetList, $facetSettings, $cfgFile = null)
370    {
371        $config = $this->configLoader
372            ->get($cfgFile ?? $this->getOptions()->getFacetsIni());
373        $this->initFacetLimitsFromConfig($config->$facetSettings ?? null);
374        return parent::initFacetList($facetList, $facetSettings, $cfgFile);
375    }
376
377    /**
378     * Initialize facet settings for the advanced search screen.
379     *
380     * @return void
381     */
382    public function initAdvancedFacets()
383    {
384        $this->initFacetList('Advanced', 'Advanced_Settings');
385    }
386
387    /**
388     * Initialize facet settings for the home page.
389     *
390     * @return void
391     */
392    public function initHomePageFacets()
393    {
394        // Load Advanced settings if HomePage settings are missing (legacy support):
395        if (!$this->initFacetList('HomePage', 'HomePage_Settings')) {
396            $this->initAdvancedFacets();
397        }
398    }
399
400    /**
401     * Initialize facet settings for the new items page.
402     *
403     * @return void
404     */
405    public function initNewItemsFacets()
406    {
407        // Load Advanced settings if NewItems settings are missing (fallback to defaults):
408        if (!$this->initFacetList('NewItems', 'NewItems_Settings')) {
409            $this->initAdvancedFacets();
410        }
411    }
412
413    /**
414     * Add filters to the object based on values found in the request object.
415     *
416     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
417     * request.
418     *
419     * @return void
420     */
421    protected function initFilters($request)
422    {
423        // Use the default behavior of the parent class, but add support for the
424        // special illustrations filter.
425        parent::initFilters($request);
426        switch ($request->get('illustration', -1)) {
427            case 1:
428                $this->addFilter('illustrated:Illustrated');
429                break;
430            case 0:
431                $this->addFilter('illustrated:"Not Illustrated"');
432                break;
433        }
434    }
435
436    /**
437     * Override the normal search behavior with an explicit array of IDs that must
438     * be retrieved.
439     *
440     * @param array $ids Record IDs to load
441     *
442     * @return void
443     */
444    public function setQueryIDs($ids)
445    {
446        // No need for spell checking or highlighting on an ID query!
447        $this->getOptions()->spellcheckEnabled(false);
448        $this->getOptions()->disableHighlighting();
449
450        // Special case -- no IDs to set:
451        if (empty($ids)) {
452            $this->setOverrideQuery('NOT *:*');
453            return;
454        }
455
456        $callback = function ($i) {
457            return '"' . addcslashes($i, '"') . '"';
458        };
459        $ids = array_map($callback, $ids);
460        $this->searchingById = true;
461        $this->setOverrideQuery('id:(' . implode(' OR ', $ids) . ')');
462    }
463
464    /**
465     * Get the maximum number of IDs that may be sent to setQueryIDs (-1 for no
466     * limit).
467     *
468     * @return int
469     */
470    public function getQueryIDLimit()
471    {
472        $config = $this->configLoader->get($this->getOptions()->getMainIni());
473        return $config->Index->maxBooleanClauses ?? 1024;
474    }
475
476    /**
477     * Normalize sort parameters.
478     *
479     * @param string $sort Sort parameter
480     *
481     * @return string
482     */
483    protected function normalizeSort($sort)
484    {
485        static $table = [
486            'year' => ['field' => 'publishDateSort', 'order' => 'desc'],
487            'publishDateSort' => ['field' => 'publishDateSort', 'order' => 'desc'],
488            'author' => ['field' => 'author_sort', 'order' => 'asc'],
489            'authorStr' => ['field' => 'author_sort', 'order' => 'asc'],
490            'title' => ['field' => 'title_sort', 'order' => 'asc'],
491            'relevance' => ['field' => 'score', 'order' => 'desc'],
492            'callnumber' => ['field' => 'callnumber-sort', 'order' => 'asc'],
493        ];
494        $tieBreaker = $this->getOptions()->getSortTieBreaker();
495        if ($tieBreaker) {
496            $sort .= ',' . $tieBreaker;
497        }
498
499        $normalized = [];
500        $fields = [];
501        foreach (explode(',', $sort) as $component) {
502            $parts = explode(' ', trim($component));
503            $field = reset($parts);
504            $order = next($parts);
505            if (isset($table[$field])) {
506                $normalized[] = sprintf(
507                    '%s %s',
508                    $table[$field]['field'],
509                    $order ?: $table[$field]['order']
510                );
511                $fields[] = $field;
512            } else {
513                if (!in_array($field, $fields)) {
514                    $normalized[] = sprintf(
515                        '%s %s',
516                        $field,
517                        $order ?: 'asc'
518                    );
519                    $fields[] = $field;
520                }
521            }
522        }
523        return implode(',', $normalized);
524    }
525
526    /**
527     * Create search backend parameters for advanced features.
528     *
529     * @return ParamBag
530     */
531    public function getBackendParameters()
532    {
533        $backendParams = new ParamBag();
534
535        // Spellcheck
536        $backendParams->set(
537            'spellcheck',
538            $this->getOptions()->spellcheckEnabled() ? 'true' : 'false'
539        );
540
541        // Facets
542        $facets = $this->getFacetSettings();
543        if (!empty($facets)) {
544            $backendParams->add('facet', 'true');
545
546            foreach ($facets as $key => $value) {
547                // prefix keys with "facet" unless they already have a "f." prefix:
548                $fullKey = str_starts_with($key, 'f.') ? $key : "facet.$key";
549                $backendParams->add($fullKey, $value);
550            }
551            $backendParams->add('facet.mincount', 1);
552        }
553
554        // Filters
555        $filters = $this->getFilterSettings();
556        foreach ($filters as $filter) {
557            $backendParams->add('fq', $filter);
558        }
559
560        // Shards
561        $allShards = $this->getOptions()->getShards();
562        $shards = $this->getSelectedShards();
563        if (empty($shards)) {
564            $shards = array_keys($allShards);
565        }
566
567        // If we have selected shards, we need to format them:
568        if (!empty($shards)) {
569            $selectedShards = [];
570            foreach ($shards as $current) {
571                $selectedShards[$current] = $allShards[$current];
572            }
573            $backendParams->add('shards', implode(',', $selectedShards));
574        }
575
576        // Sort
577        $sort = $this->getSort();
578        if ($sort) {
579            // If we have an empty search with relevance sort as the primary sort
580            // field, see if there is an override configured:
581            $sortFields = explode(',', $sort);
582            $allTerms = trim($this->getQuery()->getAllTerms() ?? '');
583            if (
584                'relevance' === $sortFields[0]
585                && ('' === $allTerms || '*:*' === $allTerms || $this->searchingById)
586                && ($relOv = $this->getOptions()->getEmptySearchRelevanceOverride())
587            ) {
588                $sort = $relOv;
589            }
590            $backendParams->add('sort', $this->normalizeSort($sort));
591        }
592
593        // Highlighting -- on by default, but we should disable if necessary:
594        if (!$this->getOptions()->highlightEnabled()) {
595            $backendParams->add('hl', 'false');
596        }
597
598        // Pivot facets for visual results
599
600        if ($pf = $this->getPivotFacets()) {
601            $backendParams->add('facet.pivot', $pf);
602            $backendParams->set('facet', 'true');
603        }
604
605        return $backendParams;
606    }
607
608    /**
609     * Set pivot facet fields to use for visual results
610     *
611     * @param string $facets A comma-separated list of fields
612     *
613     * @return void
614     */
615    public function setPivotFacets($facets)
616    {
617        $this->pivotFacets = $facets;
618    }
619
620    /**
621     * Get pivot facet information for visual facets
622     *
623     * @return string
624     */
625    public function getPivotFacets()
626    {
627        return $this->pivotFacets;
628    }
629
630    /**
631     * Format a single filter for use in getFilterList().
632     *
633     * @param string $field     Field name
634     * @param string $value     Field value
635     * @param string $operator  Operator (AND/OR/NOT)
636     * @param bool   $translate Should we translate the label?
637     *
638     * @return array
639     */
640    protected function formatFilterListEntry($field, $value, $operator, $translate)
641    {
642        $filter = parent::formatFilterListEntry(
643            $field,
644            $value,
645            $operator,
646            $translate
647        );
648
649        $hierarchicalFacets = $this->getOptions()->getHierarchicalFacets();
650        $hierarchicalFacetSeparators
651            = $this->getOptions()->getHierarchicalFacetSeparators();
652        // Convert range queries to a language-non-specific format:
653        $caseInsensitiveRegex = '/^\(\[(.*) TO (.*)\] OR \[(.*) TO (.*)\]\)$/';
654        if (preg_match('/^\[(.*) TO (.*)\]$/', $value, $matches)) {
655            // Simple case: [X TO Y]
656            $filter['displayText'] = $matches[1] . ' - ' . $matches[2];
657        } elseif (preg_match($caseInsensitiveRegex, $value, $matches)) {
658            // Case insensitive case: [x TO y] OR [X TO Y]; convert
659            // only if values in both ranges match up!
660            if (
661                strtolower($matches[3]) == strtolower($matches[1])
662                && strtolower($matches[4]) == strtolower($matches[2])
663            ) {
664                $filter['displayText'] = $matches[1] . ' - ' . $matches[2];
665            }
666        } elseif ($this->facetHelper && in_array($field, $hierarchicalFacets)) {
667            // Display hierarchical facet levels nicely
668            $separator = $hierarchicalFacetSeparators[$field] ?? '/';
669            if (!$translate) {
670                $filter['displayText'] = $this->facetHelper->formatDisplayText(
671                    $filter['displayText'],
672                    true,
673                    $separator
674                )->getDisplayString();
675            } else {
676                $domain = $this->getOptions()
677                    ->getTextDomainForTranslatedFacet($field);
678
679                // Provide translation of each separate element as a default
680                // while allowing one to translate the full string too:
681                $parts = $this->facetHelper
682                    ->getFilterStringParts($filter['value']);
683                $translated = [];
684                foreach ($parts as $part) {
685                    $translated[] = $this->translate([$domain, $part]);
686                }
687                $translatedParts = implode($separator, $translated);
688
689                $parts = array_map(
690                    function ($part) {
691                        return $part->getDisplayString();
692                    },
693                    $parts
694                );
695                $str = implode($separator, $parts);
696                $filter['displayText']
697                    = $this->translate([$domain, $str], [], $translatedParts);
698            }
699        }
700
701        return $filter;
702    }
703
704    /**
705     * Get information on the current state of the boolean checkbox facets.
706     *
707     * @param array $include        List of checkbox filters to return (null for all)
708     * @param bool  $includeDynamic Should we include dynamically-generated
709     * checkboxes that are not part of the include list above?
710     *
711     * @return array
712     */
713    public function getCheckboxFacets(
714        array $include = null,
715        bool $includeDynamic = true
716    ) {
717        // Grab checkbox facet details using the standard method:
718        $facets = parent::getCheckboxFacets($include, $includeDynamic);
719
720        $config = $this->configLoader->get($this->getOptions()->getFacetsIni());
721        $filterField = $config->CustomFilters->custom_filter_field ?? 'vufind';
722
723        // Special case -- inverted checkbox facets should always appear, even on
724        // the "no results" screen, since setting them actually EXPANDS rather than
725        // reduces the result set.
726        foreach ($facets as $i => $facet) {
727            // Append colon on end to ensure that $customFilter is always set.
728            [$field, $customFilter] = explode(':', $facet['filter'] . ':');
729            if (
730                $field == $filterField
731                && isset($config->CustomFilters->inverted_filters[$customFilter])
732            ) {
733                $facets[$i]['alwaysVisible'] = true;
734            }
735        }
736
737        // Return modified list:
738        return $facets;
739    }
740}